R/widget-textarea.R

Defines functions r_highlighter text_area editor_wrap_starts

Documented in r_highlighter text_area

#' @title TextArea widget
#' @description A multi-line text editor. See [text_area()].
#' @rdname TextArea-class
#' @export
TextArea <- R6::R6Class(
  "TextArea",
  inherit = Widget,
  public = list(
    #' @field focusable Text areas can be focused.
    focusable = TRUE,
    #' @field placeholder Text shown while the area is empty.
    placeholder = "",
    #' @field line_numbers Show a line number gutter?
    line_numbers = FALSE,
    #' @field wrap Wrap long lines (`TRUE`) or scroll horizontally?
    wrap = TRUE,
    #' @field read_only Block editing (cursor, selection, copy and search
    #'   still work)?
    read_only = FALSE,
    #' @field language Optional language name passed to the configured highlighter.
    language = NULL,
    #' @field tab_size Spaces inserted by Tab (and used for tabs in loaded
    #'   text).
    tab_size = 4L,
    #' @field tab_behavior `"focus"`: Tab moves focus (default, safe in
    #'   forms); `"indent"`: Tab inserts spaces and Shift+Tab removes them.
    tab_behavior = "focus",
    #' @field auto_indent Copy the indentation of the current line on Enter?
    auto_indent = FALSE,
    #' @field highlighter Optional function `(lines, state)` returning a list
    #'   of per-line data frames with 1-based inclusive `start`, `end`, `token`.
    highlighter = NULL,
    #' @field validate `NULL` or `function(value)` returning `NULL` (valid)
    #'   or an error message.
    validate = NULL,

    #' @description Create a text area.
    #' @param value Initial text.
    #' @param placeholder Placeholder text.
    #' @param id,classes,style,disabled See [Widget].
    #' @param line_numbers,wrap,read_only,language,tab_size,tab_behavior,auto_indent
    #'   See the fields.
    #' @param validate Validation function.
    #' @param max_history Maximum number of undo steps.
    initialize = function(value = "", placeholder = "", id = NULL, classes = NULL, style = NULL,
                          disabled = FALSE, line_numbers = FALSE, wrap = TRUE, read_only = FALSE,
                          language = NULL, tab_size = 4L, tab_behavior = "focus",
                          auto_indent = FALSE, validate = NULL, max_history = 200L, highlighter = NULL) {
      super$initialize(id = id, classes = classes, style = style, disabled = disabled)
      check_scalar_character(placeholder, "placeholder")
      check_flag(line_numbers)
      check_flag(wrap)
      check_flag(read_only)
      check_flag(auto_indent)
      check_function(validate, "validate", allow_null = TRUE)
      check_function(highlighter, "highlighter", allow_null = TRUE)
      self$tab_behavior <- check_choice(tab_behavior, c("focus", "indent"), "tab_behavior")
      self$tab_size <- max(1L, check_count(tab_size, "tab_size"))
      self$placeholder <- placeholder
      self$line_numbers <- line_numbers
      self$wrap <- wrap
      self$read_only <- read_only
      self$language <- language
      self$auto_indent <- auto_indent
      self$highlighter <- highlighter
      self$validate <- validate
      private$.buf <- TextBuffer$new(value, self$tab_size)
      private$.highlight_from <- 1L
      private$.undo <- UndoStack$new(max_entries = check_count(max_history, "max_history"))
      private$.wrap_cache <- new.env(parent = emptyenv())
      private$.highlight_cache <- new.env(parent = emptyenv())
      initial <- list(
        cursor_row = 1L, cursor_col = 0L, anchor_row = NA_integer_, anchor_col = NA_integer_,
        top = 1L, sub = 0L, left = 0L
      )
      for (name in names(initial)) private$.state[[name]] <- initial[[name]]
      private$.state$error <- run_validator(validate, private$.buf$text())
    },

    #' @description The built-in style.
    default_style = function() {
      style(
        width = "1fr", height = "1fr", border = "round", padding = c(0, 0),
        border_color = "$muted",
        focus = style(border_color = "$accent"),
        disabled = style(foreground = "$muted"),
        states = list(invalid = style(border_color = "$error"))
      )
    },

    #' @description Active states: focus, hover, disabled and invalid.
    pseudo_states = function() c(super$pseudo_states(), if (!is.null(private$.state$error)) "invalid"),

    #' @description Editing key bindings.
    default_bindings = function() {
      c(list(
        bind("left", "cursor_left"), bind("right", "cursor_right"),
        bind("up", "cursor_up"), bind("down", "cursor_down"),
        bind("home", "line_start"), bind("end,ctrl+e", "line_end"),
        bind("ctrl+home", "document_start"), bind("ctrl+end", "document_end"),
        bind("ctrl+left,alt+b", "word_left"), bind("ctrl+right,alt+f", "word_right"),
        bind("pageup", "page_up"), bind("pagedown", "page_down"),
        bind("shift+left", "select_left"), bind("shift+right", "select_right"),
        bind("shift+up", "select_up"), bind("shift+down", "select_down"),
        bind("shift+home", "select_line_start"), bind("shift+end", "select_line_end"),
        bind("ctrl+shift+home", "select_document_start"), bind("ctrl+shift+end", "select_document_end"),
        bind("ctrl+shift+left", "select_word_left"), bind("ctrl+shift+right", "select_word_right"),
        bind("shift+pageup", "select_page_up"), bind("shift+pagedown", "select_page_down"),
        bind("ctrl+a", "select_all"),
        bind("enter", "newline"),
        bind("backspace", "delete_left"), bind("delete,ctrl+d", "delete_right"),
        bind("ctrl+backspace,alt+backspace,ctrl+w", "delete_word_left"),
        bind("ctrl+delete,alt+d", "delete_word_right"),
        bind("ctrl+u", "delete_to_line_start"), bind("ctrl+k", "delete_to_line_end"),
        bind("ctrl+v", "paste"),
        bind("ctrl+z", "undo", "Undo"), bind("ctrl+y,ctrl+shift+z", "redo", "Redo"),
        bind("ctrl+f", "open_find", "Find"), bind("ctrl+h", "open_replace", "Replace"),
        bind("ctrl+g", "open_goto", "Go to line"),
        bind("f3", "find_next"), bind("shift+f3", "find_previous")
      ), if (self$tab_behavior == "indent") list(bind("tab", "indent"), bind("shift+tab", "dedent")))
    },

    # Keys ------------------------------------------------------------------

    #' @description Printable keys replace the selection or are inserted at
    #'   the cursor; Ctrl+C / Ctrl+X copy / cut a selection. While the find
    #'   bar is open, keys edit the search text.
    #' @param event A `KeyEvent`.
    on_key = function(event) {
      if (!self$is_enabled()) return(invisible())
      if (private$.goto_open) return(private$goto_key(event))
      if (private$.find_open) return(private$find_key(event))
      if (event$is_printable()) {
        self$insert(event$char)
        event$stop()
      } else if (event$key %in% c("ctrl+c", "ctrl+x") && !is.null(private$selection_range())) {
        self$copy()
        if (event$key == "ctrl+x" && !self$read_only) private$delete_selection()
        event$stop()
      }
    },

    #' @description Pasted text is inserted as one undo step; line breaks
    #'   are kept.
    #' @param event A `PasteEvent`.
    on_paste = function(event) {
      if (!self$is_enabled()) return(invisible())
      if (private$.find_open && !(private$.replace_open && private$.replace_target == "replacement")) {
        private$find_set(paste0(private$.find_text, gsub("\n", " ", event$text, fixed = TRUE)))
      } else if (private$.goto_open) {
        private$.goto_text <- paste0(private$.goto_text, gsub("[^0-9]", "", event$text))
        self$invalidate_paint()
      } else if (private$.replace_open && identical(private$.replace_target, "replacement")) {
        private$.replace_text <- paste0(private$.replace_text, event$text)
        self$invalidate_paint()
      } else {
        self$insert(event$text, kind = "paste")
      }
      event$stop()
    },

    #' @description Click to place the cursor, shift+click and drag to
    #'   select.
    #' @param event A `MouseEvent`.
    on_mouse_down = function(event) {
      if (event$button != "left" || is.null(self$region)) return(invisible())
      pos <- private$position_at(event$screen_x, event$screen_y)
      if (is.null(pos)) return(invisible())
      private$.undo$break_group()
      private$set_cursor(pos, extend = event$shift || FALSE)
      private$.dragging <- TRUE
      event$stop()
    },

    #' @description Dragging with the left button extends the selection.
    #' @param event A `MouseEvent`.
    on_mouse_move = function(event) {
      if (event$button == "left" && isTRUE(private$.dragging) && !is.null(self$region)) {
        pos <- private$position_at(event$screen_x, event$screen_y, clamp = TRUE)
        if (!is.null(pos)) private$set_cursor(pos, extend = TRUE)
        event$stop()
      }
    },

    #' @description Ends a drag selection.
    #' @param event A `MouseEvent`.
    on_mouse_up = function(event) {
      private$.dragging <- FALSE
    },

    #' @description The mouse wheel scrolls three rows.
    #' @param event A `MouseEvent`.
    on_mouse_scroll = function(event) {
      g <- private$geometry()
      if (is.null(g)) return(invisible())
      delta <- switch(event$direction, up = -3L, down = 3L, 0L)
      if (delta != 0L) {
        private$scroll_visual(g, delta)
        private$.follow <- FALSE
        self$invalidate_paint()
        event$stop()
      }
      dx <- switch(event$direction, left = -4L, right = 4L, 0L)
      if (dx != 0L && !self$wrap) {
        private$.state$left <- max(0L, min(private$.state$left + dx, g$max_left))
        self$invalidate_paint()
        event$stop()
      }
    },

    # Content ---------------------------------------------------------------

    #' @description Replace the whole text. Clears the undo history and
    #'   moves the cursor to the start.
    #' @param text A string (or character vector of lines).
    set_text = function(text) {
      private$.buf$set_text(text)
      private$.highlight_from <- 1L
      private$.undo$clear()
      private$.state$cursor_row <- 1L
      private$.state$cursor_col <- 0L
      private$.state$anchor_row <- NA_integer_
      private$.state$anchor_col <- NA_integer_
      private$.state$top <- 1L
      private$.state$sub <- 0L
      private$.state$left <- 0L
      private$.want_x <- NA_integer_
      private$changed()
      invisible(self)
    },

    #' @description Insert text at the cursor, replacing the selection.
    #' @param text A string; line breaks are kept.
    #' @param kind Internal: `"type"` merges into undo groups, `"paste"`
    #'   does not.
    insert = function(text, kind = NULL) {
      if (self$read_only || !self$is_enabled()) return(invisible(self))
      text <- normalize_text(text, self$tab_size)
      if (!nzchar(text)) return(invisible(self))
      sel <- private$selection_range()
      start <- sel$start %||% private$cursor()
      end <- sel$end %||% private$cursor()
      if (is.null(kind)) {
        kind <- if (is.null(sel) && !grepl("\n", text, fixed = TRUE) && grapheme_count(text) == 1L) "type" else "paste"
      }
      closes <- kind != "type" || grepl("^[[:space:]]$", text)
      private$apply_edit(start, end, text, kind, closes)
      invisible(self)
    },

    #' @description Select a range (1-based rows and columns; column 1 is
    #'   before the first character).
    #' @param start_row,start_column,end_row,end_column The range.
    select_range = function(start_row, start_column, end_row, end_column) {
      a <- private$.buf$clamp(text_pos(start_row, start_column - 1L))
      b <- private$.buf$clamp(text_pos(end_row, end_column - 1L))
      private$.state$anchor_row <- a[["row"]]
      private$.state$anchor_col <- a[["col"]]
      private$set_cursor(b, extend = TRUE, keep_anchor = TRUE)
      invisible(self)
    },

    #' @description Move the cursor to a line.
    #' @param row Line number (1-based).
    #' @param column Column (1-based).
    goto_line = function(row, column = 1L) {
      private$set_cursor(private$.buf$clamp(text_pos(row, column - 1L)), extend = FALSE)
      invisible(self)
    },

    #' @description Cursor position. Columns count grapheme clusters and
    #'   terminal display cells, both 1-based.
    cursor_position = function() {
      row <- private$.state$cursor_row
      cells <- private$cells(row)$widths
      column <- private$.state$cursor_col
      list(line = row, grapheme_column = column + 1L,
           display_column = sum(cells[seq_len(min(column, length(cells)))]) + 1L)
    },

    #' @description Copy the selection to the app clipboard (and the system
    #'   clipboard via OSC 52 when `system = TRUE` and supported).
    #' @param system Also write to the system clipboard?
    copy = function(system = TRUE) {
      sel <- private$selection_range()
      app <- self$app
      if (!is.null(sel) && !is.null(app)) app$clipboard_write(private$.buf$slice(sel$start, sel$end), system = system)
      invisible(self)
    },

    #' @description Copy the selection and delete it.
    #' @param system Also write to the system clipboard?
    cut = function(system = TRUE) {
      self$copy(system)
      if (!self$read_only) private$delete_selection()
      invisible(self)
    },

    #' @description Undo the last edit group.
    undo = function() {
      edit <- private$.undo$undo(private$.buf)
      if (is.null(edit)) return(invisible(FALSE))
      private$.highlight_from <- edit$first_row
      private$restore_cursor(edit$before)
      invisible(TRUE)
    },

    #' @description Redo the last undone edit.
    redo = function() {
      edit <- private$.undo$redo(private$.buf)
      if (is.null(edit)) return(invisible(FALSE))
      private$.highlight_from <- edit$first_row
      private$restore_cursor(edit$after)
      invisible(TRUE)
    },

    # Search ----------------------------------------------------------------

    #' @description Search for text and select the first match at or after
    #'   the cursor.
    #' @param query Text or regular expression (PCRE).
    #' @param case_sensitive Match case?
    #' @param regex Is `query` a regular expression? Invalid expressions
    #'   raise an error.
    #' @return `TRUE` if there is a match (invisibly).
    find = function(query, case_sensitive = FALSE, regex = FALSE) {
      private$.find <- search_query(query, case_sensitive, regex)
      private$.find_text <- query
      private$.match_cache <- NULL
      invisible(private$goto_match(1L, include_current = TRUE))
    },

    #' @description Select the next match (wrapping around).
    find_next = function() invisible(private$goto_match(1L)),

    #' @description Select the previous match (wrapping around).
    find_previous = function() invisible(private$goto_match(-1L)),

    #' @description Replace the current search match, or find `query` and
    #'   replace its next match.
    #' @param replacement Replacement text.
    #' @param query Optional search text; defaults to the active search.
    #' @param case_sensitive,regex Search options used when `query` is given.
    replace = function(replacement, query = NULL, case_sensitive = FALSE, regex = FALSE) {
      check_scalar_character(replacement, "replacement")
      if (!is.null(query)) self$find(query, case_sensitive, regex)
      sel <- private$selection_range()
      if (is.null(sel)) return(invisible(FALSE))
      if (!is.null(private$.find) && private$.find$regex) {
        current <- private$.buf$slice(sel$start, sel$end)
        replacement <- sub(private$.find$pattern, replacement, current, perl = TRUE,
                           ignore.case = !private$.find$case_sensitive)
      }
      private$apply_edit(sel$start, sel$end, replacement, "paste", TRUE)
      invisible(TRUE)
    },

    #' @description Replace all matches in one document edit. If the edit is
    #'   larger than the bounded undo budget, it is applied without retaining
    #'   an undo record.
    #' @param query Search text or regular expression.
    #' @param replacement Replacement text.
    #' @param case_sensitive Match case?
    #' @param regex Interpret `query` as a regular expression?
    replace_all = function(query, replacement, case_sensitive = FALSE, regex = FALSE) {
      check_scalar_character(replacement, "replacement")
      q <- search_query(query, case_sensitive, regex)
      lines <- private$.buf$lines
      matches <- vapply(lines, function(line) nrow(query_line_matches(q, line)), 0L)
      count <- sum(matches)
      if (!count) return(invisible(0L))
      changed <- lines
      for (i in which(matches > 0L)) {
        if (q$regex) {
          changed[[i]] <- gsub(q$pattern, replacement, changed[[i]], perl = TRUE,
                               ignore.case = !q$case_sensitive)
        } else {
          haystack <- if (q$case_sensitive) changed[[i]] else tolower(changed[[i]])
          needle <- if (q$case_sensitive) q$pattern else tolower(q$pattern)
          m <- gregexpr(needle, haystack, fixed = TRUE)[[1L]]
          lens <- attr(m, "match.length")
          for (k in rev(seq_along(m))) {
            before <- if (m[[k]] <= 1L) "" else substr(changed[[i]], 1L, m[[k]] - 1L)
            after_start <- m[[k]] + lens[[k]]
            after <- if (after_start > nchar(changed[[i]])) "" else substr(changed[[i]], after_start, nchar(changed[[i]]))
            changed[[i]] <- paste0(before, replacement, after)
          }
        }
      }
      end <- private$.buf$end_pos()
      private$apply_edit(text_pos(1L, 0L), end, paste(changed, collapse = "\n"), "paste", TRUE)
      invisible(count)
    },

    #' @description Remove the search highlights and close the find bar.
    clear_find = function() {
      private$.find <- NULL
      private$.find_open <- FALSE
      private$.match_cache <- NULL
      self$invalidate_paint()
      invisible(self)
    },

    # Actions ---------------------------------------------------------------

    #' @description Move one character left.
    action_cursor_left = function() private$move_horizontal(-1L, FALSE),
    #' @description Move one character right.
    action_cursor_right = function() private$move_horizontal(1L, FALSE),
    #' @description Move one row up.
    action_cursor_up = function() private$move_vertical(-1L, FALSE),
    #' @description Move one row down.
    action_cursor_down = function() private$move_vertical(1L, FALSE),
    #' @description Move to the start of the line.
    action_line_start = function() private$move_to(private$line_start(), FALSE),
    #' @description Move to the end of the line.
    action_line_end = function() private$move_to(private$line_end(), FALSE),
    #' @description Move to the start of the text.
    action_document_start = function() private$move_to(text_pos(1L, 0L), FALSE),
    #' @description Move to the end of the text.
    action_document_end = function() private$move_to(private$.buf$end_pos(), FALSE),
    #' @description Move to the previous word.
    action_word_left = function() private$move_to(private$word_left(), FALSE),
    #' @description Move to the next word.
    action_word_right = function() private$move_to(private$word_right(), FALSE),
    #' @description Move one page up.
    action_page_up = function() private$page(-1L, FALSE),
    #' @description Move one page down.
    action_page_down = function() private$page(1L, FALSE),
    #' @description Extend the selection left.
    action_select_left = function() private$move_horizontal(-1L, TRUE),
    #' @description Extend the selection right.
    action_select_right = function() private$move_horizontal(1L, TRUE),
    #' @description Extend the selection up.
    action_select_up = function() private$move_vertical(-1L, TRUE),
    #' @description Extend the selection down.
    action_select_down = function() private$move_vertical(1L, TRUE),
    #' @description Select to the start of the line.
    action_select_line_start = function() private$move_to(private$line_start(), TRUE),
    #' @description Select to the end of the line.
    action_select_line_end = function() private$move_to(private$line_end(), TRUE),
    #' @description Select to the start of the text.
    action_select_document_start = function() private$move_to(text_pos(1L, 0L), TRUE),
    #' @description Select to the end of the text.
    action_select_document_end = function() private$move_to(private$.buf$end_pos(), TRUE),
    #' @description Select to the previous word.
    action_select_word_left = function() private$move_to(private$word_left(), TRUE),
    #' @description Select to the next word.
    action_select_word_right = function() private$move_to(private$word_right(), TRUE),
    #' @description Extend the selection one page up.
    action_select_page_up = function() private$page(-1L, TRUE),
    #' @description Extend the selection one page down.
    action_select_page_down = function() private$page(1L, TRUE),
    #' @description Select everything.
    action_select_all = function() {
      end <- private$.buf$end_pos()
      self$select_range(1L, 1L, end[["row"]], end[["col"]] + 1L)
    },

    #' @description Insert a line break (keeping the indentation when
    #'   `auto_indent` is on).
    action_newline = function() {
      indent <- ""
      if (self$auto_indent) {
        line <- private$.buf$lines[[private$.state$cursor_row]]
        indent <- sub("^( *).*$", "\\1", line)
        indent <- grapheme_prefix(indent, private$.state$cursor_col)
      }
      self$insert(paste0("\n", indent), kind = "paste")
    },

    #' @description Delete the selection or the character before the cursor.
    action_delete_left = function() {
      if (private$delete_selection()) return(invisible(self))
      cur <- private$cursor()
      if (cur[["col"]] > 0L) {
        private$apply_edit(text_pos(cur[["row"]], cur[["col"]] - 1L), cur, "", "delete", FALSE)
      } else if (cur[["row"]] > 1L) {
        prev <- text_pos(cur[["row"]] - 1L, private$.buf$line_length(cur[["row"]] - 1L))
        private$apply_edit(prev, cur, "", "other", TRUE)
      }
    },

    #' @description Delete the selection or the character at the cursor.
    action_delete_right = function() {
      if (private$delete_selection()) return(invisible(self))
      cur <- private$cursor()
      len <- private$.buf$line_length(cur[["row"]])
      if (cur[["col"]] < len) {
        private$apply_edit(cur, text_pos(cur[["row"]], cur[["col"]] + 1L), "", "delete", FALSE)
      } else if (cur[["row"]] < private$.buf$n_lines()) {
        private$apply_edit(cur, text_pos(cur[["row"]] + 1L, 0L), "", "other", TRUE)
      }
    },

    #' @description Delete the word before the cursor.
    action_delete_word_left = function() {
      if (private$delete_selection()) return(invisible(self))
      private$apply_edit(private$word_left(), private$cursor(), "", "other", TRUE)
    },

    #' @description Delete the word after the cursor.
    action_delete_word_right = function() {
      if (private$delete_selection()) return(invisible(self))
      private$apply_edit(private$cursor(), private$word_right(), "", "other", TRUE)
    },

    #' @description Delete from the start of the line to the cursor.
    action_delete_to_line_start = function() {
      private$apply_edit(private$line_start(), private$cursor(), "", "other", TRUE)
    },

    #' @description Delete from the cursor to the end of the line (or the
    #'   line break when the cursor is at the end).
    action_delete_to_line_end = function() {
      cur <- private$cursor()
      end <- private$line_end()
      if (pos_equal(cur, end) && cur[["row"]] < private$.buf$n_lines()) end <- text_pos(cur[["row"]] + 1L, 0L)
      private$apply_edit(cur, end, "", "other", TRUE)
    },

    #' @description Insert the app clipboard.
    action_paste = function() {
      app <- self$app
      if (!is.null(app) && nzchar(app$clipboard)) self$insert(app$clipboard, kind = "paste")
    },

    #' @description Insert spaces up to the next tab stop (or indent the
    #'   selected lines).
    action_indent = function() {
      sel <- private$selection_range()
      if (!is.null(sel) && sel$start[["row"]] != sel$end[["row"]]) return(private$shift_lines(sel, self$tab_size))
      cur <- private$cursor()
      self$insert(strrep(" ", self$tab_size - (cur[["col"]] %% self$tab_size)), kind = "paste")
    },

    #' @description Remove up to one tab stop of leading spaces.
    action_dedent = function() {
      sel <- private$selection_range()
      rows <- if (is.null(sel)) private$.state$cursor_row else sel$start[["row"]]
      private$shift_lines(sel %||% list(start = text_pos(rows, 0L), end = text_pos(rows, 0L)), -self$tab_size)
    },

    #' @description Undo.
    action_undo = function() self$undo(),
    #' @description Redo.
    action_redo = function() self$redo(),

    #' @description Open the find bar (Ctrl+F).
    action_open_find = function() {
      sel <- private$selection_range()
      if (!is.null(sel) && sel$start[["row"]] == sel$end[["row"]]) {
        private$find_set(private$.buf$slice(sel$start, sel$end), jump = FALSE)
      }
      private$.find_open <- TRUE
      private$.goto_open <- FALSE
      private$.replace_open <- FALSE
      self$invalidate_paint()
    },
    #' @description Select the next match (F3).
    action_find_next = function() self$find_next(),
    #' @description Select the previous match (Shift+F3).
    action_find_previous = function() self$find_previous(),

    #' @description Open the find and replace bar (Ctrl+H).
    action_open_replace = function() {
      private$.replace_open <- TRUE
      private$.replace_target <- "query"
      private$.find_open <- TRUE
      self$invalidate_paint()
    },
    #' @description Open the go-to-line bar (Ctrl+G).
    action_open_goto = function() {
      private$.goto_text <- ""
      private$.goto_open <- TRUE
      self$invalidate_paint()
    },

    #' @description Natural width: the longest line (at most 120 columns).
    content_width = function() min(120L, max(c(1L, nchar(utils::head(private$.buf$lines, 1000L), type = "width")))) + 1L,

    #' @description Natural height: the number of lines (at most 1000).
    content_height = function(width) min(private$.buf$n_lines(), 1000L),

    #' @description Draw the visible part of the text.
    #' @param buffer A [ScreenBuffer].
    #' @param area Visible part of the region.
    #' @param st Computed style.
    paint = function(buffer, area, st) {
      draw_background(buffer, area, st)
      draw_border(buffer, self$region, st, area, self$border_title)
      g <- private$geometry()
      if (is.null(g)) return(invisible())
      if (private$.follow) private$reveal(g)
      private$paint_text(buffer, g, st, rect_intersect(g$inner, area))
    }
  ),
  active = list(
    #' @field value The text. Assigning replaces it (see `set_text()`).
    value = function(value) {
      if (missing(value)) return(private$.buf$text())
      self$set_text(value)
    },
    #' @field text The text (read-only alias of `value`).
    text = function(value) if (missing(value)) private$.buf$text() else read_only("text"),
    #' @field lines The lines as a character vector.
    lines = function(value) if (missing(value)) private$.buf$lines else read_only("lines"),
    #' @field n_lines Number of lines.
    n_lines = function(value) if (missing(value)) private$.buf$n_lines() else read_only("n_lines"),
    #' @field cursor_row,cursor_column Cursor position, 1-based (column 1 is
    #'   before the first character).
    cursor_row = function(value) if (missing(value)) private$.state$cursor_row else read_only("cursor_row"),
    cursor_column = function(value) if (missing(value)) private$.state$cursor_col + 1L else read_only("cursor_column"),
    #' @field selection The selected text (`""` if none).
    selection = function(value) {
      if (!missing(value)) read_only("selection")
      sel <- private$selection_range()
      if (is.null(sel)) "" else private$.buf$slice(sel$start, sel$end)
    },
    #' @field can_undo,can_redo Is there something to undo / redo?
    can_undo = function(value) if (missing(value)) private$.undo$can_undo() else read_only("can_undo"),
    can_redo = function(value) if (missing(value)) private$.undo$can_redo() else read_only("can_redo"),
    #' @field history_size Characters held by the undo history.
    history_size = function(value) if (missing(value)) private$.undo$size() else read_only("history_size"),
    #' @field match_count Number of matches of the current search.
    match_count = function(value) if (missing(value)) private$count_matches() else read_only("match_count"),
    #' @field find_open Is the find bar open?
    find_open = function(value) if (missing(value)) private$.find_open else read_only("find_open"),
    #' @field valid Does the value pass `validate`?
    valid = function(value) if (missing(value)) is.null(private$.state$error) else read_only("valid"),
    #' @field error The validation message, or `NULL`.
    error = function(value) if (missing(value)) private$.state$error else read_only("error")
  ),
  private = list(
    .buf = NULL,
    .undo = NULL,
    .wrap_cache = NULL,
    .follow = TRUE,
    .want_x = NA_integer_,
    .dragging = FALSE,
    .find = NULL,
    .find_text = "",
    .find_open = FALSE,
    .replace_open = FALSE,
    .replace_target = "query",
    .replace_text = "",
    .goto_open = FALSE,
    .goto_text = "",
    .find_error = NULL,
    .match_cache = NULL,
    .maxw_cache = NULL,
    .highlight_cache = NULL,
    .highlight_from = 1L,

    # Positions --------------------------------------------------------------

    cursor = function() text_pos(private$.state$cursor_row, private$.state$cursor_col),

    selection_range = function() {
      st <- private$.state
      if (is.na(st$anchor_row)) return(NULL)
      a <- text_pos(st$anchor_row, st$anchor_col)
      b <- private$cursor()
      if (pos_equal(a, b)) return(NULL)
      pos_order(a, b)
    },

    line_start = function() text_pos(private$.state$cursor_row, 0L),
    line_end = function() text_pos(private$.state$cursor_row, private$.buf$line_length(private$.state$cursor_row)),

    cells = function(row) {
      chars <- split_graphemes(private$.buf$lines[[row]])
      list(chars = chars, widths = if (!length(chars)) integer() else pmax(grapheme_width(chars), 1L))
    },

    highlight_spans = function(row) {
      empty <- data.frame(start = integer(), end = integer(), token = character())
      if (is.null(self$highlighter)) return(empty)
      key <- paste(private$.buf$version, row, sep = ":")
      if (exists(key, private$.highlight_cache, inherits = FALSE)) return(get(key, private$.highlight_cache))
      contextual <- isTRUE(attr(self$highlighter, "termr.contextual", exact = TRUE))
      raw <- if (contextual) {
        self$highlighter(private$.buf$lines,
                         state = list(language = self$language, line = row, version = private$.buf$version,
                                      changed_from = private$.highlight_from))
      } else {
        self$highlighter(private$.buf$lines[[row]], state = list(language = self$language, line = row))
      }
      if (is.list(raw) && !is.data.frame(raw)) raw <- raw[[1L]]
      if (is.null(raw)) raw <- empty
      if (!is.data.frame(raw) || !all(c("start", "end", "token") %in% names(raw))) {
        stop("A highlighter must return data frames with `start`, `end`, and `token` columns.", call. = FALSE)
      }
      raw <- raw[, c("start", "end", "token"), drop = FALSE]
      if (nrow(raw) && (!is.numeric(raw$start) || !is.numeric(raw$end) || !is.character(raw$token) ||
          anyNA(raw$start) || anyNA(raw$end) || anyNA(raw$token) || any(raw$start != floor(raw$start)) ||
          any(raw$end != floor(raw$end)) || any(raw$start < 1L | raw$end < raw$start) ||
          any(raw$end > length(split_graphemes(private$.buf$lines[[row]]))) ||
          any(!raw$token %in% c("keyword", "string", "comment", "number", "constant", "operator", "function",
                               "identifier", "punctuation", "parameter", "quoted_identifier")))) {
        stop("Highlighter spans must use valid grapheme ranges and known token names.", call. = FALSE)
      }
      assign(key, raw, private$.highlight_cache)
      raw
    },

    word_class = function(ch) grepl("^[[:space:][:punct:]]$", ch),

    word_left = function() {
      cur <- private$cursor()
      if (cur[["col"]] == 0L) {
        return(if (cur[["row"]] > 1L) text_pos(cur[["row"]] - 1L, private$.buf$line_length(cur[["row"]] - 1L)) else cur)
      }
      chars <- private$cells(cur[["row"]])$chars
      i <- cur[["col"]]
      while (i > 0L && private$word_class(chars[[i]])) i <- i - 1L
      while (i > 0L && !private$word_class(chars[[i]])) i <- i - 1L
      text_pos(cur[["row"]], i)
    },

    word_right = function() {
      cur <- private$cursor()
      len <- private$.buf$line_length(cur[["row"]])
      if (cur[["col"]] >= len) {
        return(if (cur[["row"]] < private$.buf$n_lines()) text_pos(cur[["row"]] + 1L, 0L) else cur)
      }
      chars <- private$cells(cur[["row"]])$chars
      i <- cur[["col"]]
      while (i < len && private$word_class(chars[[i + 1L]])) i <- i + 1L
      while (i < len && !private$word_class(chars[[i + 1L]])) i <- i + 1L
      text_pos(cur[["row"]], i)
    },

    # Cursor movement ---------------------------------------------------------

    # Place the cursor. With `extend` the selection grows from the anchor
    # (set to the old cursor if there was none); otherwise it is dropped.
    set_cursor = function(pos, extend = FALSE, keep_x = FALSE, keep_anchor = FALSE) {
      st <- private$.state
      pos <- private$.buf$clamp(pos)
      had_selection <- !is.null(private$selection_range())
      if (!keep_anchor) {
        if (extend) {
          if (is.na(st$anchor_row)) {
            st$anchor_row <- st$cursor_row
            st$anchor_col <- st$cursor_col
          }
        } else {
          st$anchor_row <- NA_integer_
          st$anchor_col <- NA_integer_
        }
      }
      st$cursor_row <- pos[["row"]]
      st$cursor_col <- pos[["col"]]
      private$.state <- st
      if (!keep_x) private$.want_x <- NA_integer_
      private$.follow <- TRUE
      self$invalidate_paint()
      if (had_selection || !is.null(private$selection_range())) private$selection_changed()
      invisible(TRUE)
    },

    move_to = function(pos, extend) {
      if (!extend) private$.undo$break_group()
      private$set_cursor(pos, extend)
    },

    move_horizontal = function(delta, extend) {
      sel <- private$selection_range()
      cur <- private$cursor()
      if (!extend && !is.null(sel)) return(private$move_to(if (delta < 0L) sel$start else sel$end, FALSE))
      row <- cur[["row"]]
      col <- cur[["col"]] + delta
      if (col < 0L) {
        if (row == 1L) col <- 0L else {
          row <- row - 1L
          col <- private$.buf$line_length(row)
        }
      } else if (col > private$.buf$line_length(row)) {
        if (row < private$.buf$n_lines()) {
          row <- row + 1L
          col <- 0L
        } else {
          col <- private$.buf$line_length(row)
        }
      }
      private$move_to(text_pos(row, col), extend)
    },

    move_vertical = function(delta, extend) {
      g <- private$geometry()
      cur <- private$cursor()
      if (is.null(g)) return(private$move_to(text_pos(cur[["row"]] + delta, cur[["col"]]), extend))
      sel <- private$selection_range()
      private$.undo$break_group()
      x <- private$.want_x
      if (is.na(x)) x <- private$x_of(cur[["row"]], cur[["col"]], g)
      row <- cur[["row"]]
      seg <- private$segment_of(row, cur[["col"]], g)
      n <- private$.buf$n_lines()
      end_reached <- NULL
      for (i in seq_len(abs(delta))) {
        if (delta > 0L) {
          if (seg + 1L < length(private$starts_of(row, g))) seg <- seg + 1L
          else if (row < n) {
            row <- row + 1L
            seg <- 0L
          } else {
            end_reached <- text_pos(row, private$.buf$line_length(row))
            break
          }
        } else {
          if (seg > 0L) seg <- seg - 1L
          else if (row > 1L) {
            row <- row - 1L
            seg <- length(private$starts_of(row, g)) - 1L
          } else {
            end_reached <- text_pos(1L, 0L)
            break
          }
        }
      }
      pos <- end_reached %||% text_pos(row, private$col_at_x(row, seg, x, g))
      private$set_cursor(pos, extend, keep_x = TRUE)
      private$.want_x <- if (is.null(end_reached)) x else NA_integer_
      invisible(TRUE)
    },

    page = function(direction, extend) {
      g <- private$geometry()
      if (is.null(g)) return(invisible())
      private$scroll_visual(g, direction * g$body_h)
      private$move_vertical(direction * max(1L, g$body_h - 1L), extend)
    },

    # Layout ----------------------------------------------------------------

    geometry = function() {
      region <- self$region
      if (is.null(region)) return(NULL)
      st <- self$computed_style()
      inner <- content_rect(region, st)
      if (rect_is_empty(inner)) return(NULL)
      n <- private$.buf$n_lines()
      gutter <- if (self$line_numbers) nchar(as.character(n)) + 1L else 0L
      body_h <- inner$height - as.integer(private$.find_open || private$.goto_open)
      bar_y <- n > body_h || private$.state$top > 1L
      text_w <- max(1L, inner$width - gutter - as.integer(bar_y))
      bar_x <- FALSE
      max_left <- 0L
      if (!self$wrap) {
        widest <- private$widest_line()
        if (widest + 1L > text_w) {
          bar_x <- body_h > 1L
          max_left <- widest + 1L - text_w
        }
      }
      if (bar_x) body_h <- body_h - 1L
      body_h <- max(1L, body_h)
      list(inner = inner, gutter = gutter, text_w = text_w, wrap_w = max(1L, text_w - 1L),
           body_h = body_h, bar_y = bar_y, bar_x = bar_x, max_left = max_left, n = n,
           bar_row = inner$y + inner$height - 1L)
    },

    widest_line = function() {
      v <- private$.buf$version
      if (is.null(private$.maxw_cache) || private$.maxw_cache$version != v) {
        private$.maxw_cache <- list(version = v, width = max(nchar(private$.buf$lines, type = "width")))
      }
      private$.maxw_cache$width
    },

    # Start indices (1-based graphemes) of the visual rows of a line.
    starts_of = function(row, g) {
      if (!self$wrap) return(1L)
      line <- private$.buf$lines[[row]]
      w <- g$wrap_w
      key <- as.character(row)
      entry <- private$.wrap_cache[[key]]
      if (!is.null(entry) && entry$w == w && identical(entry$line, line)) return(entry$starts)
      if (nchar(line, "width") < w) {
        starts <- 1L
      } else {
        cells <- private$cells(row)
        starts <- editor_wrap_starts(cells$chars, cells$widths, w)
      }
      if (length(private$.wrap_cache) > 4000L) rm(list = ls(private$.wrap_cache), envir = private$.wrap_cache)
      assign(key, list(line = line, w = w, starts = starts), envir = private$.wrap_cache)
      starts
    },

    segment_of = function(row, col, g) {
      starts <- private$starts_of(row, g)
      if (length(starts) == 1L) return(0L)
      findInterval(col + 1L, starts) - 1L
    },

    x_of = function(row, col, g) {
      starts <- private$starts_of(row, g)
      seg <- private$segment_of(row, col, g)
      from <- starts[[seg + 1L]]
      if (col < from) return(0L)
      sum(private$cells(row)$widths[from:col])
    },

    col_at_x = function(row, seg, x, g) {
      starts <- private$starts_of(row, g)
      cells <- private$cells(row)
      n <- length(cells$chars)
      from <- starts[[seg + 1L]]
      to <- if (seg + 1L < length(starts)) starts[[seg + 2L]] - 1L else n
      if (to < from) return(from - 1L)
      k <- sum(cumsum(cells$widths[from:to]) <= x)
      k <- min(k, if (seg + 1L < length(starts)) to - from else to - from + 1L)
      from - 1L + k
    },

    # The visual rows from (top, sub): a list of integer vectors
    # c(row, seg, from, to) with `to` = -1 for "end of line".
    visual_rows = function(g, top, sub, count) {
      rows <- vector("list", count)
      k <- 0L
      r <- top
      s <- sub
      n <- g$n
      while (k < count && r <= n) {
        starts <- private$starts_of(r, g)
        while (s < length(starts) && k < count) {
          k <- k + 1L
          rows[[k]] <- c(r, s, starts[[s + 1L]], if (s + 1L < length(starts)) starts[[s + 2L]] - 1L else -1L)
          s <- s + 1L
        }
        r <- r + 1L
        s <- 0L
      }
      rows[seq_len(k)]
    },

    # Move a (row, seg) position back by `k` visual rows.
    step_back = function(row, seg, k, g) {
      while (k > 0L) {
        if (seg > 0L) seg <- seg - 1L
        else if (row > 1L) {
          row <- row - 1L
          seg <- length(private$starts_of(row, g)) - 1L
        } else break
        k <- k - 1L
      }
      c(row, seg)
    },

    # Visual rows between (top, sub) and (row, seg), or NA when >= limit.
    visual_distance = function(top, sub, row, seg, limit, g) {
      if (row - top >= limit) return(NA_integer_)
      if (row == top) return(seg - sub)
      d <- length(private$starts_of(top, g)) - sub
      r <- top + 1L
      while (r < row && d < limit) {
        d <- d + length(private$starts_of(r, g))
        r <- r + 1L
      }
      if (r < row || d + seg >= limit) NA_integer_ else d + seg
    },

    scroll_visual = function(g, delta) {
      st <- private$.state
      top <- st$top
      sub <- st$sub
      n <- g$n
      steps <- abs(delta)
      while (steps > 0L) {
        if (delta > 0L) {
          if (sub + 1L < length(private$starts_of(top, g))) sub <- sub + 1L
          else if (top < n) {
            top <- top + 1L
            sub <- 0L
          } else break
        } else {
          if (sub > 0L) sub <- sub - 1L
          else if (top > 1L) {
            top <- top - 1L
            sub <- length(private$starts_of(top, g)) - 1L
          } else break
        }
        steps <- steps - 1L
      }
      # Never scroll past the point where the last row is at the bottom.
      limit <- private$step_back(n, length(private$starts_of(n, g)) - 1L, g$body_h - 1L, g)
      if (top > limit[[1]] || (top == limit[[1]] && sub > limit[[2]])) {
        top <- limit[[1]]
        sub <- limit[[2]]
      }
      private$.state$top <- top
      private$.state$sub <- sub
      invisible()
    },

    # Scroll so that the cursor is visible.
    reveal = function(g) {
      private$.follow <- FALSE
      st <- private$.state
      row <- st$cursor_row
      seg <- private$segment_of(row, st$cursor_col, g)
      top <- min(st$top, g$n)
      sub <- if (top == st$top) st$sub else 0L
      if (row < top || (row == top && seg < sub)) {
        top <- row
        sub <- seg
      } else if (is.na(private$visual_distance(top, sub, row, seg, g$body_h, g))) {
        back <- private$step_back(row, seg, g$body_h - 1L, g)
        top <- back[[1]]
        sub <- back[[2]]
      }
      private$.state$top <- top
      private$.state$sub <- sub
      if (!self$wrap) {
        x <- private$x_of(row, st$cursor_col, g)
        left <- st$left
        if (x < left) left <- x
        else if (x + 1L > left + g$text_w) left <- x + 1L - g$text_w
        private$.state$left <- max(0L, min(left, g$max_left))
      } else private$.state$left <- 0L
      invisible()
    },

    # Which text position is under screen (x, y)? With `clamp` positions
    # outside the text snap to the nearest edge (used while dragging).
    position_at = function(x, y, clamp = FALSE) {
      g <- private$geometry()
      if (is.null(g)) return(NULL)
      st <- private$.state
      k <- y - g$inner$y + 1L
      if (clamp) k <- min(max(k, 1L), g$body_h)
      if (k < 1L || k > g$body_h) return(NULL)
      rows <- private$visual_rows(g, st$top, st$sub, g$body_h)
      if (!length(rows)) return(private$.buf$end_pos())
      if (k > length(rows)) {
        if (!clamp) return(private$.buf$end_pos())
        k <- length(rows)
      }
      r <- rows[[k]]
      ox <- x - g$inner$x - g$gutter + st$left
      if (ox < 0L) ox <- 0L
      cells <- private$cells(r[[1]])
      n <- length(cells$chars)
      from <- r[[3]]
      to <- if (r[[4]] < 0L) n else r[[4]]
      if (to < from) return(text_pos(r[[1]], from - 1L))
      cum <- cumsum(cells$widths[from:to])
      idx <- sum(cum <= ox)  # whole characters before the pointer
      col <- from - 1L + min(idx, if (r[[4]] < 0L) to - from + 1L else to - from)
      text_pos(r[[1]], col)
    },

    # Editing ---------------------------------------------------------------

    apply_edit = function(start, end, text, kind, closes) {
      if (self$read_only || !self$is_enabled()) return(invisible(FALSE))
      if (pos_equal(start, end) && !nzchar(text)) return(invisible(FALSE))
      before <- private$cursor()
      res <- private$.buf$replace_range(start, end, text)
      res$before <- before
      res$after <- res$end
      res$kind <- kind
      res$closes <- closes
      private$.undo$push(res)
      st <- private$.state
      st$cursor_row <- res$end[["row"]]
      st$cursor_col <- res$end[["col"]]
      st$anchor_row <- NA_integer_
      st$anchor_col <- NA_integer_
      private$.state <- st
      private$.want_x <- NA_integer_
      private$.highlight_from <- res$first_row
      private$changed()
      invisible(TRUE)
    },

    delete_selection = function() {
      sel <- private$selection_range()
      if (is.null(sel)) return(FALSE)
      if (self$read_only) return(TRUE)
      private$apply_edit(sel$start, sel$end, "", "other", TRUE)
      TRUE
    },

    # Indent (positive) or dedent (negative) the rows of a selection.
    shift_lines = function(sel, amount) {
      rows <- seq.int(sel$start[["row"]], sel$end[["row"]] - as.integer(sel$end[["col"]] == 0L && sel$end[["row"]] > sel$start[["row"]]))
      lines <- private$.buf$lines[rows]
      new <- if (amount > 0L) paste0(strrep(" ", amount), lines) else {
        lead <- nchar(sub("^( *).*$", "\\1", lines))
        substring(lines, pmin(lead, -amount) + 1L)
      }
      if (identical(new, lines)) return(invisible(FALSE))
      start <- text_pos(rows[[1]], 0L)
      end <- text_pos(rows[[length(rows)]], private$.buf$line_length(rows[[length(rows)]]))
      private$apply_edit(start, end, paste(new, collapse = "\n"), "paste", TRUE)
    },

    restore_cursor = function(pos) {
      pos <- private$.buf$clamp(pos)
      st <- private$.state
      st$cursor_row <- pos[["row"]]
      st$cursor_col <- pos[["col"]]
      st$anchor_row <- NA_integer_
      st$anchor_col <- NA_integer_
      private$.state <- st
      private$.want_x <- NA_integer_
      private$changed()
    },

    changed = function() {
      private$.follow <- TRUE
      private$.match_cache <- NULL
      private$.highlight_cache <- new.env(parent = emptyenv())
      st <- private$.state
      st$top <- min(st$top, private$.buf$n_lines())
      error <- run_validator(self$validate, private$.buf$text())
      was <- st$error
      st$error <- error
      private$.state <- st
      if (!identical(was, error)) {
        bump_epoch()
        self$post_message(if (is.null(error)) "textarea.valid" else "textarea.invalid", list(error = error))
      }
      self$invalidate()
      self$post_message("textarea.changed", list(version = private$.buf$version, lines = private$.buf$n_lines(),
                                                  valid = is.null(error)))
    },

    selection_changed = function() {
      sel <- private$selection_range()
      self$post_message("textarea.selection_changed", list(
        empty = is.null(sel),
        start_row = if (!is.null(sel)) sel$start[["row"]], start_column = if (!is.null(sel)) sel$start[["col"]] + 1L,
        end_row = if (!is.null(sel)) sel$end[["row"]], end_column = if (!is.null(sel)) sel$end[["col"]] + 1L
      ))
    },

    # Search ----------------------------------------------------------------

    # Move to the next/previous match relative to the selection or cursor.
    goto_match = function(direction, include_current = FALSE) {
      q <- private$.find
      if (is.null(q) || !nzchar(q$pattern)) return(FALSE)
      buf <- private$.buf
      sel <- private$selection_range()
      ref <- if (direction > 0L) (if (include_current && !is.null(sel)) sel$start else (sel$end %||% private$cursor()))
             else (sel$start %||% private$cursor())
      row <- ref[["row"]]
      col <- ref[["col"]]
      found <- NULL
      m <- query_line_matches(q, buf$lines[[row]])
      if (direction > 0L) {
        ok <- which(m$start >= col)
        if (length(ok)) found <- c(row, m$start[[ok[[1]]]], m$end[[ok[[1]]]])
      } else {
        ok <- which(m$end <= col & m$start < col)
        if (length(ok)) found <- c(row, m$start[[max(ok)]], m$end[[max(ok)]])
      }
      if (is.null(found)) {
        n <- buf$n_lines()
        get <- function(i) buf$lines[i]
        from <- if (direction > 0L) row + 1L else row - 1L
        wrapped <- FALSE
        r <- NA_integer_
        if (direction > 0L && from <= n) r <- search_chunks(q, n, get, from, 1L, wrap = FALSE)
        if (direction < 0L && from >= 1L) r <- search_chunks(q, n, get, from, -1L, wrap = FALSE)
        if (is.na(r)) {
          # Wrap around, including the rest of the starting row.
          r <- search_chunks(q, n, get, if (direction > 0L) 1L else n, direction, wrap = FALSE)
          wrapped <- TRUE
        }
        if (is.na(r)) return(FALSE)
        mm <- query_line_matches(q, buf$lines[[r]])
        k <- if (direction > 0L) 1L else nrow(mm)
        found <- c(r, mm$start[[k]], mm$end[[k]])
        if (wrapped && !is.null(self$app)) NULL
      }
      st <- private$.state
      st$anchor_row <- found[[1]]
      st$anchor_col <- found[[2]]
      st$cursor_row <- found[[1]]
      st$cursor_col <- found[[3]]
      private$.state <- st
      private$.want_x <- NA_integer_
      private$.follow <- TRUE
      self$invalidate_paint()
      private$selection_changed()
      TRUE
    },

    count_matches = function() {
      q <- private$.find
      if (is.null(q) || !nzchar(q$pattern)) return(0L)
      v <- private$.buf$version
      if (!is.null(private$.match_cache) && private$.match_cache$version == v) return(private$.match_cache$count)
      hit <- which(query_matches(q, private$.buf$lines))
      count <- if (length(hit) > 20000L) length(hit) else
        sum(vapply(hit, function(i) nrow(query_line_matches(q, private$.buf$lines[[i]])), 0L))
      private$.match_cache <- list(version = v, count = count)
      count
    },

    # Set the find text (find bar) and jump to the first match from the
    # start of the current selection.
    find_set = function(text, jump = TRUE) {
      private$.find_text <- text
      private$.find_error <- NULL
      private$.match_cache <- NULL
      q <- tryCatch(
        search_query(text, private$.find_case %||% FALSE, isTRUE(private$.find_regex)),
        error = function(e) {
          private$.find_error <- conditionMessage(e)
          NULL
        }
      )
      private$.find <- q
      if (jump && !is.null(q)) private$goto_match(1L, include_current = TRUE)
      self$invalidate_paint()
      invisible()
    },

    .find_case = FALSE,
    .find_regex = FALSE,

    goto_key = function(event) {
      key <- event$key
      if (key == "escape") {
        private$.goto_open <- FALSE
      } else if (key == "backspace") {
        chars <- strsplit(private$.goto_text, "", fixed = TRUE)[[1]]
        private$.goto_text <- paste(head(chars, -1L), collapse = "")
      } else if (key == "enter") {
        row <- suppressWarnings(as.integer(private$.goto_text))
        if (!is.na(row) && row > 0L) self$goto_line(row)
        private$.goto_open <- FALSE
      } else if (event$is_printable() && grepl("^[0-9]$", event$char)) {
        private$.goto_text <- paste0(private$.goto_text, event$char)
      } else {
        return(invisible())
      }
      self$invalidate_paint()
      event$stop()
    },

    find_key = function(event) {
      key <- event$key
      if (key == "escape") {
        private$.find_open <- FALSE
        private$.replace_open <- FALSE
        self$invalidate_paint()
      } else if (key == "tab" && private$.replace_open) {
        private$.replace_target <- if (private$.replace_target == "query") "replacement" else "query"
        self$invalidate_paint()
      } else if (key == "alt+r") {
        private$.replace_open <- !private$.replace_open
        private$.replace_target <- "query"
        self$invalidate_paint()
      } else if (key == "ctrl+enter" && private$.replace_open) {
        tryCatch(self$replace_all(private$.find_text, private$.replace_text,
                                  isTRUE(private$.find_case), isTRUE(private$.find_regex)),
                 error = function(e) private$.find_error <- conditionMessage(e))
        private$.find_open <- FALSE
        private$.replace_open <- FALSE
        self$invalidate_paint()
      } else if (key == "enter" && private$.replace_open && private$.replace_target == "replacement") {
        self$replace(private$.replace_text)
        private$goto_match(1L)
      } else if (key == "backspace" && private$.replace_open && private$.replace_target == "replacement") {
        chars <- split_graphemes(private$.replace_text)
        private$.replace_text <- paste(chars[-length(chars)], collapse = "")
        self$invalidate_paint()
      } else if (key %in% c("enter", "down", "f3")) {
        self$find_next()
      } else if (key %in% c("up", "shift+f3")) {
        self$find_previous()
      } else if (key == "backspace") {
        chars <- split_graphemes(private$.find_text)
        private$find_set(paste(chars[-length(chars)], collapse = ""))
      } else if (key == "ctrl+r") {
        private$.find_regex <- !isTRUE(private$.find_regex)
        private$find_set(private$.find_text)
      } else if (key == "alt+c") {
        private$.find_case <- !isTRUE(private$.find_case)
        private$find_set(private$.find_text)
      } else if (event$is_printable() && private$.replace_open && private$.replace_target == "replacement") {
        private$.replace_text <- paste0(private$.replace_text, event$char)
        self$invalidate_paint()
      } else if (event$is_printable()) {
        private$find_set(paste0(private$.find_text, event$char))
      } else {
        return(invisible())
      }
      event$stop()
    },

    # Painting --------------------------------------------------------------

    paint_text = function(buffer, g, st, clip) {
      state <- private$.state
      buf <- private$.buf
      rows <- private$visual_rows(g, state$top, state$sub, g$body_h)
      focused <- self$focused && self$is_enabled()
      sel <- private$selection_range()
      styles <- list(
        gutter = resolve_style(style(foreground = "$muted"), parent = st),
        gutter_current = resolve_style(style(bold = TRUE), parent = st),
        selected = resolve_style(style(background = "$primary", foreground = "$on_primary"), parent = st),
        match = resolve_style(style(background = "$warning", foreground = "$background", underline = TRUE), parent = st),
        cursor = resolve_style(style(reverse = TRUE), parent = st),
        syntax_keyword = resolve_style(style(foreground = "blue", bold = TRUE), parent = st),
        syntax_string = resolve_style(style(foreground = "green"), parent = st),
        syntax_comment = resolve_style(style(foreground = "$muted", italic = TRUE), parent = st),
        syntax_number = resolve_style(style(foreground = "cyan"), parent = st),
        syntax_constant = resolve_style(style(foreground = "magenta", bold = TRUE), parent = st),
        syntax_operator = resolve_style(style(foreground = "yellow"), parent = st),
        syntax_function = resolve_style(style(foreground = "cyan", bold = TRUE), parent = st),
        syntax_identifier = resolve_style(style(foreground = "white"), parent = st),
        syntax_punctuation = resolve_style(style(foreground = "$muted"), parent = st),
        syntax_parameter = resolve_style(style(foreground = "magenta"), parent = st),
        syntax_quoted_identifier = resolve_style(style(foreground = "cyan", underline = TRUE), parent = st),
        placeholder = resolve_style(style(foreground = "$muted", italic = TRUE), parent = st)
      )
      x0 <- g$inner$x + g$gutter
      if (buf$n_lines() == 1L && !nzchar(buf$lines[[1]]) && nzchar(self$placeholder)) {
        buffer$put_text(x0, g$inner$y, str_truncate(self$placeholder, g$text_w), fg = styles$placeholder$foreground,
                        bg = styles$placeholder$background, attrs = styles$placeholder$attrs, clip = clip)
        if (focused) {
          first <- split_graphemes(sanitize_text(self$placeholder))[[1]]
          buffer$put_text(x0, g$inner$y, first, attrs = styles$cursor$attrs, clip = clip)
        }
        return(invisible())
      }
      query <- private$.find
      text_clip <- rect_intersect(rect(x0, g$inner$y, g$text_w, g$inner$height), clip)
      for (k in seq_along(rows)) {
        r <- rows[[k]]
        y <- g$inner$y + k - 1L
        if (g$gutter > 0L) {
          label <- if (r[[2]] == 0L) formatC(r[[1]], width = g$gutter - 1L) else strrep(" ", g$gutter - 1L)
          gst <- if (r[[1]] == state$cursor_row) styles$gutter_current else styles$gutter
          buffer$put_text(g$inner$x, y, paste0(label, " "), fg = gst$foreground, bg = gst$background,
                          attrs = gst$attrs, clip = clip)
        }
        private$paint_row(buffer, x0, y, g, r, state, sel, query, focused, styles, st, text_clip)
      }
      private$paint_bars(buffer, g, st, clip, rows)
      if (private$.goto_open) private$paint_goto_bar(buffer, g, st, clip)
      else if (private$.find_open) private$paint_find_bar(buffer, g, st, clip)
      invisible()
    },

    paint_row = function(buffer, x0, y, g, r, state, sel, query, focused, styles, st, clip) {
      row <- r[[1]]
      cells <- private$cells(row)
      n <- length(cells$chars)
      from <- r[[3]]
      to <- if (r[[4]] < 0L) n else r[[4]]
      last_seg <- r[[4]] < 0L
      kinds <- character()
      if (to >= from) {
        idx <- from:to
        kinds <- rep("plain", length(idx))
        spans <- private$highlight_spans(row)
        if (nrow(spans)) for (i in seq_len(nrow(spans))) {
          kinds[idx >= spans$start[[i]] & idx <= spans$end[[i]]] <- paste0("syntax_", spans$token[[i]])
        }
        if (!is.null(sel) && row >= sel$start[["row"]] && row <= sel$end[["row"]]) {
          s <- if (row == sel$start[["row"]]) sel$start[["col"]] else 0L
          e <- if (row == sel$end[["row"]]) sel$end[["col"]] else n
          kinds[idx > s & idx <= e] <- "selected"
        }
        if (!is.null(query)) {
          m <- query_line_matches(query, private$.buf$lines[[row]])
          for (i in seq_len(nrow(m))) kinds[idx > m$start[[i]] & idx <= m$end[[i]]] <- "match"
        }
        if (focused && row == state$cursor_row && state$cursor_col < n) {
          kinds[idx == state$cursor_col + 1L] <- "cursor"
        }
        pieces <- cells$chars[idx]
        widths <- cells$widths[idx]
      } else {
        pieces <- character()
        widths <- integer()
      }
      # A selected line break is shown as a highlighted blank cell.
      eol_selected <- !is.null(sel) && last_seg && row >= sel$start[["row"]] && row < sel$end[["row"]]
      cursor_eol <- focused && last_seg && row == state$cursor_row && state$cursor_col >= n
      if (eol_selected || cursor_eol) {
        pieces <- c(pieces, " ")
        widths <- c(widths, 1L)
        kinds <- c(kinds, if (cursor_eol) "cursor" else "selected")
      }
      if (!length(pieces)) return(invisible())
      # Horizontal scrolling (no wrap): drop what is left of the window.
      shift <- state$left
      if (shift > 0L) {
        ends <- cumsum(widths)
        keep <- ends > shift
        pieces <- pieces[keep]
        kinds <- kinds[keep]
        widths <- widths[keep]
        if (!length(pieces)) return(invisible())
      }
      cum <- cumsum(widths)
      visible <- cum - widths < g$text_w - 0L
      pieces <- pieces[visible]
      kinds <- kinds[visible]
      widths <- widths[visible]
      groups <- cumsum(c(TRUE, kinds[-1L] != kinds[-length(kinds)]))
      x <- x0
      group_cells <- split(seq_along(pieces), groups)
      for (gi in group_cells) {
        kind <- kinds[[gi[[1]]]]
        text <- paste(pieces[gi], collapse = "")
        w <- sum(widths[gi])
        sty <- if (kind == "plain") NULL else styles[[kind]]
        buffer$put_text(x, y, text, fg = sty$foreground %||% st$foreground, bg = sty$background %||% st$background,
                        attrs = bitwOr(st$attrs, sty$attrs %||% 0L), clip = clip)
        x <- x + w
      }
      invisible()
    },

    paint_bars = function(buffer, g, st, clip, rows) {
      state <- private$.state
      if (g$bar_y) {
        track <- rect(g$inner$x + g$inner$width - 1L, g$inner$y, 1L, g$body_h)
        view <- if (length(rows)) length(unique(vapply(rows, `[[`, 0L, 1L))) else g$body_h
        draw_scrollbar(buffer, track, state$top - 1L, g$n, max(1L, view), "vertical", st, clip)
      }
      if (g$bar_x) {
        track <- rect(g$inner$x + g$gutter, g$inner$y + g$body_h, g$text_w, 1L)
        draw_scrollbar(buffer, track, state$left, g$max_left + g$text_w, g$text_w, "horizontal", st, clip)
      }
      invisible()
    },

    paint_find_bar = function(buffer, g, st, clip) {
      bar <- resolve_style(style(background = "$surface", foreground = "$foreground"), parent = st)
      err <- resolve_style(style(background = "$surface", foreground = "$error", bold = TRUE), parent = bar)
      width <- g$inner$width
      flags <- paste0(if (isTRUE(private$.find_case)) "[Aa] " else "", if (isTRUE(private$.find_regex)) "[.*] " else "")
      status <- if (!is.null(private$.find_error)) {
        sub("^Invalid regular expression ", "bad regex: ", private$.find_error)
      } else if (!nzchar(private$.find_text)) {
        ""
      } else {
        count <- private$count_matches()
        if (count == 0L) "no matches" else paste0(count, if (count == 1L) " match" else " matches")
      }
      left <- if (private$.replace_open) {
        paste0(if (private$.replace_target == "query") " Find: " else " Replace: ",
               if (private$.replace_target == "query") private$.find_text else private$.replace_text, "_")
      } else paste0(" Find: ", private$.find_text, "_")
      right <- paste0(flags, status, " ")
      room <- max(0L, width - str_width(right))
      line <- paste0(str_align(left, room), str_truncate(right, width))
      sty <- if (!is.null(private$.find_error) || (nzchar(private$.find_text) && private$count_matches() == 0L)) err else bar
      buffer$put_text(g$inner$x, g$bar_row, str_align(line, width), fg = sty$foreground, bg = sty$background,
                      attrs = sty$attrs, clip = clip)
    },

    paint_goto_bar = function(buffer, g, st, clip) {
      bar <- resolve_style(style(background = "$surface", foreground = "$foreground"), parent = st)
      text <- paste0(" Go to line (1-", private$.buf$n_lines(), "): ", private$.goto_text, "_")
      buffer$put_text(g$inner$x, g$bar_row, str_align(str_truncate(text, g$inner$width), g$inner$width),
                      fg = bar$foreground, bg = bar$background, attrs = bar$attrs, clip = clip)
    }
  )
)

