pmacs/src/overlay_paint.rs

439 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! T M10.9 — paint other-frontend cursor and selection overlays
//! into a recipient's grid.
//!
//! # Contract
//!
//! Called by [`crate::instance_render::RenderState::render_frame`]
//! AFTER the main paint pass writes the buffer content into the
//! recipient's `next` grid, and BEFORE the grid is diffed against
//! the previous frame. The overlay pass MODIFIES cells in place
//! — it doesn't insert / shift / re-layout. The grid size is
//! unchanged. The diff captures overlay changes as ordinary cell
//! diffs (style changes are visible to `Cell::PartialEq`).
//!
//! # Coordinate resolution
//!
//! **Source's byte position resolves to recipient's grid coordinates
//! via recipient's window layout.** Different recipients with
//! different viewports paint the source cursor at different grid
//! coordinates — that's correct: each recipient sees the overlay
//! where their own view places it.
//!
//! This forecloses the bug shape of "painted at source's coords"
//! (which would assume both ends share a viewport — they don't).
//!
//! # Cursor + label
//!
//! - Cursor cell: foreground color set to the source's assigned
//! palette color; `reverse: true` makes it stand out against
//! underlying text. The original glyph is preserved.
//! - Label: a single character (`'A'`..`'Z'` for `FrontendId`s 227;
//! `None` beyond) painted ONE row above the cursor cell. If
//! `row == 0`, painted ONE row below instead. Label uses the
//! source's color, no reverse.
//!
//! # Selection
//!
//! For each cell within the source's selection range that's
//! visible in the recipient's window: `underline = Single` plus
//! the source's color. Distinct from local selection's `reverse`
//! styling — a recipient can visually distinguish their own
//! selection from a remote one.
//!
//! # Filtering
//!
//! Overlays paint only when:
//! - Source != recipient (sender exclusion is the caller's
//! responsibility; we don't recheck here)
//! - The source's buffer matches at least one of the recipient's
//! windows' buffers
//! - The source's cursor maps to a coord visible in that window's
//! viewport (within `view_top` + `inner_rows`, within rect cols)
//!
//! Otherwise the overlay is silently skipped — no off-screen
//! indicator; M10.x may add one.
use crate::cell::{CellCoord, CellGrid, CellSize, Color, UnderlineStyle};
use crate::editor::EditorState;
use crate::overlay_color::{color_for_slot, label_for_frontend_id};
use crate::presence::PresenceSnapshot;
use crate::protocol::FrontendId;
use crate::view::View;
use crate::window::Rect;
/// One other-frontend presence, with the daemon's resolved color
/// slot. The dispatcher builds these from
/// [`crate::presence::SessionRegistry::other_presences_for`] +
/// the per-session color slot.
#[derive(Copy, Clone, Debug)]
pub struct OtherPresence {
/// The source frontend.
pub frontend_id: FrontendId,
/// The source's last-broadcast presence snapshot.
pub snapshot: PresenceSnapshot,
/// Palette slot index (0..[`crate::overlay_color::PALETTE_LEN`])
/// for the source's color. Resolved to `Color` via
/// [`color_for_slot`].
pub color_slot: u8,
}
/// Paint other-frontend cursor + selection overlays into the
/// recipient's grid.
///
/// `state.core.active_frontend` is the recipient. `grid` is the
/// recipient's `next` grid post-`paint_frame`. `term_size` is the
/// recipient's terminal dimensions.
///
/// See module docs for the painting semantics. This function is
/// idempotent within a single tick — calling it twice produces
/// the same final grid; the per-tick coalescing happens via
/// `SessionRegistry::sweep`.
pub fn paint_other_frontend_overlays(
state: &EditorState,
grid: &mut CellGrid,
term_size: CellSize,
other_presences: &[OtherPresence],
) {
if other_presences.is_empty() {
return;
}
if term_size.rows < 2 || term_size.cols == 0 {
return;
}
let core = state.core.borrow();
// Reserve the last row for status / minibuffer (same convention
// as `paint_frame`); overlays only paint into the text area.
let text_rows = term_size.rows.saturating_sub(1);
if text_rows == 0 {
return;
}
let text_area = Rect::new(0, 0, text_rows, term_size.cols);
let placements = core.active_layout().compute(text_area);
let registry = core.registry.clone();
let reg = registry.borrow();
for presence in other_presences {
let color = color_for_slot(presence.color_slot);
let label = label_for_frontend_id(presence.frontend_id);
// For each of the recipient's windows whose buffer matches
// the source's snapshot.buffer_id, paint the cursor in
// that window's viewport.
for (win_id, window) in &core.windows {
if window.buffer_id != presence.snapshot.buffer_id {
continue;
}
let Some(rect) = placements.get(win_id).copied() else {
continue;
};
let inner_rows = inner_rows_of(&rect);
if inner_rows == 0 || rect.size.cols == 0 {
continue;
}
let Ok(buf) = reg.get(window.buffer_id) else {
continue;
};
// Source's byte position → display coords via THIS
// recipient window's text_view (the recipient's view
// of the buffer).
let Some(disp) = window
.text_view
.pos_to_display(buf, presence.snapshot.cursor)
else {
continue;
};
// Filter to viewport visible range. `view_top` is the
// top visible buffer-line; cells below it are in-frame
// until `view_top + inner_rows`.
let row_in_window = match (disp.row as usize).checked_sub(window.view_top) {
Some(r) if r < inner_rows as usize => r,
_ => continue,
};
// Column bounds: disp.col is the buffer column; window
// doesn't horizontally scroll in v1.0, so cells past
// rect.size.cols are simply off-grid for this window.
if disp.col >= rect.size.cols {
continue;
}
let cursor_grid_row = rect.origin.row + row_in_window as u32;
let cursor_grid_col = rect.origin.col + disp.col;
paint_cursor_cell(grid, cursor_grid_row, cursor_grid_col, color);
if let Some(label_ch) = label {
paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color);
}
// Selection overlay. Iterate cells in the selection
// range visible in this window.
if let Some(sel) = presence.snapshot.selection {
let (lo, hi) = if sel.anchor <= sel.active {
(sel.anchor, sel.active)
} else {
(sel.active, sel.anchor)
};
paint_selection_in_window(grid, buf, window, rect, inner_rows, lo, hi, color);
}
}
}
}
/// Compute the inner rows of a window's rect — the text area,
/// excluding the bottom mode-line row.
fn inner_rows_of(rect: &Rect) -> u32 {
rect.size.rows.saturating_sub(1)
}
/// Paint the cursor cell for the source: set fg to the source's
/// color and toggle reverse. Preserves the underlying glyph.
fn paint_cursor_cell(grid: &mut CellGrid, row: u32, col: u32, color: Color) {
if row >= grid.size.rows || col >= grid.size.cols {
return;
}
let cell = grid.at(CellCoord::new(row, col));
cell.style.fg = color;
cell.style.reverse = !cell.style.reverse;
}
/// Paint the label character one row above the cursor (or below
/// if `row == 0`). The label cell uses the source's color
/// without reverse, so it's distinct from the cursor cell.
fn paint_label_cell(grid: &mut CellGrid, cursor_row: u32, col: u32, label: char, color: Color) {
let label_row = if cursor_row == 0 {
// Top edge: paint below.
cursor_row + 1
} else {
cursor_row - 1
};
if label_row >= grid.size.rows || col >= grid.size.cols {
return;
}
let cell = grid.at(CellCoord::new(label_row, col));
cell.glyph = crate::cell::Glyph::Char(label);
cell.style.fg = color;
cell.style.bold = true;
}
/// Paint the source's selection cells visible in this window.
/// Each cell within `[lo, hi)` that maps to a visible coord gets
/// `underline = Single` + the source's color.
#[allow(clippy::too_many_arguments)]
fn paint_selection_in_window(
grid: &mut CellGrid,
buf: &crate::buffer::Buffer,
window: &crate::window::Window,
rect: Rect,
inner_rows: u32,
lo: crate::rope::Position,
hi: crate::rope::Position,
color: Color,
) {
if lo >= hi {
return;
}
// Walk byte positions from lo to hi, mapping each to a
// display coord. Step in single-byte increments; pos_to_display
// tolerates byte-boundary positions and returns None for
// positions outside the buffer.
//
// This is O(N) in the selection's byte length. For typical
// selections (5100 cells), microseconds. For large selections
// (multi-megabyte), this would be too expensive — but the
// viewport bound makes this naturally cheap: cells outside
// the viewport are skipped via the row-bounds check, and the
// selection only PAINTS for cells in the viewport. We could
// restrict iteration to viewport-byte-range; for v1.0 the
// straightforward walk is fine.
let mut pos = lo;
while pos < hi {
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
break;
};
match (disp.row as usize).checked_sub(window.view_top) {
Some(r) if r < inner_rows as usize && disp.col < rect.size.cols => {
let grid_row = rect.origin.row + r as u32;
let grid_col = rect.origin.col + disp.col;
if grid_row < grid.size.rows && grid_col < grid.size.cols {
let cell = grid.at(CellCoord::new(grid_row, grid_col));
cell.style.underline = UnderlineStyle::Single;
// Use the source's color for the underline; if
// the cell already has a foreground style, the
// underline color comes from the fg. We don't
// override fg to preserve the cell's existing
// glyph appearance.
if cell.style.fg == Color::Default {
cell.style.fg = color;
}
}
}
_ => {
// Below viewport — no further cells in this window
// are visible if we're past `view_top + inner_rows`.
// But the selection might have skipped some bytes
// (multibyte char boundaries); we keep walking.
}
}
pos += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::BufferId;
use crate::cell::{Cell, CellSize, Glyph};
use crate::editor::EditorState;
use crate::protocol::SelectionSnapshot;
fn empty_grid(size: CellSize) -> Vec<Cell> {
vec![Cell::default(); (size.rows * size.cols) as usize]
}
fn make_grid(cells: &mut [Cell], size: CellSize) -> CellGrid<'_> {
CellGrid {
cells,
stride: size.cols,
size,
}
}
fn dummy_presence(
fid: FrontendId,
buffer_id: BufferId,
cursor: u64,
color_slot: u8,
) -> OtherPresence {
OtherPresence {
frontend_id: fid,
snapshot: PresenceSnapshot {
buffer_id,
cursor,
selection: None,
},
color_slot,
}
}
#[test]
fn empty_presences_no_paint() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
paint_other_frontend_overlays(&state, &mut grid, size, &[]);
assert_eq!(cells, original, "empty presences leaves grid unchanged");
}
#[test]
fn presence_in_different_buffer_no_paint() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
// BufferId::next() produces a unique id that doesn't match
// the in-process scratch buffer.
let presence = dummy_presence(FrontendId(2), BufferId::next(), 0, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
assert_eq!(
cells, original,
"presence in different buffer leaves grid unchanged"
);
}
#[test]
fn cursor_at_origin_paints_cell_and_label_below() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
// Get the scratch buffer's id so the presence matches.
let active_buf = state.core.borrow().active_buffer_id();
let presence = dummy_presence(FrontendId(2), active_buf, 0, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cursor at row 0, col 0: cell modified (reverse toggled,
// fg = palette[0]).
let cursor_cell = &cells[0];
assert_eq!(cursor_cell.style.fg, color_for_slot(0));
assert!(cursor_cell.style.reverse);
// Label painted BELOW the cursor since row 0 is the top.
let label_cell = &cells[(size.cols) as usize];
assert!(matches!(label_cell.glyph, Glyph::Char('A')));
assert_eq!(label_cell.style.fg, color_for_slot(0));
}
#[test]
fn frontend_beyond_26_paints_cursor_no_label() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
let presence = dummy_presence(FrontendId(28), active_buf, 0, 3);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cursor cell painted with slot 3's color.
let cursor_cell = &cells[0];
assert_eq!(cursor_cell.style.fg, color_for_slot(3));
assert!(cursor_cell.style.reverse);
// Label cell NOT painted (FrontendId 28 has no label).
let label_cell = &cells[(size.cols) as usize];
assert_eq!(*label_cell, Cell::default(), "no label for FrontendId(28)");
}
#[test]
fn cursor_off_grid_no_paint() {
// The scratch buffer is empty; pos_to_display for cursor
// beyond the buffer returns None → no paint.
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
// Cursor at byte 999 — far past the empty scratch buffer.
let presence = dummy_presence(FrontendId(2), active_buf, 999, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
assert_eq!(
cells, original,
"cursor at out-of-buffer position leaves grid unchanged"
);
}
#[test]
fn selection_paints_underline_on_visible_cells() {
// Construct a state with some buffer content so selection
// has cells to paint.
let state = EditorState::new();
// Insert a few chars so cursor positions 0..5 are valid.
state.core.borrow_mut().insert_char('h');
state.core.borrow_mut().insert_char('i');
state.core.borrow_mut().insert_char('!');
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
let mut presence = dummy_presence(FrontendId(2), active_buf, 0, 0);
presence.snapshot.selection = Some(SelectionSnapshot {
anchor: 0,
active: 3,
});
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cells 0, 1, 2 should have UnderlineStyle::Single.
for (col, cell) in cells.iter().enumerate().take(3) {
assert_eq!(
cell.style.underline,
UnderlineStyle::Single,
"cell {col} should be underlined"
);
}
// Cell 3 should NOT be underlined (selection is [0, 3) exclusive).
assert_eq!(cells[3].style.underline, UnderlineStyle::None);
}
}