diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 1f692ae..5db11df 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -5,7 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +ghostty-datastruct.workspace = true +ghostty-unicode.workspace = true ghostty-vt.workspace = true +log = "0.4.34" [lints] workspace = true diff --git a/crates/ghostty-terminal/src/cursor.rs b/crates/ghostty-terminal/src/cursor.rs new file mode 100644 index 0000000..86e45e3 --- /dev/null +++ b/crates/ghostty-terminal/src/cursor.rs @@ -0,0 +1,22 @@ +//! Cursor visual style (ZIG/src/terminal/cursor.zig). + +/// The visual style of the cursor. Whether or not it blinks +/// is determined by mode 12 (modes.zig). This mode is synchronized +/// with CSI q, the same as xterm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Style { + /// DECSCUSR 5, 6 + Bar, + /// DECSCUSR 1, 2 + #[default] + Block, + /// DECSCUSR 3, 4 + Underline, + + /// The cursor styles below aren't known by DESCUSR and are custom + /// implemented in Ghostty. They are reported as some standard style + /// if requested, though. + /// Hollow block cursor. This is a block cursor with the center empty. + /// Reported as DECSCUSR 1 or 2 (block). + BlockHollow, +} diff --git a/crates/ghostty-terminal/src/lib.rs b/crates/ghostty-terminal/src/lib.rs index 6dfcc74..150187a 100644 --- a/crates/ghostty-terminal/src/lib.rs +++ b/crates/ghostty-terminal/src/lib.rs @@ -1,9 +1,15 @@ //! Safe page-based terminal memory model. #![cfg_attr(test, allow(clippy::unwrap_used))] +pub mod cursor; pub mod hyperlink; pub mod page; pub mod page_list; +pub mod pin_iter; pub mod point; pub mod ref_counted_set; +pub mod screen; +pub mod screen_set; +pub mod selection; pub mod size; pub mod style; +pub mod tabstops; diff --git a/crates/ghostty-terminal/src/pin_iter.rs b/crates/ghostty-terminal/src/pin_iter.rs new file mode 100644 index 0000000..dcc8474 --- /dev/null +++ b/crates/ghostty-terminal/src/pin_iter.rs @@ -0,0 +1,148 @@ +//! Pin-based iterators. +//! +//! Zig's `PageList.Pin` has `rowIterator`, `cellIterator` and +//! `promptIterator` methods that start from a pin and use an inclusive limit +//! *pin* (the Rust `PageList` iterators are point-based). These are local +//! equivalents used by `Screen` and `Selection`. +//! +//! TODO(integration): replace with `Pin` methods in `page_list.rs` once +//! available (see docs/rewrite/notes/ghostty-terminal-pagelist-requests.md). +use crate::{ + page_list::{Direction, PageList, Pin, PromptIterator}, + point::{Coordinate, Point, Tag}, +}; + +fn same_row(a: Pin, b: Pin) -> bool { + a.node == b.node && a.y == b.y +} + +/// Iterates the rows (x = 0) from `start` toward `limit` (inclusive). +pub struct RowIter<'a> { + list: &'a PageList, + current: Option, + limit: Option, + direction: Direction, +} + +impl Iterator for RowIter<'_> { + type Item = Pin; + + fn next(&mut self) -> Option { + let cur = self.current?; + let result = Pin { x: 0, ..cur }; + if self.limit.is_some_and(|l| same_row(l, cur)) { + self.current = None; + } else { + self.current = match self.direction { + Direction::RightDown => cur.down(self.list, 1), + Direction::LeftUp => cur.up(self.list, 1), + }; + } + Some(result) + } +} + +/// Equivalent of `Pin.rowIterator(direction, limit)`. The limit is inclusive. +pub fn row_iterator( + list: &PageList, + start: Pin, + direction: Direction, + limit: Option, +) -> RowIter<'_> { + RowIter { + list, + current: Some(start), + limit, + direction, + } +} + +/// Iterates cells from `start` toward the end of the `limit` row. +/// +/// Note that, as in Zig, the limit only applies at row granularity: iteration +/// continues to the end (or start, for `LeftUp`) of the limit row. +pub struct CellIter<'a> { + list: &'a PageList, + cell: Option, + limit: Option, + direction: Direction, +} + +impl Iterator for CellIter<'_> { + type Item = Pin; + + fn next(&mut self) -> Option { + let cell = self.cell?; + let cols = self + .list + .node(cell.node) + .map_or(self.list.cols, |n| n.data.size.cols); + let at_limit = self.limit.is_some_and(|l| same_row(l, cell)); + self.cell = match self.direction { + Direction::RightDown => { + if cell.x + 1 < cols { + Some(Pin { + x: cell.x + 1, + ..cell + }) + } else if at_limit { + None + } else { + cell.down(self.list, 1).map(|p| Pin { x: 0, ..p }) + } + } + Direction::LeftUp => { + if cell.x > 0 { + Some(Pin { + x: cell.x - 1, + ..cell + }) + } else if at_limit { + None + } else { + cell.up(self.list, 1).map(|p| { + let cols = self.list.node(p.node).map_or(cols, |n| n.data.size.cols); + Pin { x: cols - 1, ..p } + }) + } + } + }; + Some(cell) + } +} + +/// Equivalent of `Pin.cellIterator(direction, limit)`. +pub fn cell_iterator( + list: &PageList, + start: Pin, + direction: Direction, + limit: Option, +) -> CellIter<'_> { + CellIter { + list, + cell: Some(start), + limit, + direction, + } +} + +/// Equivalent of `Pin.promptIterator(direction, limit)`. +pub fn prompt_iterator( + list: &PageList, + start: Pin, + direction: Direction, + limit: Option, +) -> PromptIterator<'_> { + let pt = |p: Pin| { + list.point_from_pin(Tag::Screen, p) + .unwrap_or(Point::Screen(Coordinate::default())) + }; + match direction { + Direction::RightDown => list.prompt_iterator(direction, pt(start), limit.map(pt)), + Direction::LeftUp => list.prompt_iterator( + direction, + limit.map_or(Point::Screen(Coordinate::default()), pt), + Some(pt(start)), + ), + } +} diff --git a/crates/ghostty-terminal/src/screen.rs b/crates/ghostty-terminal/src/screen.rs new file mode 100644 index 0000000..02a18bf --- /dev/null +++ b/crates/ghostty-terminal/src/screen.rs @@ -0,0 +1,3811 @@ +//! A single terminal screen: a page list plus the cursor, saved cursor, +//! selection, charset state, kitty keyboard flags and semantic prompt state. +//! +//! Port of `ZIG/src/terminal/Screen.zig`. +//! +//! # Differences from the Zig API +//! +//! * The cursor's `page_pin` is a [`TrackedPin`] handle into `screen.pages`; +//! the cached `page_row` / `page_cell` pointers are replaced by on-demand +//! accessors ([`Screen::cursor_row`], [`Screen::cursor_cell`], ...). +//! * Errors use [`page::Error`] (which covers Zig's `Allocator.Error`, +//! `IncreaseCapacityError`, `SplitError` and the page insertion errors). +//! * Selection helpers take the screen's [`PageList`] explicitly (see +//! `selection.rs`). +#![allow( + clippy::manual_assert_eq, + clippy::single_match_else, + clippy::doc_overindented_list_items, + clippy::items_after_statements +)] +use crate::{ + cursor::Style as CursorStyle, + hyperlink::{self, Hyperlink, HyperlinkId}, + page::{Cell, ContentTag, Page, Row, SemanticContent, SemanticPrompt as RowPrompt, Wide}, + page_list::{ + CellSubset, Chunk, Direction, IncreaseCapacity, NodeId, PageList, Pin, + Scroll as ListScroll, TrackedPin, Viewport, + }, + pin_iter, + point::{Coordinate, Point, Tag}, + selection::{Bounds, Order, Selection}, + size, + style::{self, Style}, +}; +use ghostty_vt::{ + ansi::ProtectedMode, + charsets::{Charset, Slots}, + kitty::key::FlagStack, + osc::semantic_prompt::{Click, PromptKind, Redraw}, + sgr, +}; + +pub use crate::page::Error; + +/// The Unicode codepoint used by the Kitty graphics protocol for virtual +/// placements (`kitty.graphics.unicode.placeholder`). +const KITTY_PLACEHOLDER: u32 = 0x10EEEE; + +/// See Terminal.Dirty. This behaves the same way. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Dirty { + /// Set when the selection is set or unset, regardless of if the + /// selection is changed or not. + pub selection: bool, + + /// When an OSC8 hyperlink is hovered, we set the full screen as dirty + /// because links can span multiple lines. + pub hyperlink_hover: bool, +} + +/// How click handling in a prompt is handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticClick { + None, + ClickEvents, + Cl(Click), +} + +/// Semantic prompt (OSC133) state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SemanticPrompt { + /// This is flipped to true when any sort of semantic content is + /// seen. In particular, this is set to true only when a `prompt` type + /// is ever set on our cursor. + /// + /// This is used to optimize away semantic content operations if we know + /// we've never seen them. + pub seen: bool, + + /// This is set on any `cl` or `click_events` option set on the + /// most recent OSC 133 commands to specify how click handling in a + /// prompt is handling. + pub click: SemanticClick, +} + +impl SemanticPrompt { + pub const DISABLED: SemanticPrompt = SemanticPrompt { + seen: false, + click: SemanticClick::None, + }; +} + +/// Kitty graphics protocol state. +/// +/// TODO(integration): replace with `kitty::graphics_image::ImageStorage` +/// once the kitty graphics modules are ported. Only the dirty flag that +/// `Screen` touches is represented here. +#[derive(Debug, Clone, Default)] +pub struct KittyImages { + /// Dirty flag for the renderer. + pub dirty: bool, +} + +/// The cursor position and style. +#[derive(Debug, Clone)] +pub struct Cursor { + /// The x position within the active area. + pub x: size::CellCountInt, + /// The y position within the active area. + pub y: size::CellCountInt, + + /// The visual style of the cursor. This defaults to block because + /// it has to default to something, but users of this struct are + /// encouraged to set their own default. + pub cursor_style: CursorStyle, + + /// The "last column flag (LCF)" as its called. If this is set then the + /// next character print will force a soft-wrap. + pub pending_wrap: bool, + + /// The protected mode state of the cursor. If this is true then + /// all new characters printed will have the protected state set. + pub protected: bool, + + /// The currently active style. This is the concrete style value + /// that should be kept up to date. The style ID to use for cell writing + /// is below. + pub style: Style, + + /// The currently active style ID. The style is page-specific so when + /// we change pages we need to ensure that we update that page with + /// our style when used. + pub style_id: style::Id, + + /// The hyperlink ID that is currently active for the cursor. A value + /// of zero means no hyperlink is active. (Implements OSC8, saying that + /// so code search can find it.). + pub hyperlink_id: hyperlink::Id, + + /// This is the implicit ID to use for hyperlinks that don't specify + /// an ID. We do an overflowing add to this so repeats can technically + /// happen with carefully crafted inputs but for real workloads its + /// highly unlikely -- and the fix is for the TUI program to use explicit + /// IDs. + pub hyperlink_implicit_id: size::OffsetInt, + + /// Heap-allocated hyperlink state so that we can recreate it when + /// the cursor page pin changes. We can't get it from the old screen + /// state because the page may be cleared. + pub hyperlink: Option>, + + /// The current semantic content type for the cursor that will be + /// applied to any newly written cells. + pub semantic_content: SemanticContent, + pub semantic_content_clear_eol: bool, + + /// The tracked pin into the page list where the cursor is currently + /// located. This makes it faster to move the cursor. Read it with + /// [`Screen::cursor_pin`]. + pub page_pin: TrackedPin, +} + +impl Cursor { + fn new(page_pin: TrackedPin) -> Self { + Self { + x: 0, + y: 0, + cursor_style: CursorStyle::Block, + pending_wrap: false, + protected: false, + style: Style::default(), + style_id: style::DEFAULT_ID, + hyperlink_id: 0, + hyperlink_implicit_id: 0, + hyperlink: None, + semantic_content: SemanticContent::Output, + semantic_content_clear_eol: false, + page_pin, + } + } +} + +/// The set of 4 charsets (G0-G3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CharsetArray { + pub g0: Charset, + pub g1: Charset, + pub g2: Charset, + pub g3: Charset, +} + +impl Default for CharsetArray { + fn default() -> Self { + Self { + g0: Charset::Utf8, + g1: Charset::Utf8, + g2: Charset::Utf8, + g3: Charset::Utf8, + } + } +} + +impl CharsetArray { + #[inline] + pub fn get(&self, slot: Slots) -> Charset { + match slot { + Slots::G0 => self.g0, + Slots::G1 => self.g1, + Slots::G2 => self.g2, + Slots::G3 => self.g3, + } + } + + #[inline] + pub fn set(&mut self, slot: Slots, charset: Charset) { + match slot { + Slots::G0 => self.g0 = charset, + Slots::G1 => self.g1 = charset, + Slots::G2 => self.g2 = charset, + Slots::G3 => self.g3 = charset, + } + } +} + +/// State required for all charset operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CharsetState { + /// The list of graphical charsets by slot + pub charsets: CharsetArray, + + /// GL is the slot to use when using a 7-bit printable char (up to 127) + /// GR used for 8-bit printable chars. + pub gl: Slots, + pub gr: Slots, + + /// Single shift where a slot is used for exactly one char. + pub single_shift: Option, +} + +impl Default for CharsetState { + fn default() -> Self { + Self { + charsets: CharsetArray::default(), + gl: Slots::G0, + gr: Slots::G2, + single_shift: None, + } + } +} + +/// Saved cursor state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SavedCursor { + pub x: size::CellCountInt, + pub y: size::CellCountInt, + pub style: Style, + pub protected: bool, + pub pending_wrap: bool, + pub origin: bool, + pub charset: CharsetState, +} + +#[derive(Debug, Clone, Copy)] +pub struct Options { + pub cols: size::CellCountInt, + pub rows: size::CellCountInt, + + /// The maximum size of scrollback in bytes. Zero means no scrollback. + /// Any other value will be clamped to support a minimum of the active + /// area. + pub max_scrollback: usize, + + /// The total storage limit for Kitty images in bytes for this + /// screen. Kitty image storage is per-screen. + pub kitty_image_storage_limit: usize, +} + +impl Options { + /// A simple, default terminal. If you rely on specific dimensions or + /// scrollback (or lack of) then do not use this directly. This is just + /// for callers that need some defaults. + pub const DEFAULT: Options = Options { + cols: 80, + rows: 24, + max_scrollback: 0, + kitty_image_storage_limit: 320 * 1000 * 1000, // 320MB + }; + + pub fn new(cols: size::CellCountInt, rows: size::CellCountInt, max_scrollback: usize) -> Self { + Self { + cols, + rows, + max_scrollback, + ..Self::DEFAULT + } + } +} + +impl Default for Options { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Options for scrolling the viewport of the terminal grid. The reason +/// we have this in addition to PageList.Scroll is because we have additional +/// scroll behaviors that are not part of the PageList.Scroll enum. +#[derive(Debug, Clone, Copy)] +pub enum Scroll { + /// For all of these, see PageList.Scroll. + Active, + Top, + Pin(Pin), + Row(usize), + DeltaRow(isize), + DeltaPrompt(isize), +} + +#[derive(Debug, Clone, Copy)] +pub struct Resize { + /// The new size to resize to + pub cols: size::CellCountInt, + pub rows: size::CellCountInt, + + /// Whether to reflow soft-wrapped text. + /// + /// This will reflow soft-wrapped text. If the screen size is getting + /// smaller and the maximum scrollback size is exceeded, data will be + /// lost from the top of the scrollback. + pub reflow: bool, + + /// Set this to enable prompt redraw on resize. This signals + /// that the running program can redraw the prompt if the cursor is + /// currently at a prompt. This detects OSC133 prompts lines and clears + /// them. If set to `Last`, only the most recent prompt line is cleared. + pub prompt_redraw: Redraw, +} + +impl Resize { + pub fn new(cols: size::CellCountInt, rows: size::CellCountInt) -> Self { + Self { + cols, + rows, + reflow: true, + prompt_redraw: Redraw::False, + } + } +} + +/// The semantic content setting for `cursor_set_semantic_content`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputClear { + ClearExplicit, + ClearEol, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticContentSet { + Prompt(PromptKind), + Output, + Input(InputClear), +} + +/// A string and a map from each byte of the string to the pin it came from. +/// +/// TODO(integration): replace with `string_map::StringMap` once ported. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StringMap { + pub string: String, + pub map: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct SelectionString { + /// The selection to convert to a string. + pub sel: Selection, + + /// If true, trim whitespace around the selection. + pub trim: bool, +} + +impl SelectionString { + pub fn new(sel: Selection) -> Self { + Self { sel, trim: true } + } +} + +/// The default whitespace set for line/all selection: NUL, space and tab. +pub const DEFAULT_SELECT_WHITESPACE: &[u32] = &[0, ' ' as u32, '\t' as u32]; + +#[derive(Debug, Clone, Copy)] +pub struct SelectLine<'a> { + /// The pin of some part of the line to select. + pub pin: Pin, + + /// These are the codepoints to consider whitespace to trim + /// from the ends of the selection. + pub whitespace: Option<&'a [u32]>, + + /// If true, line selection will consider semantic prompt + /// state changing a boundary. State changing is ANY state + /// change. + pub semantic_prompt_boundary: bool, +} + +impl SelectLine<'_> { + pub fn new(pin: Pin) -> SelectLine<'static> { + SelectLine { + pin, + whitespace: Some(DEFAULT_SELECT_WHITESPACE), + semantic_prompt_boundary: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PromptClickMove { + pub left: usize, + pub right: usize, +} + +impl PromptClickMove { + pub const ZERO: PromptClickMove = PromptClickMove { left: 0, right: 0 }; +} + +/// Options for [`Screen::dump_string`]. +#[derive(Debug, Clone, Copy)] +pub struct DumpString { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + pub tl: Pin, + pub br: Option, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + pub unwrap: bool, +} + +impl DumpString { + pub fn new(tl: Pin) -> Self { + Self { + tl, + br: None, + unwrap: true, + } + } +} + +/// A screen is a single terminal grid with its own cursor and scrollback. +#[derive(Debug)] +pub struct Screen { + /// The list of pages in the screen. + pub pages: PageList, + + /// Special-case where we want no scrollback whatsoever. We have to flag + /// this because max_size 0 in PageList gets rounded up to two pages so + /// we can always have an active screen. + pub no_scrollback: bool, + + /// The current cursor position + pub cursor: Cursor, + + /// The saved cursor + pub saved_cursor: Option, + + /// The selection for this screen (if any). This MUST be a tracked + /// selection otherwise the selection will become invalid. Instead of + /// accessing this directly to set it, use [`Screen::select`] which will + /// automatically setup tracking. + pub selection: Option, + + /// The charset state + pub charset: CharsetState, + + /// The current or most recent protected mode. Once a protection mode is + /// set, this will never become "off" again until the screen is reset. + /// The current state of whether protection attributes should be set is + /// set on the Cell pen; this is only used to determine the most recent + /// protection mode since some sequences such as ECH depend on this. + pub protected_mode: ProtectedMode, + + /// The kitty keyboard settings. + pub kitty_keyboard: FlagStack, + + /// Kitty graphics protocol state. + pub kitty_images: KittyImages, + + /// Semantic prompt (OSC133) state. + pub semantic_prompt: SemanticPrompt, + + /// Dirty flags for the renderer. + pub dirty: Dirty, +} + +impl Screen { + /// Initialize a new screen. + /// + /// max_scrollback is the amount of scrollback to keep in bytes. This + /// will be rounded UP to the nearest page size because our minimum + /// allocation size is that anyways. + /// + /// If max scrollback is 0, then no scrollback is kept at all. + pub fn new(opts: Options) -> Result { + // Initialize our backing pages. + let mut pages = PageList::init(opts.cols, opts.rows, Some(opts.max_scrollback))?; + + // Create our tracked pin for the cursor. + let first = pages.first.ok_or(Error::InvalidId)?; + let page_pin = pages.track_pin(Pin { + node: first, + y: 0, + x: 0, + garbage: false, + })?; + + Ok(Screen { + pages, + no_scrollback: opts.max_scrollback == 0, + cursor: Cursor::new(page_pin), + saved_cursor: None, + selection: None, + charset: CharsetState::default(), + protected_mode: ProtectedMode::Off, + kitty_keyboard: FlagStack::default(), + kitty_images: KittyImages::default(), + semantic_prompt: SemanticPrompt::DISABLED, + dirty: Dirty::default(), + }) + } + + /// The page for a node (panics if the node is stale). + pub fn page(&self, node: NodeId) -> &Page { + &self.pages.node(node).expect("live page node").data + } + + /// The mutable page for a node (panics if the node is stale). + pub fn page_mut(&mut self, node: NodeId) -> &mut Page { + &mut self.pages.node_mut(node).expect("live page node").data + } + + /// The current location of the cursor in the page list. + pub fn cursor_pin(&self) -> Pin { + self.pages + .tracked_pin(self.cursor.page_pin) + .expect("cursor pin is always tracked") + } + + fn set_cursor_pin(&mut self, p: Pin) { + self.pages + .set_tracked_pin(self.cursor.page_pin, p) + .expect("cursor pin must be valid"); + } + + /// The page the cursor is currently on. + pub fn cursor_page(&self) -> &Page { + self.page(self.cursor_pin().node) + } + + /// The page the cursor is currently on. + pub fn cursor_page_mut(&mut self) -> &mut Page { + let node = self.cursor_pin().node; + self.page_mut(node) + } + + /// The row the cursor is on (Zig: `cursor.page_row`). + pub fn cursor_row(&self) -> Row { + let p = self.cursor_pin(); + *self + .page(p.node) + .get_row(usize::from(p.y)) + .expect("cursor row") + } + + pub fn cursor_row_mut(&mut self) -> &mut Row { + let p = self.cursor_pin(); + self.page_mut(p.node) + .get_row_mut(usize::from(p.y)) + .expect("cursor row") + } + + /// The cell under the cursor (Zig: `cursor.page_cell`). + pub fn cursor_cell(&self) -> Cell { + let p = self.cursor_pin(); + *self + .page(p.node) + .get_row_and_cell(usize::from(p.x), usize::from(p.y)) + .expect("cursor cell") + .1 + } + + pub fn cursor_cell_mut(&mut self) -> &mut Cell { + let p = self.cursor_pin(); + let page = self.page_mut(p.node); + let i = page + .cell_index(usize::from(p.x), usize::from(p.y)) + .expect("cursor cell"); + &mut page.cells[i] + } + + /// The cells of the cursor's row. + pub fn cursor_row_cells(&self) -> &[Cell] { + let p = self.cursor_pin(); + self.page(p.node) + .get_cells(usize::from(p.y)) + .expect("cursor row") + } + + /// Assert that the screen is in a consistent state. This doesn't check + /// all pages in the page list because that is SO SLOW even just for + /// tests. This only asserts the screen specific data so callers should + /// ensure they're also calling page integrity checks if necessary. + pub fn assert_integrity(&self) { + if cfg!(debug_assertions) { + assert!(self.cursor.x < self.pages.cols); + assert!(self.cursor.y < self.pages.rows); + + // Our cursor x/y should always match the pin. If this doesn't + // match then it indicates that the tracked pin moved and we + // didn't account for it by either calling cursor_reload or + // manually adjusting. + let pt = self + .pages + .point_from_pin(Tag::Active, self.cursor_pin()) + .expect("cursor pin must be in the active area"); + assert_eq!(self.cursor.x, pt.coord().x); + assert_eq!(u32::from(self.cursor.y), pt.coord().y); + } + } + + /// Reset the screen according to the logic of a DEC RIS sequence. + /// + /// - Clears the screen and attempts to reclaim memory. + /// - Moves the cursor to the top-left. + /// - Clears any cursor state: style, hyperlink, etc. + /// - Resets the charset + /// - Clears the selection + /// - Deletes all Kitty graphics + /// - Resets Kitty Keyboard settings + /// - Disables protection mode + pub fn reset(&mut self) -> Result<(), Error> { + // Reset our pages + self.pages.reset()?; + + // The above reset preserves tracked pins so we can still use + // our cursor pin, which should be at the top-left already. + let first = self.pages.first.ok_or(Error::InvalidId)?; + let pin = self.cursor_pin(); + assert!(pin.node == first); + assert!(pin.x == 0); + assert!(pin.y == 0); + self.set_cursor_pin(Pin { + garbage: false, + ..pin + }); + self.cursor = Cursor::new(self.cursor.page_pin); + + // Reset kitty graphics storage + self.kitty_images = KittyImages { dirty: true }; + + // Reset our basic state + self.saved_cursor = None; + self.charset = CharsetState::default(); + self.kitty_keyboard = FlagStack::default(); + self.protected_mode = ProtectedMode::Off; + self.semantic_prompt = SemanticPrompt::DISABLED; + self.clear_selection(); + Ok(()) + } + + /// Clone the screen. + /// + /// This will copy: + /// + /// - Screen dimensions + /// - Screen data (cell state, etc.) for the region + /// + /// Anything not mentioned above is NOT copied. Some of this is for + /// very good reason: + /// + /// - Kitty images have a LOT of data. This is not efficient to copy. + /// Use a lock and access the image data. The dirty bit is there for + /// a reason. + /// - Cursor location can be expensive to calculate with respect to the + /// specified region. It is faster to grab the cursor from the old + /// screen and then move it to the new screen. + /// - Current hyperlink cursor state has heap allocations. Since clone + /// is only for read-only operations, it is better to not have any + /// hyperlink state. Note that already-written hyperlinks are cloned. + /// + /// If not mentioned above, then there isn't a specific reason right now + /// to not copy some data other than we probably didn't need it and it + /// isn't necessary for screen coherency. + /// + /// Other notes: + /// + /// - The viewport will always be set to the active area of the new + /// screen. This is the bottom "rows" rows. + /// - If the clone region is smaller than a viewport area, blanks will + /// be filled in at the bottom. + pub fn clone_screen(&self, top: Point, bot: Option) -> Result { + // Create a tracked pin remapper for our selection and cursor. + let (mut pages, pin_remap) = self.pages.clone_range(top, bot)?; + + // Find our cursor. If the cursor isn't in the cloned area, we move it + // to the top-left arbitrarily because a screen must have SOME cursor. + let mut cursor_xy = None; + if let Some(&handle) = pin_remap.get(&self.cursor.page_pin) + && let Some(p) = pages.tracked_pin(handle) + && let Some(pt) = pages.point_from_pin(Tag::Active, p) + { + cursor_xy = Some((handle, pt.coord().x, pt.coord().y as u16)); + } + let (page_pin, cx, cy) = match cursor_xy { + Some(v) => v, + None => { + let first = pages.first.ok_or(Error::InvalidId)?; + let h = pages.track_pin(Pin { + node: first, + y: 0, + x: 0, + garbage: false, + })?; + (h, 0, 0) + } + }; + let mut cursor = Cursor::new(page_pin); + cursor.x = cx; + cursor.y = cy; + + // Preserve our selection if we have one. + let sel: Option = if let Some(sel) = self.selection { + assert!(sel.tracked()); + let Bounds::Tracked { start, end } = sel.bounds else { + unreachable!() + }; + + let (tl, br) = match sel.order(&self.pages) { + Order::Forward | Order::MirroredForward => (start, end), + Order::Reverse | Order::MirroredReverse => (end, start), + }; + let tl_pin = self.pages.tracked_pin(tl).expect("selection pin"); + let br_pin = self.pages.tracked_pin(br).expect("selection pin"); + + let start_handle = match pin_remap.get(&tl).copied() { + Some(h) => Some(h), + None => 'start: { + // No start means it is outside the cloned area. + + // If we have no end pin then either + // (1) our whole selection is outside the cloned area or + // (2) our cloned area is within the selection + if !pin_remap.contains_key(&br) { + // We check if the selection bottom right pin is above + // the cloned area or if the top left pin is below the + // cloned area, in either of these cases it means that + // the selection is fully out of bounds, so we have no + // selection in the cloned area and break out now. + let Some(clone_top) = self.pages.pin(top) else { + break 'start None; + }; + let clone_top_y = self + .pages + .point_from_pin(Tag::Screen, clone_top) + .expect("pin in screen") + .coord() + .y; + if self + .pages + .point_from_pin(Tag::Screen, br_pin) + .expect("pin in screen") + .coord() + .y + < clone_top_y + { + break 'start None; + } + if self + .pages + .point_from_pin(Tag::Screen, tl_pin) + .expect("pin in screen") + .coord() + .y + > clone_top_y + { + break 'start None; + } + } + + // We move the top pin back in bounds to the top row. + let first = pages.first.ok_or(Error::InvalidId)?; + Some(pages.track_pin(Pin { + node: first, + y: 0, + x: if sel.rectangle { tl_pin.x } else { 0 }, + garbage: false, + })?) + } + }; + + match start_handle { + None => None, + Some(start_handle) => { + // If we got to this point it means that the selection is + // not fully out of bounds, so we move the bottom right pin + // back in bounds if it isn't already. + let end_handle = match pin_remap.get(&br).copied() { + Some(h) => h, + None => { + let last = pages.last.ok_or(Error::InvalidId)?; + let rows = pages.node(last).expect("pin in screen").data.size.rows; + pages.track_pin(Pin { + node: last, + x: if sel.rectangle { + br_pin.x + } else { + pages.cols - 1 + }, + y: rows - 1, + garbage: false, + })? + } + }; + Some(Selection { + bounds: Bounds::Tracked { + start: start_handle, + end: end_handle, + }, + rectangle: sel.rectangle, + }) + } + } + } else { + None + }; + + let result = Screen { + pages, + no_scrollback: self.no_scrollback, + cursor, + saved_cursor: None, + selection: sel, + charset: CharsetState::default(), + protected_mode: ProtectedMode::Off, + kitty_keyboard: FlagStack::default(), + kitty_images: KittyImages::default(), + semantic_prompt: SemanticPrompt::DISABLED, + dirty: self.dirty, + }; + result.assert_integrity(); + Ok(result) + } + + /// Increase the capacity of a page, keeping our cursor state (style and + /// hyperlink reference counts) consistent when it is the cursor page. + pub fn increase_capacity( + &mut self, + node: NodeId, + adjustment: Option, + ) -> Result { + // If the page being modified isn't our cursor page then + // this is a quick operation because we have no additional + // accounting. We have to do this check here BEFORE calling + // increase_capacity because increase_capacity will update all + // our tracked pins (including our cursor). + if node != self.cursor_pin().node { + return self.pages.increase_capacity(node, adjustment); + } + + // We're modifying the cursor page. When we increase the + // capacity below it will be short the ref count on our + // current style and hyperlink, so we need to init those. + let new_node = self.pages.increase_capacity(node, adjustment)?; + + // Re-add the style, if the page somehow doesn't have enough + // memory to add it, we emit a warning and gracefully degrade + // to the default style for the cursor. + if self.cursor.style_id != style::DEFAULT_ID { + let st = self.cursor.style; + self.cursor.style_id = match self.page_mut(new_node).styles.add(st) { + Ok(id) => id, + Err(err) => { + // TODO: Should we increase the capacity further in this case? + log::warn!( + "(Screen.increaseCapacity) Failed to add cursor style back to page, err={err:?}" + ); + + // Reset the cursor style. + self.cursor.style = Style::default(); + style::DEFAULT_ID + } + }; + } + + // Re-add the hyperlink, if the page somehow doesn't have enough + // memory to add it, we emit a warning and gracefully degrade to + // no hyperlink. + if let Some(link) = self.cursor.hyperlink.take() { + // So we don't attempt to free any memory in the replaced page. + self.cursor.hyperlink_id = 0; + + // Re-add + if let Err(err) = self.start_hyperlink_once(&link) { + // TODO: Should we increase the capacity further in this case? + log::warn!( + "(Screen.increaseCapacity) Failed to add cursor hyperlink back to page, err={err:?}" + ); + } + } + + // Reload the cursor information because the pin changed. + // So our page row/cell and so on are all off. + self.cursor_reload(); + + Ok(new_node) + } + + /// The cell `n` columns right of the cursor. + pub fn cursor_cell_right(&self, n: size::CellCountInt) -> Cell { + assert!(self.cursor.x + n < self.pages.cols); + self.cursor_row_cells()[usize::from(self.cursor.x + n)] + } + + pub fn cursor_cell_right_mut(&mut self, n: size::CellCountInt) -> &mut Cell { + assert!(self.cursor.x + n < self.pages.cols); + let p = self.cursor_pin(); + let page = self.page_mut(p.node); + let i = page + .cell_index(usize::from(p.x + n), usize::from(p.y)) + .expect("cursor cell"); + &mut page.cells[i] + } + + /// The cell `n` columns left of the cursor. + pub fn cursor_cell_left(&self, n: size::CellCountInt) -> Cell { + assert!(self.cursor.x >= n); + self.cursor_row_cells()[usize::from(self.cursor.x - n)] + } + + pub fn cursor_cell_left_mut(&mut self, n: size::CellCountInt) -> &mut Cell { + assert!(self.cursor.x >= n); + let p = self.cursor_pin(); + let page = self.page_mut(p.node); + let i = page + .cell_index(usize::from(p.x - n), usize::from(p.y)) + .expect("cursor cell"); + &mut page.cells[i] + } + + /// The last cell of the row above the cursor row. + pub fn cursor_cell_end_of_prev(&self) -> Cell { + assert!(self.cursor.y > 0); + + let mut page_pin = self + .cursor_pin() + .up(&self.pages, 1) + .expect("row above cursor"); + page_pin.x = self.pages.cols - 1; + *page_pin + .row_and_cell(&self.pages) + .expect("end of prev cell") + .1 + } + + /// Move the cursor right. This is a specialized function that is very fast + /// if the caller can guarantee we have space to move right (no wrapping). + pub fn cursor_right(&mut self, n: size::CellCountInt) { + assert!(self.cursor.x + n < self.pages.cols); + + let mut p = self.cursor_pin(); + p.x += n; + self.set_cursor_pin(p); + self.cursor.x += n; + self.assert_integrity(); + } + + /// Move the cursor left. + pub fn cursor_left(&mut self, n: size::CellCountInt) { + assert!(self.cursor.x >= n); + + let mut p = self.cursor_pin(); + p.x -= n; + self.set_cursor_pin(p); + self.cursor.x -= n; + self.assert_integrity(); + } + + /// Move the cursor up. + /// + /// Precondition: The cursor is not at the top of the screen. + pub fn cursor_up(&mut self, n: size::CellCountInt) { + assert!(self.cursor.y >= n); + + self.cursor.y -= n; // Must be set before cursor_change_pin + let new = self + .cursor_pin() + .up(&self.pages, usize::from(n)) + .expect("row above cursor"); + self.cursor_change_pin(new); + self.assert_integrity(); + } + + /// The row `n` rows above the cursor row. + pub fn cursor_row_up(&self, n: size::CellCountInt) -> Row { + assert!(self.cursor.y >= n); + + let page_pin = self + .cursor_pin() + .up(&self.pages, usize::from(n)) + .expect("row above cursor"); + *page_pin.row_and_cell(&self.pages).expect("row").0 + } + + /// Move the cursor down. + /// + /// Precondition: The cursor is not at the bottom of the screen. + pub fn cursor_down(&mut self, n: size::CellCountInt) { + assert!(u32::from(self.cursor.y) + u32::from(n) < u32::from(self.pages.rows)); + + self.cursor.y += n; // Must be set before cursor_change_pin + + // We move the offset into our page list to the next row and then + // get the pointers to the row/cell and set all the cursor state up. + let new = self + .cursor_pin() + .down(&self.pages, usize::from(n)) + .expect("row below cursor"); + self.cursor_change_pin(new); + self.assert_integrity(); + } + + /// Move the cursor to some absolute horizontal position. + pub fn cursor_horizontal_absolute(&mut self, x: size::CellCountInt) { + assert!(x < self.pages.cols); + + let mut p = self.cursor_pin(); + p.x = x; + self.set_cursor_pin(p); + self.cursor.x = x; + self.assert_integrity(); + } + + /// Move the cursor to some absolute position. + pub fn cursor_absolute(&mut self, x: size::CellCountInt, y: size::CellCountInt) { + assert!(x < self.pages.cols); + assert!(y < self.pages.rows); + + let cur = self.cursor_pin(); + let mut page_pin = match y.cmp(&self.cursor.y) { + std::cmp::Ordering::Less => cur + .up(&self.pages, usize::from(self.cursor.y - y)) + .expect("row above cursor"), + std::cmp::Ordering::Greater => cur + .down(&self.pages, usize::from(y - self.cursor.y)) + .expect("row below cursor"), + std::cmp::Ordering::Equal => cur, + }; + page_pin.x = x; + self.cursor.x = x; // Must be set before cursor_change_pin + self.cursor.y = y; + self.cursor_change_pin(page_pin); + self.assert_integrity(); + } + + /// Reloads the cursor information into the screen. This is expensive + /// so it should only be done in cases where the pointers are invalidated + /// in such a way that its difficult to recover otherwise. + pub fn cursor_reload(&mut self) { + // Our tracked pin is ALWAYS accurate, so we derive the active + // point from the pin. If this returns None it means our pin + // points outside the active area. In that case, we update the + // pin to be the top-left. + let pt = match self.pages.point_from_pin(Tag::Active, self.cursor_pin()) { + Some(pt) => pt, + None => { + let pin = self + .pages + .pin(Point::Active(Coordinate::default())) + .expect("active area exists"); + self.set_cursor_pin(pin); + self.pages + .point_from_pin(Tag::Active, pin) + .expect("active area") + } + }; + + self.cursor.x = pt.coord().x; + self.cursor.y = pt.coord().y as size::CellCountInt; + + // If we have a style, we need to ensure it is in the page because this + // method may also be called after a page change. + if self.cursor.style_id != style::DEFAULT_ID + && let Err(err) = self.manual_style_update() + { + // This failure should not happen because manual_style_update + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log::error!("failed to update style on cursor reload err={err:?}"); + self.cursor.style = Style::default(); + self.cursor.style_id = 0; + } + self.assert_integrity(); + } + + /// Scroll the active area and keep the cursor at the bottom of the screen. + /// This is a very specialized function but it keeps it fast. + pub fn cursor_down_scroll(&mut self) -> Result<(), Error> { + assert!(self.cursor.y == self.pages.rows - 1); + + // Scrolling dirties the images because it updates their placements pins. + self.kitty_images.dirty = true; + + // If we have no scrollback, then we shift all our rows instead. + if self.no_scrollback { + // If we have a single-row screen, we have no rows to shift + // so our cursor is in the correct place we just have to clear + // the cells. + if self.pages.rows == 1 { + let p = self.cursor_pin(); + let cols = usize::from(self.pages.cols); + self.clear_cells(p.node, p.y, 0, cols); + self.cursor_mark_dirty(); + } else { + // The call to `erase_row` will move the tracked cursor pin up + // by one row, but we don't actually want that, so we keep the + // old pin and put it back after calling `erase_row`. + let old_pin = self.cursor_pin(); + + // erase_row will shift everything below it up. + self.pages.erase_row(Point::Active(Coordinate::default()))?; + + // Note we don't need to mark anything dirty in this branch + // because erase_row will mark all the rotated rows as dirty + // in the entire page. + + // We don't use `cursor_change_pin` here because we aren't + // actually changing the pin, we're keeping it the same. + self.set_cursor_pin(old_pin); + } + } else { + let old_pin = self.cursor_pin(); + + // Grow our pages by one row. The PageList will handle if we need to + // allocate, prune scrollback, whatever. + self.pages.grow()?; + + let new_pin = { + // If our pin page change it means that the page that the pin + // was on was pruned. In this case, grow() moves the pin to + // the top-left of the new page. This effectively moves it by + // one already, we just need to fix up the x value. + let cur = self.cursor_pin(); + let page_pin = if old_pin.node == cur.node { + cur.down(&self.pages, 1).expect("row below cursor") + } else { + let mut pin = cur; + pin.x = self.cursor.x; + pin + }; + + // These assertions help catch some pagelist math errors. Our + // x/y should be unchanged after the grow. + if cfg!(debug_assertions) { + let active = self + .pages + .point_from_pin(Tag::Active, page_pin) + .expect("active") + .coord(); + assert_eq!(active.x, self.cursor.x); + assert_eq!(active.y, u32::from(self.cursor.y)); + } + + page_pin + }; + self.cursor_change_pin(new_pin); + + // Our new row is always dirty + self.cursor_mark_dirty(); + + // Clear the new row so it gets our bg color. We only do this + // if we have a bg color at all. + if self.cursor.style.bg_color != style::Color::None { + let p = self.cursor_pin(); + let cols = usize::from(self.pages.cols); + self.clear_cells(p.node, p.y, 0, cols); + } + } + + if self.cursor.style_id != style::DEFAULT_ID { + // The newly created line needs to be styled according to + // the bg color if it is set. + self.fill_cursor_row_bg(); + } + self.assert_integrity(); + Ok(()) + } + + /// If the cursor style has a background, fill the whole cursor row with + /// the blank background cell. + fn fill_cursor_row_bg(&mut self) { + if let Some(blank_cell) = self.cursor.style.bg_cell() { + self.cursor_page_row_cells_mut().fill(blank_cell); + } + } + + fn cursor_page_row_cells_mut(&mut self) -> &mut [Cell] { + let p = self.cursor_pin(); + self.page_mut(p.node) + .get_cells_mut(usize::from(p.y)) + .expect("cursor row") + } + + /// This scrolls the active area at and above the cursor. + /// The lines below the cursor are not scrolled. + pub fn cursor_scroll_above(&mut self) -> Result<(), Error> { + // We unconditionally mark the cursor row as dirty here because + // the cursor always changes page rows inside this function, and + // when that happens it can mean the text in the old row needs to + // be re-shaped because the cursor splits runs to break ligatures. + self.cursor_mark_dirty(); + + // If the cursor is on the bottom of the screen, its faster to use + // our specialized function for that case. + if self.cursor.y == self.pages.rows - 1 { + return self.cursor_down_scroll(); + } + + // Logic below assumes we always have at least one row that isn't moving + assert!(self.cursor.y < self.pages.rows - 1); + + // Explanation: + // We don't actually move everything that's at or above the cursor row, + // since this would require us to shift up our ENTIRE scrollback, which + // would be ridiculously expensive. Instead, we insert a new row at the + // end of the pagelist (`grow()`), and move everything BELOW the cursor + // DOWN by one row. This has the same practical result but it's a whole + // lot cheaper in 99% of cases. + + let old_pin = self.cursor_pin(); + if self.pages.grow()?.is_some() { + self.cursor_scroll_above_rotate()?; + } else { + // In this case, it means grow() didn't allocate a new page. + + if Some(self.cursor_pin().node) == self.pages.last { + // If we're on the last page we can do a very fast path because + // all the rows we need to move around are within a single page. + + // Note: we don't need to call cursor_change_pin here because + // the pin page is the same so there is no accounting to do + // for styles or any of that. + assert!(old_pin.node == self.cursor_pin().node); + let down = self + .cursor_pin() + .down(&self.pages, 1) + .expect("row below cursor"); + self.set_cursor_pin(down); + + let page = self.page_mut(down.node); + + // Rotate the rows so that the newly created empty row is at the + // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. + let n = usize::from(page.size.rows); + page.rows[usize::from(down.y)..n].rotate_right(1); + + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + page.dirty = true; + } else { + // We didn't grow pages but our cursor isn't on the last page. + // In this case we need to do more work because we need to copy + // elements between pages. + self.cursor_scroll_above_rotate()?; + } + } + + if self.cursor.style_id != style::DEFAULT_ID { + // The newly created line needs to be styled according to + // the bg color if it is set. + self.fill_cursor_row_bg(); + } + self.assert_integrity(); + Ok(()) + } + + fn cursor_scroll_above_rotate(&mut self) -> Result<(), Error> { + let down = self + .cursor_pin() + .down(&self.pages, 1) + .expect("row below cursor"); + self.cursor_change_pin(down); + let cursor_pin = self.cursor_pin(); + + // Go through each of the pages following our pin, shift all rows + // down by one, and copy the last row of the previous page. + let mut current = self.pages.last.expect("last page"); + while current != cursor_pin.node { + let prev = self + .pages + .node(current) + .and_then(|n| n.prev) + .expect("prev page"); + let prev_rows = self.page(prev).size.rows; + + // Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ] + let cur_page = self.page_mut(current); + let n = usize::from(cur_page.size.rows); + cur_page.rows[..n].rotate_right(1); + + // Copy the last row of the previous page to the top of current. + self.clone_row_between(prev, prev_rows - 1, current, 0)?; + + // Mark dirty on the page, since we are dirtying all rows with this. + self.page_mut(current).dirty = true; + current = prev; + } + + // Our current is our cursor page, we need to rotate down from + // our cursor and clear our row. + assert!(current == cursor_pin.node); + let cols = usize::from(self.pages.cols); + let cur_page = self.page_mut(current); + let n = usize::from(cur_page.size.rows); + cur_page.rows[usize::from(cursor_pin.y)..n].rotate_right(1); + self.clear_cells(current, cursor_pin.y, 0, cols); + + // Mark the whole page as dirty. + // + // Technically we only need to mark from the cursor row to the + // end but this is a hot function, so we want to minimize work. + self.page_mut(current).dirty = true; + Ok(()) + } + + /// Clone a row from one page to another (Zig: `Page.cloneRowFrom`). + fn clone_row_between( + &mut self, + src: NodeId, + src_y: u16, + dst: NodeId, + dst_y: u16, + ) -> Result<(), Error> { + let (row, cells) = { + let sp = self.page(src); + let row = *sp + .get_row(usize::from(src_y)) + .ok_or(Error::InvalidCoordinate)?; + let cells: Vec<_> = (0..usize::from(sp.size.cols)) + .map(|x| sp.export_cell(row.cells() + x)) + .collect(); + (row, cells) + }; + let dp = self.page_mut(dst); + let cols = usize::from(dp.size.cols); + dp.clear_cells(usize::from(dst_y), 0, cols, false)?; + for (x, c) in cells.iter().enumerate().take(cols) { + dp.import_cell(x, usize::from(dst_y), c)?; + } + let start = dp.rows[usize::from(dst_y)].cells(); + dp.rows[usize::from(dst_y)] = row; + dp.rows[usize::from(dst_y)].set_cells(start); + Ok(()) + } + + /// Move the cursor down if we're not at the bottom of the screen. Otherwise + /// scroll. Currently only used for testing. + pub fn cursor_down_or_scroll(&mut self) -> Result<(), Error> { + if self.cursor.y + 1 < self.pages.rows { + self.cursor_down(1); + Ok(()) + } else { + self.cursor_down_scroll() + } + } + + /// Copy another cursor. The cursor can be on any screen but the x/y + /// must be within our screen bounds. + /// + /// `hyperlink`: Copy the hyperlink from the other cursor. If not set, + /// this will clear our current hyperlink. + pub fn cursor_copy(&mut self, other: &Cursor, hyperlink: bool) -> Result<(), Error> { + assert!(other.x < self.pages.cols); + assert!(other.y < self.pages.rows); + + // End any currently active hyperlink on our cursor. + self.end_hyperlink(); + + let old = self.cursor.clone(); + let mut new = other.clone(); + + // Keep our old style ID so it can be properly cleaned up below. + new.style_id = old.style_id; + + // Hyperlinks will be managed separately below. + new.hyperlink_id = 0; + new.hyperlink = None; + + // Keep our old page pin and X/Y because: + // 1. The old style will need to be cleaned up from the page it's from. + // 2. The new position navigated to by `cursor_absolute` needs to be in our + // own screen. + new.page_pin = old.page_pin; + new.x = old.x; + new.y = old.y; + self.cursor = new; + + // Call manual style update in order to clean up our old style, if we have + // one, and also to load the style from the other cursor, if it had one. + if let Err(e) = self.manual_style_update() { + self.cursor = old; + return Err(e); + } + + // Move to the correct location to match the other cursor. + self.cursor_absolute(other.x, other.y); + + // If the other cursor had a hyperlink, add it to ours. + if hyperlink + && other.hyperlink_id != 0 + && let Some(other_link) = &other.hyperlink + { + // And it to our cursor. + let id = match &other_link.id { + HyperlinkId::Explicit(id) => Some(id.clone()), + HyperlinkId::Implicit(_) => None, + }; + if let Err(err) = self.start_hyperlink(&other_link.uri, id.as_deref()) { + // This shouldn't happen because start_hyperlink should handle + // resizing. This only happens if we're truly out of RAM. Degrade + // to forgetting the hyperlink. + log::error!("failed to update hyperlink on cursor change err={err:?}"); + } + } + Ok(()) + } + + /// Always use this to write to the cursor pin. + /// + /// This specifically handles the case when the new pin is on a different + /// page than the old AND we have a style or hyperlink set. In that case, + /// we must release our old one and insert the new one, since styles are + /// stored per-page. + /// + /// Note that this can change the cursor pin AGAIN if the process of + /// setting up our cursor forces a capacity adjustment of the underlying + /// cursor page, so any references to the page pin should be re-read + /// from `cursor_pin()` after calling this. + fn cursor_change_pin(&mut self, new: Pin) { + let cur = self.cursor_pin(); + + // Moving the cursor affects text run splitting (ligatures) so + // we must mark the old and new page dirty. We do this as long + // as the pins are not equal + if !cur.eql(new) { + self.cursor_mark_dirty(); + new.mark_dirty(&mut self.pages); + } + + // If our pin is on the same page, then we can just update the pin. + // We don't need to migrate any state. + if cur.node == new.node { + self.set_cursor_pin(new); + return; + } + + // If we have an old style then we need to release it from the old page. + let old_style: Option