# Start indices of the visual rows of a wrapped line. Every grapheme belongs
# to exactly one row; rows break after the last space that fits (a space just
# past the width hangs at the end of the row), or inside a word when there is
# none.
editor_wrap_starts <- function(chars, widths, width) {
  n <- length(chars)
  if (n == 0L || sum(widths) <= width) return(1L)
  csum <- cumsum(widths)
  starts <- 1L
  start <- 1L
  repeat {
    base <- if (start > 1L) csum[[start - 1L]] else 0L
    fit <- findInterval(base + width, csum) - start + 1L
    if (fit < 1L) fit <- 1L
    end <- start + fit - 1L
    if (end >= n) break
    if (chars[[end + 1L]] == " ") {
      # The space after the last word hangs at the end of this row.
      end <- end + 1L
      if (end >= n) break
    } else if (chars[[end]] != " ") {
      spaces <- which(chars[start:end] == " ")
      if (length(spaces)) end <- start + max(spaces) - 1L
    }
    start <- end + 1L
    starts <- c(starts, start)
  }
  starts
}

#' Multi-line text editor
#'
#' An editable text area with a cursor, selection, undo/redo, search and
#' scrolling. Text is stored as a vector of lines and edited by logical
#' (row, grapheme) positions, so emoji, accents and CJK text are never
#' split, and only the visible lines are formatted when drawing.
#'
#' Keys: arrows, Home/End, Ctrl+Home/End, Ctrl+Left/Right (words),
#' PageUp/PageDown, Shift+ to select, Ctrl+A select all, Enter, Backspace,
#' Delete, Ctrl+Backspace / Ctrl+Delete (words), Ctrl+U / Ctrl+K (to line
#' start / end), Ctrl+C / Ctrl+X / Ctrl+V (copy, cut, paste with the app
#' clipboard; copy also reaches the system clipboard via OSC 52 when the
#' terminal supports it), Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find (Enter /
#' Down next, Up previous, Ctrl+R regex, Alt+C case, Esc close), F3 /
#' Shift+F3 next / previous match. Tab moves focus unless
#' `tab_behavior = "indent"`. The mouse places the cursor, drags select and
#' the wheel scrolls. A bracketed paste is one undo step.
#'
#' Messages (`event$data`): `"textarea.changed"` (`version`, `lines`,
#' `valid`; read the text with `$value`), `"textarea.selection_changed"`
#' (`empty`, `start_row`, `start_column`, `end_row`, `end_column`) and
#' `"textarea.valid"` / `"textarea.invalid"` (`error`).
#'
#' Editing groups: typing is merged into undo steps per word, paste,
#' delete-selection and line operations are single steps. The history is
#' bounded (`max_history` steps and about 2 million characters).
#' Tabs in text are expanded to spaces. This widget is **experimental**.
#'
#' @param value Initial text.
#' @param placeholder Text shown while the area is empty.
#' @param id Optional identifier.
#' @param classes Optional classes.
#' @param style A [style()].
#' @param disabled Disabled areas cannot be focused or edited.
#' @param line_numbers Show line numbers?
#' @param wrap Wrap long lines? If `FALSE` the view scrolls horizontally.
#' @param read_only Block editing?
#' @param language Optional language label supplied to the highlighter.
#' @param tab_size Spaces per tab stop.
#' @param tab_behavior `"focus"` (Tab moves focus) or `"indent"`.
#' @param auto_indent Keep the indentation on Enter?
#' @param validate Optional `function(value)` returning `NULL`/`TRUE` when
#'   valid or a message.
#' @param max_history Maximum number of undo steps.
#' @param highlighter Optional viewport-first syntax highlighter; see
#'   `r_highlighter()`.
#' @return A `TextArea` widget. Useful members: `$value`, `$set_text()`,
#'   `$insert()`, `$selection`, `$cursor_row`, `$cursor_column`,
#'   `$goto_line()`, `$cursor_position()`, `$undo()`, `$redo()`, `$find()`,
#'   `$replace()`, `$replace_all()`, `$find_next()`, `$find_previous()`,
#'   `$match_count`.
#' @export
#' @examples
#' ed <- text_area("Hello\nworld", id = "editor", line_numbers = TRUE)
#' ed$insert("!")
#' ed$value
#' ed$find("wor")
#' ed$selection
text_area <- function(value = "", placeholder = "", id = NULL, classes = NULL, style = NULL,
                      disabled = FALSE, line_numbers = FALSE, wrap = TRUE, read_only = FALSE,
                      language = NULL, tab_size = 4L, tab_behavior = "focus", auto_indent = FALSE,
                      validate = NULL, max_history = 200L, highlighter = NULL) {
  TextArea$new(value, placeholder = placeholder, id = id, classes = classes, style = style,
               disabled = disabled, line_numbers = line_numbers, wrap = wrap, read_only = read_only,
               language = language, tab_size = tab_size, tab_behavior = tab_behavior,
               auto_indent = auto_indent, validate = validate, max_history = max_history,
               highlighter = highlighter)
}

#' Lightweight R syntax highlighter
#'
#' Returns a viewport-first tokenizer for [text_area()]. It highlights common
#' R keywords, strings, comments, numeric constants, operators, and function
#' names. It uses tolerant regular expressions and accepts incomplete syntax.
#' Each line produces grapheme-indexed spans with `start`, `end`, and `token`.
#' @return A function `(lines, state = NULL)` suitable for `text_area()`.
#' @export
r_highlighter <- function() {
  pattern <- paste0(
    '"(?:\\\\.|[^"\\\\])*"?', "|", "'(?:\\\\.|[^'\\\\])*'?", "|",
    "`[^`]*`|#[^\\n]*|\\b(?:if|else|repeat|while|function|for|in|next|break)\\b|",
    "\\b(?:TRUE|FALSE|NULL|NA|NA_integer_|NA_real_|NA_character_|Inf|NaN)\\b|",
    "\\b[0-9]+(?:\\.[0-9]*)?(?:[eE][+-]?[0-9]+)?[L]?\\b|",
    "[A-Za-z.][A-Za-z0-9._]*(?=\\s*\\()|<<-|<-|->>|->|==|!=|<=|>=|&&|\\|\\||[+*/^~:$@=<>!&|%-]"
  )
  function(lines, state = NULL) {
    lapply(lines, function(line) {
      match <- gregexpr(pattern, line, perl = TRUE)[[1L]]
      if (match[[1L]] < 0L) return(data.frame(start = integer(), end = integer(), token = character()))
      text <- regmatches(line, list(match))[[1L]]
      lens <- attr(match, "match.length")
      token <- vapply(text, function(value) {
        if (startsWith(value, "#")) "comment"
        else if (startsWith(value, "\"") || startsWith(value, "'") || startsWith(value, "`")) "string"
        else if (grepl("^(if|else|repeat|while|function|for|in|next|break)$", value)) "keyword"
        else if (grepl("^(TRUE|FALSE|NULL|NA|NA_integer_|NA_real_|NA_character_|Inf|NaN)$", value)) "constant"
        else if (grepl("^[0-9]", value)) "number"
        else if (grepl("^[A-Za-z.]", value)) "function"
        else "operator"
      }, "")
      start <- as.integer(match)
      end <- start + as.integer(lens) - 1L
      if (!is_ascii(line)) {
        cumulative <- cumsum(nchar(split_graphemes(line), type = "chars"))
        start <- findInterval(start - 1L, cumulative) + 1L
        end <- findInterval(end - 1L, cumulative) + 1L
      }
      data.frame(start = as.integer(start), end = as.integer(end), token = token)
    })
  }
}

Try the termr package in your browser

Any scripts or data that you put into this service are public.

termr documentation built on Oct. 21, 2026, 5:13 p.m.