use std::cell::{Cell, OnceCell, RefCell};
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use cellix_core::{Buffer, Motion, Selection, Selections};
use cellix_doc::changes::{self, Change, Kind, Syntax};
use cellix_doc::latex::Project;
use cellix_doc::notebook::{Marker, Notebook, marker};
use cellix_doc::prose::{Issue, Source};
use cellix_doc::table::Edit as TableEdit;
use cellix_input::{Command, Direction, Format};
use cellix_lang::{Registry, Span, Worker};
use cellix_render::{
Bounds, Font, Grid, INTERFACE_SIZE, Icon, Measure, Paint, Rect, Rgb, Strip, units,
};
use cellix_review::LineChange;
use unicode_width::UnicodeWidthChar;
use crate::panels::Symbol;
use crate::rendered::{
self, Callout, Dialect, Extras, Figure, Glyph, Intent, Library, Row, Target, Tone,
};
use crate::snippets::Snippet;
use crate::theme::Theme;
const TAB_WIDTH: usize = 4;
const GUTTER_MIN_DIGITS: usize = 4;
const NARROW_PANE: u16 = 60;
const MAX_HEADING_CYCLE: usize = 3;
const INDENT: &str = " ";
const MINIMAP_WIDTH: u16 = 13;
const MINIMAP_LINES_PER_ROW: usize = 6;
const MINIMAP_COLUMNS: usize = 96;
const MIN_MINIMAP_PANE: u16 = 90;
const MAX_INLINE_HIGHLIGHT: usize = 32 * 1024;
const TALL_DROP: f32 = 0.9;
const PAGE_MARGIN: u16 = 2;
const WIDE_PAGE_MARGIN: u16 = 4;
const MAX_PAGE_WIDTH: u16 = 512;
#[derive(Default)]
pub struct Marks {
pub comments: Vec<usize>,
pub errors: Vec<usize>,
}
type LaidOut = (
Rc<Vec<Row>>,
Vec<Figure>,
Vec<(usize, usize)>,
Option<usize>,
);
struct PageCache {
revision: u64,
width: usize,
open: Option<(usize, usize)>,
rows: Rc<Vec<Row>>,
figures: Rc<Vec<Figure>>,
spans: Rc<Vec<(usize, usize)>>,
preview: Option<usize>,
}
fn dimmed(color: Rgb, bg: Rgb) -> Rgb {
let mix = |a: u8, b: u8| ((a as u16 * 3 + b as u16 * 2) / 5) as u8;
Rgb(mix(color.0, bg.0), mix(color.1, bg.1), mix(color.2, bg.2))
}
type MinimapRows = ((u64, u16, u16), Vec<Vec<Strip>>);
pub struct Editor {
pub buffer: Buffer,
pub selections: Selections,
pub found: Vec<(usize, usize)>,
pub committed: Option<String>,
changes: RefCell<(u64, HashMap<usize, LineChange>)>,
minimap: RefCell<Option<MinimapRows>>,
pub top: usize,
pub left: usize,
spans: RefCell<Option<(u64, Vec<Span>)>>,
view_rows: Cell<u16>,
worker: OnceCell<Worker>,
asked: Cell<u64>,
spans_bytes: Cell<usize>,
pub rendered: bool,
pub conflict: bool,
pub minimap_on: bool,
page: RefCell<Option<PageCache>>,
library: RefCell<Library>,
dark: Cell<bool>,
view_width: Cell<u16>,
pub tracking: bool,
pub agent_review: Option<(String, String)>,
words: Cell<Option<Counted>>,
paper_files: RefCell<cellix_doc::paper::Files>,
paper_root: RefCell<Option<PathBuf>>,
pub notebook: Option<Notebook>,
pub issues: (u64, Vec<Issue>),
pub checked: u64,
pub built: Option<PathBuf>,
wanted: RefCell<Vec<Snippet>>,
pub quoted: RefCell<Vec<(usize, usize)>>,
project: RefCell<Option<(u64, Rc<Project>)>>,
show_preamble: bool,
measure: Rc<dyn Measure>,
registry: Rc<Registry>,
pub synced: u64,
}
type Counted = ((u64, u64), usize, usize);
fn display_width(c: char, column: usize) -> usize {
if c == '\t' {
TAB_WIDTH - column % TAB_WIDTH
} else {
c.width().unwrap_or(0)
}
}
fn callout_colour(kind: Callout, theme: &Theme) -> Rgb {
match kind {
Callout::Note => theme.accent,
Callout::Tip => theme.inserted(),
Callout::Important => Rgb(0x88, 0x57, 0xe0),
Callout::Warning => Rgb(0xc0, 0x86, 0x10),
Callout::Caution => theme.deleted(),
}
}
const FIGURE_LINES: usize = 6;
impl Editor {
pub fn new(buffer: Buffer, measure: Rc<dyn Measure>, registry: Rc<Registry>) -> Self {
let definitions = |text: &str| {
let lines = text.lines().filter(|l| !l.trim().is_empty());
let marks = [
"\\newcommand",
"\\renewcommand",
"\\def",
"\\usepackage",
"\\Declare",
];
let (total, defining) = lines.fold((0, 0), |(total, defining), line| {
let is = marks.iter().any(|m| line.trim_start().starts_with(m));
(total + 1, defining + usize::from(is))
});
total > 0 && defining * 2 > total
};
let is_page =
buffer.path().is_some_and(|p| Dialect::of(p).is_some()) && !definitions(&buffer.text());
Self {
buffer,
selections: Selections::default(),
found: Vec::new(),
committed: None,
changes: RefCell::new((u64::MAX, HashMap::new())),
minimap: RefCell::new(None),
top: 0,
left: 0,
spans: RefCell::new(None),
view_rows: Cell::new(0),
worker: OnceCell::new(),
asked: Cell::new(u64::MAX),
spans_bytes: Cell::new(0),
rendered: is_page,
conflict: false,
minimap_on: true,
page: RefCell::new(None),
library: RefCell::default(),
dark: Cell::new(false),
view_width: Cell::new(0),
tracking: false,
agent_review: None,
words: Cell::new(None),
paper_files: RefCell::default(),
paper_root: RefCell::new(None),
notebook: None,
issues: (0, Vec::new()),
checked: 0,
built: None,
wanted: RefCell::default(),
quoted: RefCell::default(),
project: RefCell::new(None),
show_preamble: false,
measure,
registry,
synced: 0,
}
}
pub fn title(&self) -> String {
self.buffer
.path()
.and_then(Path::file_name)
.map_or_else(|| "untitled".into(), |n| n.to_string_lossy().into_owned())
}
fn shows_minimap(&self, area: Rect) -> bool {
let fits = self.rendered && {
let cache = self.page.borrow();
cache
.as_ref()
.is_some_and(|p| p.rows.len() <= area.h as usize)
};
self.minimap_on && !self.measure.in_cells() && area.w >= MIN_MINIMAP_PANE && !fits
}
fn text_area(&self, area: Rect) -> Rect {
let side = if self.shows_minimap(area) {
MINIMAP_WIDTH
} else {
1
};
Rect::new(area.x, area.y, area.w.saturating_sub(side), area.h)
}
pub fn refresh_changes(&self) {
self.changes.borrow_mut().0 = u64::MAX;
}
fn line_changes(&self) -> std::cell::Ref<'_, (u64, HashMap<usize, LineChange>)> {
let revision = self.buffer.revision();
if self.changes.borrow().0 != revision {
let found = self.committed.as_ref().map_or_else(HashMap::new, |old| {
cellix_review::line_changes(old, &self.buffer.text())
.into_iter()
.collect()
});
*self.changes.borrow_mut() = (revision, found);
}
self.changes.borrow()
}
fn draw_change(&self, grid: &mut Grid, x: f32, y: u16, change: LineChange, theme: &Theme) {
let color = match change {
LineChange::Added => theme.git_added,
LineChange::Modified => theme.git_modified,
LineChange::DeletedAbove => theme.error,
};
let bounds = match change {
LineChange::DeletedAbove => Bounds::cells(x, y as f32 - 0.06, 0.55, 0.12),
_ => Bounds::cells(x, y as f32, 0.28, 1.0),
};
grid.panel(bounds, 0, Some(color), None);
if self.measure.in_cells() {
let style = cellix_render::Style {
fg: color,
..theme.gutter
};
grid.put_str(x as u16, y, "▎", style, x as u16 + 1);
}
}
fn code_words(&self, line: usize, spans: &[Span], theme: &Theme) -> Vec<(usize, usize, Rgb)> {
let start = self.buffer.rope().line_to_byte(line);
let text = self.line_text(line);
let mut words = Vec::new();
let (mut column, mut byte) = (0, 0);
for word in text.split_inclusive(char::is_whitespace) {
let size = word.trim_end().chars().count();
if size > 0 {
let at = start + byte;
let found = spans.partition_point(|s| s.end <= at);
let span = spans.get(found).filter(|s| s.start <= at);
let color = span.map_or(theme.text.fg, |s| theme.token(s.token));
words.push((column, size, color));
}
column += word.chars().count();
byte += word.len();
}
words
}
fn page_words(row: &Row, theme: &Theme) -> Vec<(usize, usize, Rgb)> {
let color = |tone| match tone {
Tone::Heading => theme.heading,
Tone::Code | Tone::Block(_) => theme.string,
Tone::Link | Tone::Check(_) => theme.accent,
_ => theme.text.fg,
};
let mut words = Vec::new();
let mut run: Option<(usize, usize, Rgb)> = None;
for g in &row.glyphs {
if g.ch.is_whitespace() {
words.extend(run.take());
} else {
let column = g.x as usize;
let word = run.get_or_insert((column, 0, color(g.tone)));
word.1 = (column + 1).saturating_sub(word.0);
}
}
words.extend(run);
words
}
fn minimap_rows(&self, rows: u16, text: Rect, theme: &Theme) -> Vec<Vec<Strip>> {
let page = self.rendered.then(|| self.page(text));
let lines = page
.as_ref()
.map_or_else(|| self.buffer.rope().len_lines(), |p| p.len());
let capacity = rows as usize * MINIMAP_LINES_PER_ROW;
let shown = lines.min(capacity);
let mut out = vec![Vec::new(); rows as usize];
let spans = self.spans.borrow();
let no_spans = Vec::new();
let spans = spans.as_ref().map_or(&no_spans, |(_, spans)| spans);
for slot in 0..shown {
let line = slot * lines / shown.max(1);
let words = match &page {
Some(page) => Self::page_words(&page[line], theme),
None => self.code_words(line, spans, theme),
};
let row = slot / MINIMAP_LINES_PER_ROW;
let y = ((slot % MINIMAP_LINES_PER_ROW) * 256 / MINIMAP_LINES_PER_ROW) as u8;
for (column, size, color) in words {
if column < MINIMAP_COLUMNS {
let x = (column * 256 / MINIMAP_COLUMNS) as u8;
let w = (size.min(MINIMAP_COLUMNS - column) * 256 / MINIMAP_COLUMNS) as u8;
let color = dimmed(color, theme.text.bg);
out[row].push(Strip { x, y, w, color });
}
}
}
out
}
fn draw_minimap_pictures(
&self,
grid: &mut Grid,
track: Rect,
(margin, width): (f32, f32),
theme: &Theme,
) {
if !self.rendered {
return;
}
let area = self.text_area(Rect::new(
track.x.saturating_sub(self.view_width.get()),
track.y,
self.view_width.get() + track.w,
track.h,
));
let page = self.page(area);
let figures = self.figures();
let per_row = MINIMAP_LINES_PER_ROW as f32;
let capacity = track.h as f32 * per_row;
let squeeze = (capacity / page.len().max(1) as f32).min(1.0);
let column = width / MINIMAP_COLUMNS as f32;
let clip = Bounds::cells(
track.x as f32,
track.y as f32,
track.w as f32,
track.h as f32,
);
for (index, row) in page.iter().enumerate() {
let Some((figure, x)) = row.figure else {
continue;
};
let Some(figure) = figures.get(figure) else {
continue;
};
let top = track.y as f32 + index as f32 * squeeze / per_row;
let bounds = Bounds::cells(
track.x as f32 + margin + x * column,
top,
figure.width * column,
figure.height * squeeze / per_row,
);
grid.paint.push(Paint::Image {
bounds,
clip,
bitmap: figure.bitmap.clone(),
color: dimmed(theme.text.fg, theme.text.bg),
});
}
}
fn draw_minimap(&self, grid: &mut Grid, full: Rect, theme: &Theme) {
let text = self.text_area(full);
let track = Rect::new(text.right(), full.y, full.w - text.w, full.h);
grid.fill(track, theme.text);
let total = if self.rendered {
self.page(text).len()
} else {
self.buffer.rope().len_lines()
}
.max(1);
let revision = self.buffer.revision();
let built = (revision, track.h, text.w);
let stale = !self
.minimap
.borrow()
.as_ref()
.is_some_and(|(key, _)| *key == built);
if stale {
let rows = self.minimap_rows(track.h, text, theme);
*self.minimap.borrow_mut() = Some((built, rows));
}
let height = track.h as f32;
let filled = (total as f32 / MINIMAP_LINES_PER_ROW as f32).min(height);
let span = (text.h as f32 * filled / total as f32).clamp(1.0, height);
let range = total.saturating_sub(text.h as usize).max(1) as f32;
let travel = (filled - span).max(0.0);
let top = track.y as f32 + (self.top as f32 / range).min(1.0) * travel;
let margin = 0.6;
let width = track.w as f32 - margin * 1.5;
let view = Bounds::cells(track.x as f32 + margin - 0.3, top, width + 0.6, span);
grid.panel(view, 12, Some(theme.minimap_view), Some(theme.popup_border));
self.draw_minimap_pictures(grid, track, (margin, width), theme);
if let Some((_, rows)) = self.minimap.borrow().as_ref() {
for (row, strips) in rows.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
let bounds = Bounds::cells(
track.x as f32 + margin,
track.y as f32 + row as f32,
width,
1.0,
);
grid.paint.push(cellix_render::Paint::Strips {
bounds,
strips: strips.clone(),
});
}
}
let lines = self.buffer.rope().len_lines().max(1);
let fill_lines = lines as f32;
for (line, change) in &self.line_changes().1 {
let y = track.y as f32 + *line as f32 * filled / fill_lines;
let color = match change {
LineChange::Added => theme.git_added,
LineChange::Modified => theme.git_modified,
LineChange::DeletedAbove => theme.error,
};
let height = (filled / fill_lines).max(0.1);
let mark = Bounds::cells(track.right() as f32 - 0.45, y, 0.3, height);
grid.panel(mark, 0, Some(color), None);
}
}
fn map_filled(&self, area: Rect, lines: usize) -> usize {
if self.shows_minimap(area) {
(lines.div_ceil(MINIMAP_LINES_PER_ROW)).clamp(1, area.h as usize)
} else {
area.h.max(1) as usize
}
}
fn page_margin(area: Rect) -> u16 {
if area.w < NARROW_PANE {
PAGE_MARGIN
} else {
WIDE_PAGE_MARGIN
}
}
fn page_width(area: Rect) -> usize {
area.w
.saturating_sub(Self::page_margin(area) * 2 + 1)
.clamp(1, MAX_PAGE_WIDTH) as usize
}
fn project(&self, text: &str) -> Rc<Project> {
let saved = self.buffer.is_dirty();
let mut cache = self.project.borrow_mut();
if let Some((revision, project)) = cache.as_ref()
&& (saved || *revision == self.buffer.revision())
{
return project.clone();
}
let project = match self.buffer.path() {
Some(path) if !saved => {
let root = cellix_doc::latex::root_of(path, text);
Project::read(&root)
}
_ => Project::from_text(text),
};
let project = Rc::new(project);
*cache = Some((self.buffer.revision(), project.clone()));
project
}
pub fn word_count(&self, others: &[&Editor]) -> (usize, Option<usize>, bool) {
let latex = self.dialect() == Dialect::Latex;
let unsaved: Vec<&Editor> = others
.iter()
.copied()
.filter(|e| latex && e.buffer.is_dirty() && e.dialect() == Dialect::Latex)
.filter(|e| e.buffer.path().is_some() && !std::ptr::eq(*e, self))
.collect();
let stamp = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for editor in &unsaved {
(editor.buffer.path(), editor.buffer.revision()).hash(&mut hasher);
}
hasher.finish()
};
let revision = (self.buffer.revision(), stamp);
let (all, files) = match self.words.get() {
Some((counted, words, files)) if counted == revision => (words, files),
_ => {
let text = self.buffer.text();
let (words, files) = match self.buffer.path().filter(|_| latex) {
Some(path) => {
let root = self.paper_root(path, &text);
let texts: Vec<(&Path, String)> = unsaved
.iter()
.filter_map(|e| Some((e.buffer.path()?, e.buffer.text())))
.collect();
let mut open = vec![(path, text.as_str())];
open.extend(texts.iter().map(|(p, t)| (*p, t.as_str())));
let mut files = self.paper_files.borrow_mut();
cellix_doc::words::paper(&root, path, &open, &mut files)
}
None => (cellix_doc::words::count(&text, latex), 1),
};
self.words.set(Some((revision, words, files)));
(words, files)
}
};
let selected = self
.selected_text()
.filter(|t| !t.trim().is_empty())
.map(|t| cellix_doc::words::count(&t, false));
(all, selected, files > 1)
}
pub fn recount(&self) {
self.words.set(None);
}
fn paper_root(&self, path: &Path, text: &str) -> PathBuf {
let mut known = self.paper_root.borrow_mut();
match known.as_ref() {
Some(root) if self.buffer.is_dirty() => root.clone(),
_ => known.insert(cellix_doc::latex::root_of(path, text)).clone(),
}
}
pub fn change_syntax(&self) -> cellix_doc::changes::Syntax {
if self.dialect() == Dialect::Latex {
cellix_doc::changes::Syntax::Latex
} else {
cellix_doc::changes::Syntax::Critic
}
}
pub fn set_all_text(&mut self, text: &str) {
let head = self.selections.primary().head;
self.selections.set_single(Selection {
anchor: 0,
head: self.buffer.rope().len_chars(),
goal: None,
});
self.buffer.replace(&mut self.selections, text);
let end = self.buffer.rope().len_chars();
self.selections.set_single(Selection::cursor(head.min(end)));
}
pub fn latex_project(&self) -> Rc<Project> {
self.project(&self.buffer.text())
}
pub fn row_target(&self, area: Rect, y: u16) -> Option<Target> {
let area = self.text_area(area);
let rows = self.page(area);
let row = self.top + y.saturating_sub(area.y) as usize;
rows.get(row)?.target.clone()
}
pub fn toggle_preamble(&mut self) {
self.show_preamble = !self.show_preamble;
*self.page.borrow_mut() = None;
}
pub fn dialect(&self) -> Dialect {
let path = self.buffer.path();
path.and_then(Dialect::of).unwrap_or_default()
}
fn lay_out(&self, text: &str, width: usize, open: Option<(usize, usize)>) -> LaidOut {
let folder = self.buffer.path().and_then(Path::parent);
let dialect = self.dialect();
let extras = Extras {
dialect,
project: (dialect == Dialect::Latex).then(|| self.project(text)),
file: self.buffer.path(),
preamble: self.show_preamble,
open,
folder,
library: Some(&self.library),
dark: self.dark.get(),
built: self.built.as_deref(),
notebook: self.notebook.as_ref(),
..Extras::default()
};
let rows = rendered::layout_with(text, width, &*self.measure, &self.registry, &extras);
self.wanted.borrow_mut().extend(extras.wanted.take());
(
Rc::new(rows),
extras.figures.into_inner(),
extras.spans.into_inner(),
extras.preview.into_inner(),
)
}
fn page(&self, area: Rect) -> Rc<Vec<Row>> {
let width = Self::page_width(area);
let revision = self.buffer.revision();
let head = self.selections.primary().head;
let mut cache = self.page.borrow_mut();
let stale = cache
.as_ref()
.is_none_or(|c| c.revision != revision || c.width != width);
if stale {
let text = self.buffer.text();
let (rows, figures, spans, _) = self.lay_out(&text, width, None);
*cache = Some(PageCache {
revision,
width,
open: None,
rows,
figures: Rc::new(figures),
spans: Rc::new(spans),
preview: None,
});
}
let page = cache.as_mut().expect("laid out above");
let open = page
.spans
.iter()
.copied()
.find(|s| s.0 < head && head < s.1);
if open != page.open {
let text = self.buffer.text();
let (rows, figures, _, preview) = self.lay_out(&text, width, open);
page.open = open;
page.rows = rows;
page.figures = Rc::new(figures);
page.preview = preview;
}
page.rows.clone()
}
fn figures(&self) -> Rc<Vec<Figure>> {
let cache = self.page.borrow();
cache
.as_ref()
.map_or_else(Rc::default, |page| page.figures.clone())
}
pub fn figure_on_row(&self, area: Rect, y: u16) -> Option<(usize, [f32; 4])> {
let area = self.text_area(area);
let rows = self.page(area);
let figures = self.figures();
let left = (area.x + Self::page_margin(area)) as f32;
let row = (y as f32) + 0.5;
for (index, found) in rows.iter().enumerate() {
let Some((figure, x)) = found.figure else {
continue;
};
let figure = figures.get(figure)?;
let top = area.y as f32 + index as f32 - self.top as f32 + 0.2;
if row < top || row > top + figure.height {
continue;
}
let shown = [left + x, left + x + figure.width, top, top + figure.height];
return Some((found.line, shown));
}
None
}
pub fn code_block_on_row(&self, area: Rect, y: u16) -> Option<(usize, u16)> {
let area = self.text_area(area);
let rows = self.page(area);
let index = self.top + y.checked_sub(area.y)? as usize;
let row = rows.get(index).filter(|r| r.shaded)?;
let first = (0..=index).rev().take_while(|n| rows[*n].shaded).last()?;
let top = first.max(self.top) - self.top;
Some((row.line, area.y + top as u16))
}
fn image_on_row(&self, area: Rect, y: u16) -> Option<(usize, [f32; 4])> {
let (line, shown) = self.figure_on_row(area, y)?;
let text = self.line_text(line);
let names_one = cellix_doc::figure::image_line(&text).is_some()
|| self.dialect() == Dialect::Latex && {
let lines = line..self.buffer.rope().len_lines();
let mut lines = lines.take(FIGURE_LINES);
lines.any(|n| self.line_text(n).contains("\\includegraphics"))
};
names_one.then_some((line, shown))
}
pub fn figure_quote(&self, line: usize) -> String {
let text = self.line_text(line);
let next = || self.line_text(line + 1).trim().to_string();
if let Some(image) = cellix_doc::figure::image_line(&text) {
return format!("Image {}", image.path);
}
let trimmed = text.trim();
if let Some(language) = trimmed.strip_prefix("```") {
return format!("{} diagram: {}", language.trim(), next());
}
if trimmed.starts_with("$$") || trimmed.starts_with("\\[") {
return format!("Formula: {}", trimmed.trim_matches('$'));
}
let lines = line..self.buffer.rope().len_lines();
let named = lines.take(FIGURE_LINES).find_map(|n| {
let source = self.line_text(n);
let open = source.find("\\includegraphics")?;
let brace = open + source[open..].find('{')?;
let close = brace + source[brace..].find('}')?;
Some(source[brace + 1..close].to_string())
});
match named {
Some(path) => format!("Image {path}"),
None => format!("Figure: {trimmed}"),
}
}
pub fn draw_hover(&self, grid: &mut Grid, area: Rect, hover: (u16, u16), theme: &Theme) {
if self.rendered && !self.measure.in_cells() {
self.draw_image_handle(grid, area, hover, theme);
}
}
pub fn image_edge(&self, area: Rect, x: f32, y: u16) -> Option<usize> {
const REACH: f32 = 1.2;
let (line, [_, right, ..]) = self.image_on_row(area, y)?;
((x - right).abs() <= REACH).then_some(line)
}
pub fn drag_image(&mut self, area: Rect, line: usize, x: f32) {
const STEP: f32 = 5.0;
let page = Self::page_width(self.text_area(area)) as f32;
let middle = (area.x + Self::page_margin(self.text_area(area))) as f32 + page / 2.0;
let share = (x - middle).max(0.0) * 2.0 / page.max(1.0) * 100.0;
let percent = ((share / STEP).round() * STEP).clamp(STEP * 2.0, 100.0) as u32;
let at = self.buffer.position(line, 0);
self.selections.set_single(Selection::cursor(at));
self.set_image_width(percent);
}
fn draw_image_handle(&self, grid: &mut Grid, area: Rect, hover: (u16, u16), theme: &Theme) {
let Some((_, [_, right, top, bottom])) = self.image_on_row(area, hover.1) else {
return;
};
let middle = (top + bottom) / 2.0;
let handle = Bounds::cells(right + 0.25, middle - 0.8, 0.3, 1.6);
grid.panel(handle, 30, Some(theme.accent), None);
}
fn draw_figures(&self, grid: &mut Grid, area: Rect, rows: &[Row], theme: &Theme) {
let figures = self.figures();
let left = (area.x + Self::page_margin(area)) as f32;
let end = (self.top + area.h as usize).min(rows.len());
for (index, row) in rows.iter().enumerate().take(end) {
let Some((figure, x)) = row.figure else {
continue;
};
let Some(figure) = figures.get(figure) else {
continue;
};
let top = area.y as f32 + index as f32 - self.top as f32;
if top + figure.height < area.y as f32 {
continue;
}
grid.paint.push(Paint::Image {
bounds: Bounds::cells(left + x, top + 0.2, figure.width, figure.height),
clip: Bounds::cells(area.x as f32, area.y as f32, area.w as f32, area.h as f32),
bitmap: figure.bitmap.clone(),
color: theme.text.fg,
});
}
}
#[cfg(test)]
pub fn page_rows(&self, area: Rect) -> Rc<Vec<Row>> {
self.page(self.text_area(area))
}
pub fn take_wanted(&self) -> Vec<Snippet> {
self.wanted.take()
}
pub fn refresh_page(&mut self) {
*self.page.borrow_mut() = None;
}
pub fn set_measure(&mut self, measure: Rc<dyn Measure>) {
self.measure = measure;
*self.page.borrow_mut() = None;
*self.library.borrow_mut() = Library::default();
}
pub fn prepare_edit(&mut self, area: Rect) {
if !self.rendered {
return;
}
let area = self.text_area(area);
let primary = self.selections.primary();
if primary.is_empty() && self.selections.len() == 1 {
let at = rendered::snap(&self.page(area), primary.head);
self.selections.set_single(Selection::cursor(at));
}
}
fn move_on_page(&mut self, area: Rect, direction: Direction, extend: bool) -> bool {
let rows = self.page(area);
let mut primary = self.selections.primary();
if primary.is_empty() {
let at = rendered::snap(&rows, primary.head);
primary.anchor = at;
primary.head = at;
}
let head = primary.head;
let page = area.h.max(2) as isize - 1;
let (_, column) = rendered::locate(&rows, head);
let goal = primary
.goal
.map_or(column, |g| g as f32 / units(1.0) as f32);
let vertical = |delta| rendered::vertical(&rows, head, delta, goal);
let (next, keep_goal) = match direction {
Direction::Left => (rendered::step(&rows, head, false), false),
Direction::Right => (rendered::step(&rows, head, true), false),
Direction::Up => (vertical(-1), true),
Direction::Down => (vertical(1), true),
Direction::PageUp => (vertical(-page), true),
Direction::PageDown => (vertical(page), true),
Direction::LineStart | Direction::LineColumnZero => {
(rendered::row_edge(&rows, head, false), false)
}
Direction::LineEnd => (rendered::row_edge(&rows, head, true), false),
_ => return false,
};
self.selections.set_single(Selection {
anchor: if extend { primary.anchor } else { next },
head: next,
goal: keep_goal.then_some(units(goal) as usize),
});
true
}
fn line_of_head(&self) -> (String, usize) {
let rope = self.buffer.rope();
let line = rope.char_to_line(self.selections.primary().head);
(self.line_text(line), rope.line_to_char(line))
}
fn enter_on_page(&mut self) {
let (line, start) = self.line_of_head();
let head = self.selections.primary().head;
let at_end = head == start + line.chars().count();
match rendered::enter_intent(&line) {
Intent::Continue(marker) if at_end => {
self.buffer
.replace(&mut self.selections, &format!("\n{marker}"));
}
Intent::EndList(hidden) => {
self.buffer
.replace_range(&mut self.selections, start, start + hidden, "");
self.selections.set_single(Selection::cursor(start));
}
_ => self.buffer.replace(&mut self.selections, "\n"),
}
}
fn backspace_on_page(&mut self) {
let (line, start) = self.line_of_head();
let hidden = rendered::hidden_prefix(&line);
let primary = self.selections.primary();
if primary.is_empty() && hidden > 0 && primary.head == start + hidden {
self.buffer
.replace_range(&mut self.selections, start, start + hidden, "");
self.selections.set_single(Selection::cursor(start));
} else {
self.buffer.delete_backward(&mut self.selections);
}
}
fn next_slot(&self, head: usize) -> Option<usize> {
let cache = self.page.borrow();
let (_, end) = cache.as_ref()?.open?;
let rope = self.buffer.rope();
let text: Vec<char> = rope
.slice(head..end.min(rope.len_chars()))
.chars()
.collect();
let found = text.windows(2).skip(1).position(|w| w == ['{', '}'])?;
Some(head + found + 2)
}
fn tab_on_page(&mut self, area: Rect) {
let rows = self.page(area);
let head = self.selections.primary().head;
if let Some(slot) = self.next_slot(head) {
self.selections.set_single(Selection::cursor(slot));
return;
}
if let Some(cell) = rendered::next_cell(&rows, head) {
self.select_cell(area, cell);
return;
}
if self.selection_in_table(area) {
self.edit_table(TableEdit::RowBelow);
let rows = self.page(area);
let head = self.selections.primary().head;
if let Some(cell) = rendered::cell_in_row(&rows, head, 0) {
self.select_cell(area, cell);
}
return;
}
let (line, start) = self.line_of_head();
if rendered::hidden_prefix(&line) > 0 {
self.buffer
.replace_range(&mut self.selections, start, start, " ");
self.selections.set_single(Selection::cursor(head + 2));
} else {
self.buffer.type_text(&mut self.selections, " ");
}
}
fn selection_in_table(&self, area: Rect) -> bool {
let rows = self.page(area);
let head = self.selections.primary().head;
rendered::cell_column(&rows, head).is_some() && self.table_at_caret().is_some()
}
fn select_cell(&mut self, area: Rect, cell: usize) {
let rows = self.page(area);
let (anchor, head) = rendered::cell_range(&rows, cell);
self.selections.set_single(Selection {
anchor,
head,
goal: None,
});
}
fn next_row(&mut self, area: Rect) {
let rows = self.page(area);
let head = self.selections.primary().start();
let column = rendered::cell_column(&rows, head).unwrap_or(0);
if let Some(cell) = rendered::cell_below(&rows, head) {
self.select_cell(area, cell);
return;
}
self.edit_table(TableEdit::RowBelow);
let rows = self.page(area);
let head = self.selections.primary().head;
if let Some(cell) = rendered::cell_in_row(&rows, head, column) {
self.select_cell(area, cell);
}
}
pub fn click_checkbox(&mut self, area: Rect, x: f32, y: u16) -> bool {
if !self.rendered {
return false;
}
let area = self.text_area(area);
let rows = self.page(area);
let row = self.top + y.saturating_sub(area.y) as usize;
let column = x - (area.x + Self::page_margin(area)) as f32;
let Some((at, mark)) = rows.get(row).and_then(|r| r.checkbox) else {
return false;
};
if column < at || column >= at + 1.3 {
return false;
}
let checked = self.buffer.rope().char(mark) != ' ';
let text = if checked { " " } else { "x" };
self.buffer
.replace_range(&mut self.selections, mark, mark + 1, text);
true
}
pub fn on_scrollbar(&self, area: Rect, x: u16) -> bool {
x >= self.text_area(area).right()
}
pub fn scroll_to_row(&mut self, area: Rect, y: u16) {
let lines = if self.rendered {
self.page(self.text_area(area)).len()
} else {
self.buffer.rope().len_lines()
};
let row = y.saturating_sub(area.y) as usize;
let filled = self.map_filled(area, lines);
let middle = (row * lines / filled).saturating_sub(area.h as usize / 2);
self.view_rows.set(area.h);
self.top = middle.min(self.last_top());
}
fn gutter_width(&self, pane_width: u16) -> u16 {
let digits = self.buffer.rope().len_lines().to_string().len();
let minimum = if pane_width < NARROW_PANE {
2
} else {
GUTTER_MIN_DIGITS
};
(digits.max(minimum) + 2) as u16
}
pub fn line_text(&self, line: usize) -> String {
let text = self.buffer.rope().line(line).to_string();
text.trim_end_matches(['\n', '\r']).to_string()
}
fn column_of(&self, at: usize) -> usize {
let rope = self.buffer.rope();
let line = rope.char_to_line(at);
let start = rope.line_to_char(line);
let mut column = 0;
for c in rope.slice(start..at).chars() {
column += display_width(c, column);
}
column
}
pub fn position_at(&self, area: Rect, fine_x: f32, y: u16) -> usize {
let x = fine_x as u16;
let area = self.text_area(area);
if self.rendered {
let rows = self.page(area);
let row = self.top + y.saturating_sub(area.y) as usize;
let column = (fine_x - (area.x + Self::page_margin(area)) as f32).max(0.0);
let row = row.min(rows.len().saturating_sub(1));
let above = row
.checked_sub(1)
.and_then(|r| rows.get(r))
.filter(|r| r.tall);
let picture = (!rows[row].stop)
.then(|| {
rows[..=row]
.iter()
.rev()
.find(|r| r.stop || r.figure.is_some())
})
.flatten()
.filter(|r| r.figure.is_some());
let stop = above
.or(picture)
.or_else(|| rows[row..].iter().find(|r| r.stop));
return stop.map_or(self.buffer.rope().len_chars(), |r| r.position_at(column));
}
let rope = self.buffer.rope();
let line = (self.top + (y.saturating_sub(area.y)) as usize).min(rope.len_lines() - 1);
let target = self.left + x.saturating_sub(area.x + self.gutter_width(area.w)) as usize;
let start = rope.line_to_char(line);
let mut column = 0;
for (i, c) in self.line_text(line).chars().enumerate() {
let width = display_width(c, column);
if column + width > target {
return start + i;
}
column += width;
}
start + self.line_text(line).chars().count()
}
pub fn top(&self) -> usize {
self.top
}
fn scroll_length(&self) -> usize {
let rows = self.page.borrow().as_ref().map(|page| page.rows.len());
rows.filter(|_| self.rendered)
.unwrap_or_else(|| self.buffer.rope().len_lines())
}
fn last_top(&self) -> usize {
self.scroll_length()
.saturating_sub(self.view_rows.get().max(1) as usize)
}
pub fn scroll_by(&mut self, delta: isize) {
let last = self.last_top() as isize;
self.top = (self.top as isize + delta).clamp(0, last) as usize;
}
pub fn scroll_region(&self, area: Rect) -> Rect {
let text = self.text_area(area);
Rect::new(text.x + 1, text.y, text.w.saturating_sub(1), text.h)
}
pub fn reveal(&mut self, area: Rect) {
let area = self.text_area(area);
if self.rendered {
let (row, _) = rendered::locate(&self.page(area), self.selections.primary().head);
let height = area.h.max(1) as usize;
if row < self.top {
self.top = row;
} else if row >= self.top + height {
self.top = row + 1 - height;
}
self.left = 0;
return;
}
let head = self.selections.primary().head;
let line = self.buffer.rope().char_to_line(head);
let rows = area.h.max(1) as usize;
if line < self.top {
self.top = line;
} else if line >= self.top + rows {
self.top = line + 1 - rows;
}
let column = self.column_of(head);
let cols = area.w.saturating_sub(self.gutter_width(area.w)).max(1) as usize;
if column < self.left {
self.left = column;
} else if column >= self.left + cols {
self.left = column + 1 - cols;
}
}
pub fn run(&mut self, command: Command, area: Rect) {
let page = area.h.max(2) as usize - 1;
if self.rendered {
let text_area = self.text_area(area);
self.prepare_for_command(command, text_area);
let handled = match command {
Command::Move(direction, extend) => self.move_on_page(text_area, direction, extend),
Command::DeleteBackward if self.tracking => {
self.tracked_delete(false);
true
}
Command::DeleteForward if self.tracking => {
self.tracked_delete(true);
true
}
Command::Newline if self.selection_in_table(text_area) => {
self.next_row(text_area);
true
}
Command::Outdent if self.selection_in_table(text_area) => {
let rows = self.page(text_area);
let start = self.selections.primary().start();
if let Some(cell) = rendered::previous_cell(&rows, start) {
self.select_cell(text_area, cell);
}
true
}
Command::Newline => {
self.enter_on_page();
true
}
Command::DeleteBackward => {
self.backspace_on_page();
true
}
Command::Indent => {
self.tab_on_page(text_area);
true
}
_ => false,
};
if handled {
self.reveal(area);
return;
}
}
match command {
Command::Move(direction, extend) => {
let motion = match direction {
Direction::Left => Motion::Left,
Direction::Right => Motion::Right,
Direction::Up => Motion::Up,
Direction::Down => Motion::Down,
Direction::WordLeft => Motion::WordLeft,
Direction::WordRight => Motion::WordRight,
Direction::LineStart => Motion::LineStart,
Direction::LineColumnZero => Motion::LineColumnZero,
Direction::LineEnd => Motion::LineEnd,
Direction::Top => Motion::Top,
Direction::Bottom => Motion::Bottom,
Direction::PageUp => Motion::PageUp(page),
Direction::PageDown => Motion::PageDown(page),
Direction::NextWordStart => Motion::NextWordStart,
Direction::FindChar(c, forward, till) => Motion::FindChar(c, forward, till),
};
self.selections
.apply_motion(self.buffer.rope(), motion, extend);
}
Command::Newline => self.newline(),
Command::Type(c) => self.type_char(c),
Command::ToggleComment => self.toggle_comment(),
Command::MoveLineUp => self.move_line(-1),
Command::MoveLineDown => self.move_line(1),
Command::DuplicateLine => self.duplicate_line(),
Command::DeleteLine => self.delete_line(),
Command::InsertLineBelow | Command::InsertLineAbove => {
self.insert_line(command == Command::InsertLineAbove);
}
Command::SelectAllMatches => {
self.buffer.select_next_match(&mut self.selections);
if !self.selections.primary().is_empty() {
loop {
let before = self.selections.len();
self.buffer.select_next_match(&mut self.selections);
if self.selections.len() == before {
break;
}
}
}
}
Command::AddCursorAbove | Command::AddCursorBelow => {
let rope = self.buffer.rope();
let primary = self.selections.primary();
let line = rope.char_to_line(primary.head);
let column = primary.head - rope.line_to_char(line);
let next = if command == Command::AddCursorAbove {
line.checked_sub(1)
} else {
Some(line + 1).filter(|l| *l < rope.len_lines())
};
if let Some(next) = next {
let width = self.line_text(next).chars().count();
let at = rope.line_to_char(next) + column.min(width);
self.selections.add(Selection::cursor(at));
}
}
Command::Indent => self.buffer.type_text(&mut self.selections, " "),
Command::Outdent => self.outdent(),
Command::DeleteWordLeft => self.delete_to(Motion::WordLeft),
Command::DeleteWordRight => self.delete_to(Motion::WordRight),
Command::DeleteToLineStart => self.delete_to(Motion::LineColumnZero),
Command::DeleteToLineEnd => self.delete_to(Motion::LineEnd),
Command::DeleteBackward => self.backspace(),
Command::DeleteForward => self.buffer.delete_forward(&mut self.selections),
Command::Undo => {
self.buffer.undo(&mut self.selections);
}
Command::Redo => {
self.buffer.redo(&mut self.selections);
}
Command::SelectAll => {
let len = self.buffer.rope().len_chars();
self.selections.set_single(Selection {
anchor: 0,
head: len,
goal: None,
});
}
Command::SelectLines(count) => {
let rope = self.buffer.rope();
let line = rope.char_to_line(self.selections.primary().head);
let last = rope.len_lines();
self.selections.set_single(Selection {
anchor: rope.line_to_char(line),
head: rope.line_to_char((line + count).min(last)),
goal: None,
});
}
Command::SelectNextMatch => self.buffer.select_next_match(&mut self.selections),
_ => return,
}
self.reveal(area);
}
fn prepare_for_command(&mut self, command: Command, area: Rect) {
if matches!(
command,
Command::Newline | Command::DeleteBackward | Command::DeleteForward | Command::Indent
) {
let primary = self.selections.primary();
if primary.is_empty() {
let at = rendered::snap(&self.page(area), primary.head);
self.selections.set_single(Selection::cursor(at));
}
}
}
fn outdent(&mut self) {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let start = rope.line_to_char(rope.char_to_line(head));
let spaces = rope
.line(rope.char_to_line(head))
.chars()
.take(TAB_WIDTH)
.take_while(|c| *c == ' ')
.count();
if spaces > 0 {
self.buffer
.replace_range(&mut self.selections, start, start + spaces, "");
}
}
fn char_at(&self, at: usize) -> Option<char> {
let rope = self.buffer.rope();
(at < rope.len_chars()).then(|| rope.char(at))
}
fn newline(&mut self) {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.line(rope.char_to_line(head));
let indent: String = line
.chars()
.take_while(|c| matches!(c, ' ' | '\t'))
.collect();
let before = head.checked_sub(1).and_then(|at| self.char_at(at));
let after = self.char_at(head);
let opens = matches!(before, Some('{' | '(' | '['));
let closes = matches!(
(before, after),
(Some('{'), Some('}')) | (Some('('), Some(')')) | (Some('['), Some(']'))
);
let inner = format!("{indent}{}", if opens { INDENT } else { "" });
let text = if closes {
format!("\n{inner}\n{indent}")
} else {
format!("\n{inner}")
};
self.buffer.type_text(&mut self.selections, &text);
if closes {
let at = head + 1 + inner.chars().count();
self.selections.set_single(Selection::cursor(at));
}
}
pub fn type_char(&mut self, c: char) {
if self.tracking && self.rendered {
self.tracked_type(c.encode_utf8(&mut [0; 4]));
return;
}
let primary = self.selections.primary();
let close = match c {
'(' => Some(')'),
'[' => Some(']'),
'{' => Some('}'),
'"' | '`' | '\'' => Some(c),
_ => None,
};
let pairs = !self.rendered && self.selections.len() == 1;
let after = self.char_at(primary.head);
let before = primary.head.checked_sub(1).and_then(|at| self.char_at(at));
if pairs
&& primary.is_empty()
&& after == Some(c)
&& matches!(c, ')' | ']' | '}' | '"' | '`' | '\'')
{
self.selections
.set_single(Selection::cursor(primary.head + 1));
return;
}
let text = c.to_string();
let Some(close) = close.filter(|_| pairs) else {
self.buffer.type_text(&mut self.selections, &text);
return;
};
if !primary.is_empty() {
let (start, end) = (primary.start(), primary.end());
let inner = self.buffer.rope().slice(start..end).to_string();
let wrapped = format!("{c}{inner}{close}");
self.buffer
.replace_range(&mut self.selections, start, end, &wrapped);
self.selections.set_single(Selection {
anchor: start + 1,
head: end + 1,
goal: None,
});
return;
}
let by_word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
if by_word(after) || (c == close && by_word(before)) {
self.buffer.type_text(&mut self.selections, &text);
return;
}
self.buffer
.type_text(&mut self.selections, &format!("{c}{close}"));
self.selections
.set_single(Selection::cursor(primary.head + 1));
}
fn backspace(&mut self) {
let primary = self.selections.primary();
let pair = primary.head.checked_sub(1).and_then(|at| {
let open = self.char_at(at)?;
let close = self.char_at(primary.head)?;
matches!(
(open, close),
('(', ')') | ('[', ']') | ('{', '}') | ('"', '"') | ('`', '`') | ('\'', '\'')
)
.then_some(at)
});
match pair.filter(|_| primary.is_empty() && self.selections.len() == 1) {
Some(at) => {
self.buffer
.replace_range(&mut self.selections, at, at + 2, "");
self.selections.set_single(Selection::cursor(at));
}
None => self.buffer.delete_backward(&mut self.selections),
}
}
fn selected_lines(&self) -> (usize, usize, usize, usize) {
let primary = self.selections.primary();
let rope = self.buffer.rope();
let first = rope.char_to_line(primary.start());
let last = rope.char_to_line(primary.end());
let start = rope.line_to_char(first);
let end = rope.line_to_char(last) + self.line_text(last).chars().count();
(first, last, start, end)
}
fn toggle_comment(&mut self) {
let Some(mark) = self.buffer.path().and_then(Registry::line_comment) else {
return;
};
let (first, last, start, end) = self.selected_lines();
let lines: Vec<String> = (first..=last).map(|line| self.line_text(line)).collect();
let written = |line: &&String| !line.trim().is_empty();
let commented = lines
.iter()
.filter(written)
.all(|line| line.trim_start().starts_with(mark));
let margin = lines
.iter()
.filter(written)
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
let changed: Vec<String> = lines
.iter()
.map(|line| {
if line.trim().is_empty() {
line.clone()
} else if commented {
let at = line.len() - line.trim_start().len();
let rest = line[at + mark.len()..]
.strip_prefix(' ')
.unwrap_or(&line[at + mark.len()..]);
format!("{}{rest}", &line[..at])
} else {
format!("{}{mark} {}", &line[..margin], &line[margin..])
}
})
.collect();
let text = changed.join("\n");
self.buffer
.replace_range(&mut self.selections, start, end, &text);
self.selections
.set_single(Selection::cursor(start + text.chars().count()));
}
fn move_line(&mut self, by: isize) {
let (first, last, start, end) = self.selected_lines();
let rope = self.buffer.rope();
let count = rope.len_lines();
let trailing = rope.line(count - 1).len_chars() == 0;
let lines = count - usize::from(trailing);
let other = if by < 0 {
first.checked_sub(1)
} else {
(last + 1 < lines).then_some(last + 1)
};
let Some(other) = other else {
return;
};
let head = self.selections.primary().head;
let block = rope.slice(start..end).to_string();
let neighbour = self.line_text(other);
let (from, to, text, moved) = if by < 0 {
let from = rope.line_to_char(other);
(
from,
end,
format!("{block}\n{neighbour}"),
from + head - start,
)
} else {
let to = rope.line_to_char(other) + neighbour.chars().count();
let shift = neighbour.chars().count() + 1;
(start, to, format!("{neighbour}\n{block}"), head + shift)
};
self.buffer
.replace_range(&mut self.selections, from, to, &text);
self.selections.set_single(Selection::cursor(moved));
}
fn delete_line(&mut self) {
let (first, last, start, _) = self.selected_lines();
let rope = self.buffer.rope();
let lines = rope.len_lines();
let end = if last + 1 < lines {
rope.line_to_char(last + 1)
} else {
rope.len_chars()
};
let start = if end == rope.len_chars() && first > 0 && start > 0 {
start - 1
} else {
start
};
self.buffer
.replace_range(&mut self.selections, start, end, "");
let rope = self.buffer.rope();
let line = first.min(rope.len_lines().saturating_sub(1));
self.selections
.set_single(Selection::cursor(rope.line_to_char(line)));
}
fn insert_line(&mut self, above: bool) {
let (first, last, start, end) = self.selected_lines();
let line = if above { first } else { last };
let indent: String = self
.line_text(line)
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
let (at, text) = if above {
(start, format!("{indent}\n"))
} else {
(end, format!("\n{indent}"))
};
self.buffer
.replace_range(&mut self.selections, at, at, &text);
let caret = if above {
start + indent.chars().count()
} else {
at + text.chars().count()
};
self.selections.set_single(Selection::cursor(caret));
}
fn duplicate_line(&mut self) {
let (_, _, start, end) = self.selected_lines();
let block = self.buffer.rope().slice(start..end).to_string();
let head = self.selections.primary().head;
let copy = format!("\n{block}");
self.buffer
.replace_range(&mut self.selections, end, end, ©);
let at = head + block.chars().count() + 1;
self.selections.set_single(Selection::cursor(at));
}
pub fn go_to_line(&mut self, line: usize, area: Rect) {
let at = self.buffer.position(line.saturating_sub(1), 0);
self.selections.set_single(Selection::cursor(at));
self.reveal(area);
}
pub fn caret_cell(&self, area: Rect) -> Option<(f32, u16)> {
self.cell_of(area, self.selections.primary().head)
}
pub fn cell_of(&self, area: Rect, head: usize) -> Option<(f32, u16)> {
let area = self.text_area(area);
let (row, x) = if self.rendered {
let rows = self.page(area);
let (row, x) = rendered::locate(&rows, head);
(row, (area.x + Self::page_margin(area)) as f32 + x)
} else {
let row = self.buffer.rope().char_to_line(head);
let column = self.column_of(head).checked_sub(self.left)?;
let gutter = self.gutter_width(area.w);
(row, (area.x + gutter) as f32 + column as f32)
};
let y = row.checked_sub(self.top)?;
(y < area.h as usize).then_some((x, area.y + y as u16))
}
pub fn collect_highlights(&self) -> bool {
let Some((revision, spans)) = self.worker.get().and_then(Worker::finished) else {
return false;
};
if revision == self.buffer.revision() {
self.spans_bytes.set(self.buffer.rope().len_bytes());
*self.spans.borrow_mut() = Some((revision, spans));
*self.minimap.borrow_mut() = None;
true
} else {
false
}
}
fn delete_to(&mut self, motion: Motion) {
let primary = self.selections.primary();
let (start, end) = if primary.is_empty() {
let mut moved = self.selections.clone();
moved.apply_motion(self.buffer.rope(), motion, false);
let target = moved.primary().head;
(target.min(primary.head), target.max(primary.head))
} else {
(primary.start(), primary.end())
};
if self.tracking && self.rendered {
self.selections.set_single(Selection {
anchor: start,
head: end,
goal: None,
});
self.tracked_delete(false);
return;
}
self.buffer
.replace_range(&mut self.selections, start, end, "");
self.selections.set_single(Selection::cursor(start));
}
fn toggle_inline(&mut self, marker: &str) {
let primary = self.selections.primary();
let (start, end) = if primary.is_empty() {
self.buffer.word_at(primary.head)
} else {
(primary.start(), primary.end())
};
let size = marker.chars().count();
let rope = self.buffer.rope();
let before = start
.checked_sub(size)
.filter(|from| rope.slice(*from..start) == marker);
let after_end = end + size;
let after = after_end <= rope.len_chars() && rope.slice(end..after_end) == marker;
let inner = rope.slice(start..end).to_string();
let (from, to, text, selected) = match before.filter(|_| after) {
Some(from) => (from, after_end, inner, from),
None => (start, end, format!("{marker}{inner}{marker}"), start + size),
};
let length = end - start;
self.buffer
.replace_range(&mut self.selections, from, to, &text);
self.selections.set_single(Selection {
anchor: selected,
head: selected + length,
goal: None,
});
}
fn next_prefix(format: Format, line: &str) -> (usize, String) {
let hashes = line.chars().take_while(|c| *c == '#').count();
let heading = hashes > 0 && line[hashes..].starts_with(' ');
let simple = |prefix: &str| {
if line.starts_with(prefix) {
(prefix.len(), String::new())
} else {
(0, prefix.to_string())
}
};
match format {
Format::Heading if heading && hashes < MAX_HEADING_CYCLE => {
(hashes + 1, format!("{} ", "#".repeat(hashes + 1)))
}
Format::Heading if heading => (hashes + 1, String::new()),
Format::Heading => (0, "# ".to_string()),
Format::Level(level) => {
let drop = if heading { hashes + 1 } else { 0 };
let add = "#".repeat(level as usize);
(drop, if level == 0 { add } else { format!("{add} ") })
}
Format::Numbered => simple("1. "),
Format::Bullet => simple("- "),
Format::Task => simple("- [ ] "),
Format::PlainText if heading => (hashes + 1, String::new()),
Format::PlainText => {
let markers = ["- [ ] ", "- [x] ", "- ", "* ", "> "];
let found = markers.iter().find(|m| line.starts_with(**m));
(found.map_or(0, |m| m.len()), String::new())
}
_ => simple("> "),
}
}
fn toggle_lines(&mut self, format: Format) {
let primary = self.selections.primary();
let rope = self.buffer.rope();
let first = rope.char_to_line(primary.start());
let last = rope.char_to_line(primary.end());
let start = rope.line_to_char(first);
let end = rope.line_to_char(last) + self.line_text(last).chars().count();
let mut text = Vec::new();
for line in first..=last {
let line = self.line_text(line);
let (drop, add) = Self::next_prefix(format, &line);
text.push(format!("{add}{}", &line[drop..]));
}
let text = text.join("\n");
self.buffer
.replace_range(&mut self.selections, start, end, &text);
let at = start + text.chars().count();
self.selections.set_single(Selection::cursor(at));
}
fn toggle_command(&mut self, open: &str) {
let primary = self.selections.primary();
let (start, end) = if primary.is_empty() {
self.buffer.word_at(primary.head)
} else {
(primary.start(), primary.end())
};
let size = open.chars().count();
let rope = self.buffer.rope();
let inside = start >= size
&& rope.slice(start - size..start) == open
&& rope.get_char(end) == Some('}');
let inner = rope.slice(start..end).to_string();
let length = end - start;
let selected = if inside {
let (from, to) = (start - size, end + 1);
self.buffer
.replace_range(&mut self.selections, from, to, &inner);
from
} else {
let text = format!("{open}{inner}}}");
self.buffer
.replace_range(&mut self.selections, start, end, &text);
start + size
};
self.selections.set_single(Selection {
anchor: selected,
head: selected + length,
goal: None,
});
}
fn line_as_text(&self) -> (usize, String) {
let rope = self.buffer.rope();
let line = rope.char_to_line(self.selections.primary().head);
let text = self.line_text(line);
let plain = text
.split_once('{')
.filter(|_| text.trim_start().starts_with("\\"))
.and_then(|(_, rest)| rest.trim_end().strip_suffix('}'))
.unwrap_or(&text)
.to_string();
(line, plain)
}
fn set_section(&mut self, level: u8) {
let (line, plain) = self.line_as_text();
let start = self.buffer.rope().line_to_char(line);
let end = start + self.line_text(line).chars().count();
let text = match level {
1 => format!("\\section{{{plain}}}"),
2 => format!("\\subsection{{{plain}}}"),
3 => format!("\\subsubsection{{{plain}}}"),
_ => plain,
};
self.buffer
.replace_range(&mut self.selections, start, end, &text);
let at = start + text.chars().count();
self.selections.set_single(Selection::cursor(at));
}
fn wrap_environment(&mut self, name: &str, items: bool) {
let primary = self.selections.primary();
let rope = self.buffer.rope();
let first = rope.char_to_line(primary.start());
let last = rope.char_to_line(primary.end());
let start = rope.line_to_char(first);
let end = rope.line_to_char(last) + self.line_text(last).chars().count();
let mut text = format!("\\begin{{{name}}}\n");
for line in first..=last {
let lead = if items { " \\item " } else { " " };
text.push_str(&format!("{lead}{}\n", self.line_text(line).trim()));
}
text.push_str(&format!("\\end{{{name}}}"));
self.buffer
.replace_range(&mut self.selections, start, end, &text);
let at = start + text.chars().count() - name.len() - 7;
self.selections.set_single(Selection::cursor(at));
}
fn format_latex(&mut self, format: Format) {
match format {
Format::Bold => self.toggle_command("\\textbf{"),
Format::Italic => self.toggle_command("\\emph{"),
Format::Code => self.toggle_command("\\texttt{"),
Format::Strikethrough => {
self.ensure_package("[normalem]{ulem}", "{ulem}");
self.toggle_command("\\sout{");
}
Format::Link => self.make_link("https://"),
Format::Bullet | Format::Task => self.wrap_environment("itemize", true),
Format::Numbered => self.wrap_environment("enumerate", true),
Format::Quote => self.wrap_environment("quote", false),
Format::CodeBlock => self.wrap_environment("verbatim", false),
Format::Level(level) => self.set_section(level),
Format::Heading => self.set_section(1),
Format::PlainText => self.set_section(0),
Format::Rule => self.insert_block("\\bigskip\\hrule\\bigskip", 0),
Format::Table => self.insert_table(3, 3),
}
}
pub fn format(&mut self, format: Format, area: Rect) {
self.prepare_edit(area);
if self.dialect() == Dialect::Latex {
self.format_latex(format);
return;
}
match format {
Format::Bold => self.toggle_inline("**"),
Format::Italic => self.toggle_inline("*"),
Format::Code => self.toggle_inline("`"),
Format::Strikethrough => self.toggle_inline("~~"),
Format::CodeBlock => self.fence_lines(),
Format::Table => self.insert_table(3, 3),
Format::Rule => self.insert_block("---", 3),
Format::Link => self.make_link("https://"),
_ => self.toggle_lines(format),
}
}
pub fn make_link(&mut self, address: &str) {
let primary = self.selections.primary();
let (start, end) = if primary.is_empty() {
self.buffer.word_at(primary.head)
} else {
(primary.start(), primary.end())
};
let text = self.buffer.rope().slice(start..end).to_string();
let link = if self.dialect() == Dialect::Latex {
format!("\\href{{{address}}}{{{text}}}")
} else {
format!("[{text}]({address})")
};
self.buffer
.replace_range(&mut self.selections, start, end, &link);
let after = start + link.chars().count();
self.selections.set_single(Selection::cursor(after));
}
pub fn set_quotes(&self, quotes: &[(usize, String)]) {
let rope = self.buffer.rope();
let mut ranges = Vec::new();
for (line, quote) in quotes {
let index = line.saturating_sub(1);
if quote.is_empty() || index >= rope.len_lines() {
continue;
}
let text = self.line_text(index);
if let Some(at) = text.find(quote.as_str()) {
let start = rope.line_to_char(index) + text[..at].chars().count();
ranges.push((start, start + quote.chars().count()));
}
}
*self.quoted.borrow_mut() = ranges;
}
pub fn insert_block(&mut self, block: &str, caret: usize) {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.char_to_line(head);
let text = self.line_text(line);
let at = rope.line_to_char(line) + text.chars().count();
let gap = if text.trim().is_empty() { "" } else { "\n\n" };
let insert = format!("{gap}{block}\n");
self.buffer
.replace_range(&mut self.selections, at, at, &insert);
let spot = at + gap.chars().count() + caret;
self.selections.set_single(Selection::cursor(spot));
}
pub fn insert_image(&mut self, path: &str) {
if self.dialect() == Dialect::Latex {
let figure = format!(
"\\begin{{figure}}[h]\n \\centering\n \\includegraphics[width=\\linewidth]{{{path}}}\n \\caption{{}}\n\\end{{figure}}"
);
let caret = figure.find("\\caption{}").unwrap_or(0) + "\\caption{".len();
self.insert_block(&figure, caret);
} else {
self.insert_block(&format!(""), 2);
}
}
pub fn save(&mut self) -> io::Result<()> {
match &self.notebook {
Some(notebook) => {
let written = notebook.write(&self.buffer.text());
self.buffer.save_as(&written)
}
None => self.buffer.save(),
}
}
pub fn reload(&mut self) -> io::Result<()> {
let notebook = self.notebook.is_some();
let read = self
.buffer
.path()
.filter(|_| notebook)
.map(std::fs::read_to_string);
let Some(text) = read else {
return self.buffer.reload(&mut self.selections);
};
let notebook = Notebook::read(&text?).map_err(io::Error::other)?;
let page = notebook.page();
self.notebook = Some(notebook);
*self.page.borrow_mut() = None;
self.buffer.reload_as(&mut self.selections, page)
}
pub fn code_cell_at(&self, line: usize) -> Option<(usize, Marker, String)> {
self.notebook.as_ref()?;
let last = self.buffer.rope().len_lines();
let top = (0..=line.min(last.saturating_sub(1)))
.rev()
.find(|n| marker(&self.line_text(*n)).is_some())?;
let mark = marker(&self.line_text(top)).filter(|m| m.code)?;
let lines = (top + 1..last).map(|n| self.line_text(n));
let lines: Vec<String> = lines.take_while(|text| marker(text).is_none()).collect();
let open = lines.iter().position(|l| l.starts_with("```"))?;
let close = lines.iter().rposition(|l| l.trim() == "```")?;
(close > open).then(|| (top, mark, lines[open + 1..close].join("\n")))
}
pub fn code_cells(&self) -> Vec<(Marker, String)> {
let lines = 0..self.buffer.rope().len_lines();
let tops = lines.filter(|n| marker(&self.line_text(*n)).is_some_and(|m| m.code));
let cells = tops.filter_map(|n| self.code_cell_at(n));
cells.map(|(_, mark, code)| (mark, code)).collect()
}
pub fn number_new_cells(&mut self) -> io::Result<()> {
let head = self.selections.primary().head;
self.save()?;
self.reload()?;
self.selections.set_single(Selection::cursor(head));
Ok(())
}
pub fn insert_cell(&mut self, code: bool) {
let language = self
.notebook
.as_ref()
.map_or("python", |n| n.language.as_str());
let cell = if code {
cellix_doc::notebook::new_code_cell(language)
} else {
cellix_doc::notebook::new_text_cell()
};
let rope = self.buffer.rope();
let line = rope.char_to_line(self.selections.primary().head);
let next = (line + 1..rope.len_lines()).find(|n| marker(&self.line_text(*n)).is_some());
let at = next.map_or(rope.len_chars(), |n| rope.line_to_char(n));
let caret = if code {
cell.find("\n\n").map_or(0, |gap| gap + 1)
} else {
cell.chars().count()
};
let lead = if at == rope.len_chars() && !self.buffer.text().ends_with("\n\n") {
"\n"
} else {
""
};
let block = format!("{lead}{cell}\n\n");
self.buffer
.replace_range(&mut self.selections, at, at, &block);
let spot = at + lead.chars().count() + cell[..caret].chars().count();
self.selections.set_single(Selection::cursor(spot));
}
pub fn go_to_next_cell(&mut self, area: Rect) {
let rope = self.buffer.rope();
let line = rope.char_to_line(self.selections.primary().head);
let next = (line + 1..rope.len_lines())
.find(|n| marker(&self.line_text(*n)).is_some_and(|m| m.code));
if let Some(next) = next {
self.go_to_line(next + 3, area);
}
}
fn current_issues(&self) -> &[Issue] {
if self.issues.0 == self.buffer.revision() {
&self.issues.1
} else {
&[]
}
}
pub fn prose_source(&self) -> Source {
match self.dialect() {
Dialect::Latex => Source::Latex,
_ => Source::Markdown,
}
}
pub fn issue_at_caret(&self) -> Option<&Issue> {
let head = self.selections.primary().head;
let holds = |issue: &&Issue| issue.start <= head && head <= issue.end;
self.current_issues().iter().find(holds)
}
pub fn words_in(&self, start: usize, end: usize) -> String {
self.buffer.rope().slice(start..end).to_string()
}
pub fn apply_fix(&mut self, start: usize, end: usize, text: &str) {
self.buffer
.replace_range(&mut self.selections, start, end, text);
let at = start + text.chars().count();
self.selections.set_single(Selection::cursor(at));
}
pub fn forget_issues(&mut self, word: &str) {
let wanted = word.to_lowercase();
let rope = self.buffer.rope().clone();
self.issues.1.retain(|issue| {
rope.slice(issue.start..issue.end)
.to_string()
.to_lowercase()
!= wanted
});
}
fn syntax(&self) -> Syntax {
match self.dialect() {
Dialect::Latex => Syntax::Latex,
_ => Syntax::Critic,
}
}
fn changes(&self) -> Vec<Change> {
changes::find(&self.buffer.text(), self.syntax())
}
pub fn change_at_caret(&self) -> Option<Change> {
let head = self.selections.primary().head;
let holds = |c: &Change| c.start <= head && head <= c.end;
self.changes().into_iter().find(holds)
}
pub fn settle_change(&mut self, accept: bool) {
let Some(change) = self.change_at_caret() else {
return;
};
let kept = if accept { &change.new } else { &change.old };
self.buffer
.replace_range(&mut self.selections, change.start, change.end, kept);
let at = change.start + kept.chars().count();
self.selections.set_single(Selection::cursor(at));
}
pub fn settle_all_changes(&mut self, accept: bool) -> usize {
let text = self.buffer.text();
let count = changes::find(&text, self.syntax()).len();
if count > 0 {
let settled = changes::settle_all(&text, self.syntax(), accept);
let total = self.buffer.rope().len_chars();
self.buffer
.replace_range(&mut self.selections, 0, total, &settled);
let head = self.selections.primary().head.min(settled.chars().count());
self.selections.set_single(Selection::cursor(head));
}
count
}
fn ensure_package(&mut self, what: &str, known: &str) {
let text = self.buffer.text();
if text.contains(known) {
return;
}
let Some(at) = text.find("\\begin{document}") else {
return;
};
let at = text[..at].chars().count();
let mut selections = self.selections.clone();
let line = format!("\\usepackage{what}\n");
self.buffer.replace_range(&mut selections, at, at, &line);
let length = line.chars().count();
let shifted: Vec<Selection> = self
.selections
.iter()
.map(|s| Selection {
anchor: s.anchor + length,
head: s.head + length,
goal: s.goal,
})
.collect();
if let Some(first) = shifted.first() {
self.selections.set_single(*first);
for s in &shifted[1..] {
self.selections.add(*s);
}
}
}
pub fn toggle_tracking(&mut self) -> bool {
self.tracking = !self.tracking;
let text = self.buffer.text();
if self.tracking
&& self.dialect() == Dialect::Latex
&& !text.contains("{changes}")
&& let Some(at) = text.find("\\begin{document}")
{
let at = text[..at].chars().count();
let mut selections = self.selections.clone();
self.buffer
.replace_range(&mut selections, at, at, "\\usepackage{changes}\n");
}
self.tracking
}
fn tracked_head(&self, changes: &[Change]) -> usize {
let head = self.selections.primary().head;
let deleted = changes
.iter()
.find(|c| c.kind == Kind::Delete && c.start < head && head < c.end);
deleted.map_or(head, |c| c.end)
}
pub fn cut_selection(&mut self) {
if self.tracking && self.rendered {
self.tracked_delete(false);
} else {
self.buffer.replace(&mut self.selections, "");
}
}
pub fn tracked_paste(&mut self, text: &str) {
self.tracked_type(text);
}
fn tracked_type(&mut self, text: &str) {
if !self.selections.primary().is_empty() {
self.tracked_delete(false);
let changes = self.changes();
let head = self.selections.primary().head;
if let Some(change) = changes.iter().find(|c| c.start == head) {
self.selections.set_single(Selection::cursor(change.end));
}
}
let changes = self.changes();
let head = self.tracked_head(&changes);
let (open, close) = self.syntax().insertion();
let closing = close.chars().count();
let joins = changes
.iter()
.find(|c| c.kind == Kind::Insert && c.start < head && head <= c.end);
let (at, typed) = match joins {
Some(change) => (head.min(change.end - closing), text.to_string()),
None => (head, format!("{open}{text}{close}")),
};
self.buffer
.replace_range(&mut self.selections, at, at, &typed);
let caret = match joins {
Some(_) => at + text.chars().count(),
None => at + open.chars().count() + text.chars().count(),
};
self.selections.set_single(Selection::cursor(caret));
}
fn tracked_delete(&mut self, forward: bool) {
let primary = self.selections.primary();
let changes = self.changes();
let (open, close) = self.syntax().deletion();
if !primary.is_empty() {
let text = self
.buffer
.rope()
.slice(primary.start()..primary.end())
.to_string();
let lines: Vec<String> = text
.split('\n')
.map(|line| {
if line.is_empty() {
String::new()
} else {
format!("{open}{line}{close}")
}
})
.collect();
let start = primary.start();
self.buffer.replace_range(
&mut self.selections,
start,
primary.end(),
&lines.join("\n"),
);
self.selections.set_single(Selection::cursor(start));
return;
}
let head = self.selections.primary().head;
let total = self.buffer.rope().len_chars();
let settled = changes.iter().find(|c| {
c.kind == Kind::Delete
&& if forward {
c.start <= head && head < c.end
} else {
c.start < head && head <= c.end
}
});
let head = settled.map_or(head, |c| if forward { c.end } else { c.start });
let at = if forward {
(head < total).then_some(head)
} else {
head.checked_sub(1)
};
let Some(at) = at else {
return;
};
let (added_open, added_close) = self.syntax().insertion();
let added = changes.iter().find(|c| {
let inner =
(c.start + added_open.chars().count())..(c.end - added_close.chars().count());
c.kind == Kind::Insert && (inner.contains(&at) || (!forward && head == c.end))
});
if let Some(change) = added {
if change.new.chars().count() <= 1 {
self.buffer
.replace_range(&mut self.selections, change.start, change.end, "");
self.selections.set_single(Selection::cursor(change.start));
return;
}
let inner_end = change.end - added_close.chars().count();
let at = if head == change.end {
inner_end - 1
} else {
at
};
self.buffer
.replace_range(&mut self.selections, at, at + 1, "");
self.selections.set_single(Selection::cursor(at));
return;
}
let Some(gone) = self.char_at(at) else {
return;
};
if gone == '\n' {
self.buffer
.replace_range(&mut self.selections, at, at + 1, "");
self.selections.set_single(Selection::cursor(at));
return;
}
let opening = open.chars().count();
if let (false, Some(change)) = (
forward,
changes
.iter()
.find(|c| c.kind == Kind::Delete && c.start == head),
) {
let joined = format!("{open}{gone}");
self.buffer
.replace_range(&mut self.selections, at, change.start + opening, &joined);
self.selections.set_single(Selection::cursor(at));
return;
}
let crossed = format!("{open}{gone}{close}");
self.buffer
.replace_range(&mut self.selections, at, at + 1, &crossed);
let caret = if forward {
at + crossed.chars().count()
} else {
at
};
self.selections.set_single(Selection::cursor(caret));
}
fn image_line_at_caret(&self) -> Option<usize> {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.char_to_line(head);
if self.dialect() == Dialect::Markdown {
return cellix_doc::figure::image_line(&self.line_text(line)).map(|_| line);
}
let names = |n: &usize| self.line_text(*n).contains("\\includegraphics");
if names(&line) {
return Some(line);
}
let opens = |n: &usize| self.line_text(*n).contains("\\begin{figure");
let closes = |n: &usize| self.line_text(*n).contains("\\end{figure");
let first = (0..=line).rev().find(opens)?;
let last = (line..rope.len_lines()).find(closes)?;
(first..=last).find(names)
}
pub fn image_at_caret(&self) -> bool {
self.image_line_at_caret().is_some()
}
fn rewrite_image(&mut self, line: usize, text: &str) {
let start = self.buffer.rope().line_to_char(line);
let end = start + self.line_text(line).chars().count();
self.buffer
.replace_range(&mut self.selections, start, end, text);
self.selections.set_single(Selection::cursor(start));
}
pub fn set_image_width(&mut self, percent: u32) {
let Some(line) = self.image_line_at_caret() else {
return;
};
let text = self.line_text(line);
let sized = if self.dialect() == Dialect::Latex {
cellix_doc::figure::set_graphics_width(&text, percent)
} else {
cellix_doc::figure::set_width(&text, percent)
};
if let Some(sized) = sized {
self.rewrite_image(line, &sized);
}
}
pub fn replace_image(&mut self, path: &str) {
let Some(line) = self.image_line_at_caret() else {
return;
};
let text = self.line_text(line);
let replaced = if self.dialect() == Dialect::Latex {
cellix_doc::figure::set_graphics_path(&text, path)
} else {
cellix_doc::figure::set_path(&text, path)
};
if let Some(replaced) = replaced {
self.rewrite_image(line, &replaced);
}
}
pub fn remove_image(&mut self) {
let Some(line) = self.image_line_at_caret() else {
return;
};
let rope = self.buffer.rope();
let (mut first, mut last) = (line, line);
if self.dialect() == Dialect::Latex {
let opens = |n: &usize| self.line_text(*n).contains("\\begin{figure");
let closes = |n: &usize| self.line_text(*n).contains("\\end{figure");
if let (Some(a), Some(b)) = (
(0..=line).rev().find(opens),
(line..rope.len_lines()).find(closes),
) {
(first, last) = (a, b);
}
}
let start = rope.line_to_char(first);
let end = rope.line_to_char((last + 1).min(rope.len_lines() - 1));
let end = end.max(start + self.line_text(last).chars().count());
self.buffer
.replace_range(&mut self.selections, start, end, "");
self.selections.set_single(Selection::cursor(start));
}
pub fn insert_diagram(&mut self, language: &str, source: &str) {
let block = format!("```{language}\n{source}\n```");
self.insert_block(&block, language.chars().count() + 4);
}
pub fn insert_text(&mut self, text: &str) {
self.buffer.replace(&mut self.selections, text);
}
pub fn in_formula(&self) -> bool {
self.page
.borrow()
.as_ref()
.is_some_and(|p| p.open.is_some())
}
pub fn insert_math(&mut self, symbol: &Symbol) {
let display = if self.dialect() == Dialect::Latex {
("\\begin{equation}\n", "\n\\end{equation}")
} else {
("$$\n", "\n$$")
};
let (open, close, block) = match (self.in_formula(), symbol.block) {
(true, _) => ("", "", false),
(false, true) => (display.0, display.1, true),
(false, false) => ("$", "$", false),
};
let chars = symbol.text.chars().count();
let caret = symbol.caret.min(chars) + open.chars().count();
let selected = self.selected_text().unwrap_or_default();
let slot = symbol.caret < chars && !selected.is_empty();
let body = if slot {
let at: String = symbol.text.chars().take(symbol.caret).collect();
let rest: String = symbol.text.chars().skip(symbol.caret).collect();
format!("{at}{selected}{rest}")
} else {
symbol.text.to_string()
};
let text = format!("{open}{body}{close}");
if block {
self.insert_block(&text, caret);
return;
}
let primary = self.selections.primary();
let (start, end) = (primary.start(), primary.end());
self.buffer
.replace_range(&mut self.selections, start, end, &text);
self.selections.set_single(Selection::cursor(start + caret));
}
fn tabular_at_caret(&self) -> Option<(usize, usize, usize, usize)> {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.char_to_line(head);
let first = (0..=line)
.rev()
.find(|n| self.line_text(*n).contains("\\begin{tabular}"))?;
let last =
(line..rope.len_lines()).find(|n| self.line_text(*n).contains("\\end{tabular}"))?;
let row = (first..line)
.filter(|n| self.line_text(*n).contains("\\\\"))
.count();
let before: String = self
.line_text(line)
.chars()
.take(head - rope.line_to_char(line))
.collect();
Some((first, last, row, before.matches('&').count()))
}
pub fn table_at_caret(&self) -> Option<(usize, usize, usize, usize)> {
if self.dialect() == Dialect::Latex {
return self.tabular_at_caret();
}
if let Dialect::Data(separator) = self.dialect() {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.char_to_line(head);
let last = (0..rope.len_lines())
.rev()
.find(|n| !self.line_text(*n).is_empty())
.unwrap_or(0);
let before = rope.slice(rope.line_to_char(line)..head).to_string();
return Some((
0,
last,
line,
cellix_doc::table::field_of(&before, separator),
));
}
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = rope.char_to_line(head);
let has_pipe = |n: usize| n < rope.len_lines() && self.line_text(n).contains('|');
if !has_pipe(line) {
return None;
}
let first = (0..=line)
.rev()
.take_while(|n| has_pipe(*n))
.last()
.unwrap_or(line);
let last = (line..rope.len_lines())
.take_while(|n| has_pipe(*n))
.last()
.unwrap_or(line);
let rule = self.line_text(first + 1);
let is_rule = |c: char| "|-: ".contains(c);
if last == first || !rule.contains('-') || !rule.chars().all(is_rule) {
return None;
}
let column = head - rope.line_to_char(line);
let before: String = self.line_text(line).chars().take(column).collect();
let pipes = before.matches('|').count().saturating_sub(1);
let row = match line - first {
0 => 0,
1 => 1,
below => below - 1,
};
Some((first, last, row, pipes))
}
pub fn edit_table(&mut self, edit: TableEdit) {
let Some((first, last, row, column)) = self.table_at_caret() else {
return;
};
let rope = self.buffer.rope();
let start = rope.line_to_char(first);
let end = rope.line_to_char(last) + self.line_text(last).chars().count();
let text = rope.slice(start..end).to_string();
if let Dialect::Data(separator) = self.dialect() {
if let Some((table, at)) =
cellix_doc::table::edit_rows(&text, separator, row, column, edit)
{
self.buffer
.replace_range(&mut self.selections, start, end, &table);
self.selections.set_single(Selection::cursor(start + at));
}
return;
}
if self.dialect() == Dialect::Latex {
let table = cellix_doc::latex::edit_tabular(&text, row, column, edit);
let table = table.unwrap_or_default();
self.buffer
.replace_range(&mut self.selections, start, end, &table);
let row = row + usize::from(edit == TableEdit::RowBelow);
let column = column + usize::from(edit == TableEdit::ColumnRight);
let at = cellix_doc::latex::tabular_cell_offset(&table, row, column);
self.selections.set_single(Selection::cursor(start + at));
return;
}
match cellix_doc::table::apply(&text, row, column, edit) {
Some((table, row, column)) => {
self.buffer
.replace_range(&mut self.selections, start, end, &table);
let at = start + cellix_doc::table::cell_offset(&table, row, column);
self.selections.set_single(Selection::cursor(at));
}
None => {
let total = self.buffer.rope().len_chars();
self.buffer
.replace_range(&mut self.selections, start, (end + 1).min(total), "");
self.selections.set_single(Selection::cursor(start));
}
}
}
pub fn caret_formats(&self) -> (&'static str, Vec<Format>) {
let head = self.selections.primary().head;
let rope = self.buffer.rope();
let line = self.line_text(rope.char_to_line(head));
let hashes = line.chars().take_while(|c| *c == '#').count();
let trimmed = line.trim_start();
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
let mut active = Vec::new();
if trimmed.starts_with("- [") {
active.push(Format::Task);
} else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
active.push(Format::Bullet);
} else if digits > 0 && trimmed[digits..].starts_with(". ") {
active.push(Format::Numbered);
} else if trimmed.starts_with("> ") {
active.push(Format::Quote);
}
let cache = self.page.borrow();
let rows = cache.as_ref().map(|p| p.rows.clone());
let at = rows.iter().flat_map(|r| r.iter()).find_map(|row| {
row.glyphs
.iter()
.find(|g| g.at == Some(head) || g.at.map(|a| a + 1) == Some(head))
});
if let Some(glyph) = at {
if glyph.font.bold && hashes == 0 {
active.push(Format::Bold);
}
if glyph.font.italic {
active.push(Format::Italic);
}
if glyph.tone == Tone::Code {
active.push(Format::Code);
}
if glyph.tone == Tone::Link {
active.push(Format::Link);
}
}
let style = match hashes {
1 => "Heading 1",
2 => "Heading 2",
3 => "Heading 3",
4 => "Heading 4",
5 => "Heading 5",
6 => "Heading 6",
_ => "Text",
};
(style, active)
}
fn fence_lines(&mut self) {
let primary = self.selections.primary();
let rope = self.buffer.rope();
let first = rope.char_to_line(primary.start());
let last = rope.char_to_line(primary.end());
let start = rope.line_to_char(first);
let end = rope.line_to_char(last) + self.line_text(last).chars().count();
let inner = rope.slice(start..end).to_string();
let text = format!("```\n{inner}\n```");
self.buffer
.replace_range(&mut self.selections, start, end, &text);
self.selections.set_single(Selection::cursor(start + 4));
}
pub fn insert_table(&mut self, rows: usize, columns: usize) {
if self.dialect() == Dialect::Latex {
let table = cellix_doc::latex::new_table(rows, columns);
let first = table.find("Column 1").unwrap_or(0);
self.insert_block(&table, first);
return;
}
let table = cellix_doc::table::new_table(rows, columns);
self.insert_block(&table, 2);
let head = self.selections.primary().head;
self.selections.set_single(Selection {
anchor: head,
head: head + "Column 1".len(),
goal: None,
});
}
pub fn select_word_at(&mut self, at: usize) {
let (start, end) = self.buffer.word_at(at);
self.selections.set_single(Selection {
anchor: start,
head: end,
goal: None,
});
}
pub fn select_line_at(&mut self, at: usize) {
let rope = self.buffer.rope();
let line = rope.char_to_line(at);
let start = rope.line_to_char(line);
let end = if line + 1 < rope.len_lines() {
rope.line_to_char(line + 1)
} else {
rope.len_chars()
};
self.selections.set_single(Selection {
anchor: start,
head: end,
goal: None,
});
}
pub fn has_page(&self) -> bool {
self.buffer.path().is_some_and(|p| Dialect::of(p).is_some())
}
pub fn selected_text(&self) -> Option<String> {
let rope = self.buffer.rope();
let parts: Vec<String> = self
.selections
.iter()
.filter(|s| !s.is_empty())
.map(|s| rope.slice(s.start()..s.end()).to_string())
.collect();
(!parts.is_empty()).then(|| parts.join("\n"))
}
pub fn draw(
&self,
grid: &mut Grid,
area: Rect,
theme: &Theme,
focused: bool,
registry: &Registry,
marks: &Marks,
) {
let full = area;
let area = self.text_area(full);
self.view_rows.set(area.h);
self.view_width.set(area.w);
let dark = !theme.is_light();
if self.dark.replace(dark) != dark {
*self.page.borrow_mut() = None;
*self.library.borrow_mut() = Library::default();
}
if self.rendered {
self.draw_page(grid, area, theme, focused, marks);
if self.shows_minimap(full) {
self.draw_minimap(grid, full, theme);
} else {
let track = Rect::new(area.right(), area.y, 1, area.h);
self.draw_scrollbar(grid, track, self.page(area).len(), theme);
}
return;
}
let lines = self.buffer.rope().len_lines();
let track = Rect::new(area.right(), area.y, 1, area.h);
if !self.shows_minimap(full) {
self.draw_scrollbar(grid, track, lines, theme);
}
grid.fill(area, theme.text);
let gutter = self.gutter_width(area.w);
let rope = self.buffer.rope();
let current = rope.char_to_line(self.selections.primary().head);
for row in 0..area.h {
let line = self.top + row as usize;
if line >= rope.len_lines() {
break;
}
let y = area.y + row;
let active = focused && line == current;
let bg = if active { theme.line } else { theme.text.bg };
let text_style = cellix_render::Style { bg, ..theme.text };
grid.fill(Rect::new(area.x, y, area.w, 1), text_style);
let number_fg = if marks.errors.contains(&(line + 1)) {
theme.error
} else if active {
theme.number_active
} else {
theme.gutter.fg
};
let gutter_style = cellix_render::Style {
fg: number_fg,
bg,
..theme.gutter
};
let number = format!("{:>w$} ", line + 1, w = gutter as usize - 1);
grid.put_str(area.x, y, &number, gutter_style, area.x + gutter);
if marks.comments.contains(&(line + 1)) {
let mark = cellix_render::Style {
fg: theme.accent,
bg,
..theme.gutter
};
grid.put_str(area.x, y, "◆", mark, area.x + gutter);
}
let mut column = 0;
let mut text = String::new();
for c in self.line_text(line).chars() {
let width = display_width(c, column);
if column + width > self.left {
if c == '\t' {
text.extend(std::iter::repeat_n(' ', width));
} else {
text.push(c);
}
}
column += width;
}
grid.put_str(area.x + gutter, y, &text, text_style, area.right());
let guide = cellix_render::Style {
fg: theme.guide,
bg,
..theme.text
};
let indent = text.chars().take_while(|c| *c == ' ').count();
for at in (0..indent).step_by(TAB_WIDTH) {
let x = area.x + gutter + at as u16;
if x < area.right() {
grid.put_str(x, y, "│", guide, area.right());
}
}
}
self.draw_highlights(grid, area, gutter, theme, registry);
if self.shows_minimap(full) {
self.draw_minimap(grid, full, theme);
}
for (line, change) in &self.line_changes().1 {
if *line >= self.top && *line < self.top + area.h as usize {
let y = area.y + (*line - self.top) as u16;
self.draw_change(grid, area.x as f32 + gutter as f32 - 0.7, y, *change, theme);
}
}
for (start, end) in self.found.iter().copied() {
let s = Selection {
anchor: start,
head: end,
goal: None,
};
self.draw_range(grid, area, gutter, &s, theme.found);
}
for s in self.selections.iter().filter(|s| !s.is_empty()) {
self.draw_range(grid, area, gutter, s, theme.selection);
}
if focused {
let head = self.selections.primary().head;
let line = rope.char_to_line(head);
let column = self.column_of(head);
if line >= self.top && column >= self.left {
let x = area.x as usize + gutter as usize + column - self.left;
let y = area.y as usize + line - self.top;
if x < area.right() as usize && y < area.bottom() as usize {
grid.cursor = Some((x as u16, y as u16));
}
}
}
}
fn draw_scrollbar(&self, grid: &mut Grid, track: Rect, lines: usize, theme: &Theme) {
grid.fill(track, theme.text);
let rows = track.h as usize;
if lines <= rows {
return;
}
let height = (rows * rows / lines).max(1);
let top = self.top * rows / lines;
let cells = Rect::new(track.x, track.y + top as u16, 1, height as u16);
let thumb = cellix_render::Style {
fg: theme.scrollbar,
painted: true,
..theme.text
};
for y in cells.y..cells.bottom() {
grid.put_str(cells.x, y, "▐", thumb, cells.right());
}
let bounds =
cellix_render::Bounds::cells(cells.x as f32 + 0.3, cells.y as f32, 0.5, cells.h as f32);
grid.panel(bounds, 20, Some(theme.scrollbar), None);
}
fn draw_page_blocks(&self, grid: &mut Grid, area: Rect, rows: &[Row], theme: &Theme) {
let left = (area.x + Self::page_margin(area)) as f32;
let visible = self.top..(self.top + area.h as usize).min(rows.len());
let y_of = |index: usize| area.y as f32 + (index - self.top) as f32;
let mut index = visible.start;
while index < visible.end {
let row = &rows[index];
let group =
|r: &Row| (row.shaded && r.shaded) || (row.table.is_some() && r.table.is_some());
if !group(row) {
index += 1;
continue;
}
let end = (index..visible.end)
.find(|i| !group(&rows[*i]))
.unwrap_or(visible.end);
let (top, height) = (y_of(index), (end - index) as f32);
if row.shaded {
let width = area.right() as f32 - left - 1.0;
let bounds = Bounds::cells(left, top - 0.08, width, height + 0.2);
grid.panel(bounds, 22, Some(theme.code_bg), Some(theme.border));
} else if let Some(table) = &row.table {
let width = table.columns.last().copied().unwrap_or(0.0);
let bounds = Bounds::cells(left, top + 0.02, width, height - 0.04);
if table.header {
let head = Bounds::cells(left, top + 0.02, width, 1.0);
grid.panel(head, 14, Some(theme.code_bg), None);
}
for at in index + 1..end {
let line = Bounds::cells(left, y_of(at), width, 0.0);
grid.hairline(line, theme.popup_border);
}
let inner = table.columns.len().saturating_sub(2);
for x in table.columns.iter().skip(1).take(inner) {
let line = Bounds::cells(left + x, top + 0.02, 0.0, height - 0.04);
grid.hairline(line, theme.popup_border);
}
grid.panel(bounds, 14, None, Some(theme.popup_border));
}
index = end;
}
}
fn draw_page(&self, grid: &mut Grid, area: Rect, theme: &Theme, focused: bool, marks: &Marks) {
grid.fill(area, theme.text);
let rows = self.page(area);
let proportional = !self.measure.in_cells();
let left = area.x + Self::page_margin(area);
let primary = self.selections.primary();
let (caret_row, caret_x) = rendered::locate(&rows, primary.head);
if proportional {
self.draw_page_blocks(grid, area, &rows, theme);
self.draw_figures(grid, area, &rows, theme);
}
let changes = self.line_changes();
for (index, row) in rows.iter().enumerate().skip(self.top) {
let y = area.y + (index - self.top) as u16;
if y >= area.bottom() {
break;
}
let bg = if row.shaded && !proportional {
theme.code_bg
} else {
theme.text.bg
};
if !proportional {
grid.fill(
Rect::new(area.x, y, area.w, 1),
cellix_render::Style { bg, ..theme.text },
);
}
let mut earlier = rows[..index]
.iter()
.rev()
.take_while(|r| r.line == row.line);
let first_row = row.stop && !earlier.any(|r| r.stop);
if first_row && let Some(change) = changes.1.get(&row.line) {
let mark = if row.tall { y + 1 } else { y };
self.draw_change(grid, left as f32 - 1.4, mark, *change, theme);
}
if first_row && marks.comments.contains(&(row.line + 1)) {
let mark = cellix_render::Style {
fg: theme.accent,
bg,
..theme.text
};
grid.put_str(area.x + 1, y, "◆", mark, left);
}
let commented = marks.comments.contains(&(row.line + 1));
if proportional && focused && index == caret_row && row.heading > 0 && !commented {
let dim = cellix_render::Style {
fg: theme.gutter.fg,
..theme.text
};
self.draw_heading_mark(grid, row, (area.x, left, y), dim.fg);
}
if row.rule && proportional {
let line = Bounds::cells(left as f32, y as f32 + 0.5, 60.0, 0.0);
grid.hairline(line, theme.popup_border);
}
if row.quote {
let bar = Bounds::cells(left as f32 + 0.1, y as f32, 0.3, 1.0);
let colour = row.callout.map(|kind| callout_colour(kind, theme));
grid.panel(bar, 0, Some(colour.unwrap_or(theme.popup_border)), None);
}
if !(proportional && row.tall && y + 2 >= area.bottom()) {
self.draw_page_row(grid, row, (left, y), area, bg, theme);
}
}
if focused && primary.is_empty() && caret_row >= self.top {
let y = area.y as usize + caret_row - self.top;
let x = left as f32 + caret_x;
self.draw_preview(grid, area, (x, y as f32), theme);
if y < area.bottom() as usize && x < area.right() as f32 {
let row = &rows[caret_row];
let size = row.glyphs.first().map_or(100, |g| g.font.size) as f32 / 100.0;
grid.set_caret(x, y as u16, (0.62 * size).min(1.6));
if row.tall
&& let Some(caret) = &mut grid.caret
{
caret.y += units(TALL_DROP);
}
}
}
}
fn draw_preview(&self, grid: &mut Grid, area: Rect, (x, y): (f32, f32), theme: &Theme) {
let cache = self.page.borrow();
let Some(page) = cache.as_ref() else {
return;
};
let Some(figure) = page.preview.and_then(|index| page.figures.get(index)) else {
return;
};
const PADDING: (f32, f32) = (1.0, 0.3);
let (width, height) = (
figure.width + PADDING.0 * 2.0,
figure.height + PADDING.1 * 2.0,
);
let left = x.min(area.right() as f32 - width - 1.0).max(area.x as f32);
let top = y + 1.2;
let clip = Bounds::cells(area.x as f32, area.y as f32, area.w as f32, area.h as f32);
grid.popup(
Bounds::cells(left, top, width, height),
22,
theme.popup,
theme.popup_border,
);
grid.paint.push(Paint::Image {
bounds: Bounds::cells(
left + PADDING.0,
top + PADDING.1,
figure.width,
figure.height,
),
clip,
bitmap: figure.bitmap.clone(),
color: theme.text.fg,
});
}
fn draw_heading_mark(
&self,
grid: &mut Grid,
row: &Row,
(x, left, y): (u16, u16, u16),
color: Rgb,
) {
const BASELINE: f32 = 0.35;
const MARK_SIZE: u8 = 85;
let size = row.glyphs.first().map_or(100, |g| g.font.size);
let scale = (size as f32 - MARK_SIZE as f32) / 100.0;
let drop = BASELINE * INTERFACE_SIZE * scale;
let top = y as f32 + if row.tall { TALL_DROP } else { 0.0 } + drop;
let font = Font {
size: MARK_SIZE,
..Font::default()
};
grid.text_run(
(x as f32 + 0.7, top),
left as f32,
"#",
(color, false),
font,
);
}
fn draw_page_row(
&self,
grid: &mut Grid,
row: &Row,
(left, y): (u16, u16),
area: Rect,
bg: Rgb,
theme: &Theme,
) {
let proportional = !self.measure.in_cells();
let origin = left as f32;
let fg_of = |tone: Tone| match tone {
Tone::Text | Tone::Block(None) => theme.text.fg,
Tone::Heading => theme.heading,
Tone::Code => theme.string,
Tone::Block(Some(token)) => theme.token(token),
Tone::Link | Tone::Check(_) => theme.accent,
Tone::Dim | Tone::TableLine => theme.gutter.fg,
Tone::Formula { .. } | Tone::Source | Tone::Marked => theme.text.fg,
Tone::Struck | Tone::Raised | Tone::Lowered => theme.text.fg,
Tone::Footnote => theme.accent,
Tone::Callout(kind) => callout_colour(kind, theme),
Tone::Inserted => theme.inserted(),
Tone::Deleted | Tone::Failed => theme.deleted(),
Tone::Margin(_) => theme.gutter.fg,
};
let selected = |at: Option<usize>| {
at.is_some_and(|at| {
self.selections
.iter()
.any(|s| at >= s.start() && at < s.end())
})
};
let found = |at: Option<usize>| {
at.is_some_and(|at| self.found.iter().any(|f| at >= f.0 && at < f.1))
};
let quoted = |at: Option<usize>| {
at.is_some_and(|at| self.quoted.borrow().iter().any(|q| at >= q.0 && at < q.1))
};
let mark = |at: Option<usize>| {
if selected(at) {
Some(theme.selection)
} else if found(at) {
Some(theme.found)
} else {
quoted(at).then_some(theme.pick)
}
};
let issues = self.current_issues();
let flagged = |at: Option<usize>| {
at.is_some_and(|at| issues.iter().any(|i| at >= i.start && at < i.end))
};
for g in &row.glyphs {
let style = cellix_render::Style {
fg: fg_of(g.tone),
bg: mark(g.at).filter(|_| !proportional).unwrap_or(bg),
bold: g.font.bold,
italic: g.font.italic,
underline: g.underline || flagged(g.at),
painted: proportional,
};
let x = left + g.x as u16;
grid.put_str(x, y, g.ch.encode_utf8(&mut [0; 4]), style, area.right());
}
if !proportional {
return;
}
let text_y = y as f32 + if row.tall { TALL_DROP } else { 0.0 };
let right = area.right() as f32;
for issue in issues {
let inside = |g: &&Glyph| g.at.is_some_and(|at| at >= issue.start && at < issue.end);
let mut word = row.glyphs.iter().filter(inside);
let Some(first) = word.next() else {
continue;
};
let last = word.next_back().unwrap_or(first);
let x = origin + first.x;
let width = last.x + last.advance - first.x;
let colour = if issue.spelling {
theme.deleted()
} else {
theme.accent
};
const DOT: f32 = 0.18;
const STEP: f32 = 0.4;
for dot in 0..(width / STEP).ceil() as usize {
let spot = Bounds::cells(x + dot as f32 * STEP, text_y + 0.9, DOT, 0.07);
grid.panel(spot, 0, Some(colour), None);
}
}
let mut i = 0;
while i < row.glyphs.len() {
let g = row.glyphs[i];
let same = |o: &Glyph| {
o.font == g.font
&& o.tone == g.tone
&& o.underline == g.underline
&& mark(o.at) == mark(g.at)
&& (g.tone != Tone::TableLine || o.x == g.x)
};
let mut end = i + 1;
while end < row.glyphs.len() && same(&row.glyphs[end]) {
let prior = row.glyphs[end - 1];
if (prior.x + prior.advance - row.glyphs[end].x).abs() > 0.01 {
break;
}
end += 1;
}
let run = &row.glyphs[i..end];
let text: String = run.iter().map(|g| g.ch).collect();
let x = origin + g.x;
let width: f32 = run.iter().map(|g| g.advance).sum();
if let Some(color) = mark(g.at) {
let bounds = Bounds::cells(x, text_y + 0.06, width, 0.88);
grid.panel(bounds, 6, Some(color), None);
}
match g.tone {
Tone::TableLine => {}
Tone::Formula { index, .. } => {
if let Some(figure) = self.figures().get(index as usize) {
let line = text_y + 0.5 + 0.35 * cellix_render::INTERFACE_SIZE;
let top = line - figure.baseline;
grid.paint.push(Paint::Image {
bounds: Bounds::cells(x, top, figure.width, figure.height),
clip: Bounds::cells(
area.x as f32,
area.y as f32,
area.w as f32,
area.h as f32,
),
bitmap: figure.bitmap.clone(),
color: theme.text.fg,
});
}
}
Tone::Check(checked) => {
const SIDE: f32 = 0.56;
let cell = self.measure.cell_pixels().unwrap_or((1, 2));
let across = SIDE * cell.1 as f32 / cell.0.max(1) as f32;
let left = x + ((width - across) / 2.0).max(0.0);
let top = y as f32 + (1.0 - SIDE) / 2.0;
let bounds = Bounds::cells(left, top, across, SIDE);
let (fill, edge) = if checked {
(Some(theme.accent), Some(theme.accent))
} else {
(None, Some(theme.gutter.fg))
};
grid.panel(bounds, 22, fill, edge);
if checked {
grid.paint.push(cellix_render::Paint::Icon {
bounds,
icon: Icon::Check,
color: theme.button_text,
});
}
}
_ => {
if matches!(g.tone, Tone::Code | Tone::Source) {
let pill = Bounds::cells(x - 0.2, text_y + 0.14, width + 0.4, 0.72);
grid.panel(pill, 14, Some(theme.code_bg), None);
}
if g.tone == Tone::Marked {
let pill = Bounds::cells(x - 0.1, text_y + 0.1, width + 0.2, 0.8);
grid.panel(pill, 8, Some(theme.highlighter()), None);
}
let shift = match g.tone {
Tone::Raised | Tone::Footnote => -0.2,
Tone::Lowered => 0.2,
Tone::Margin(moved) => moved as f32 / 64.0,
_ => 0.0,
};
let inked = g.underline || g.tone == Tone::Inserted;
let color = (fg_of(g.tone), inked);
grid.text_run((x, text_y + shift), right, &text, color, g.font);
if matches!(g.tone, Tone::Struck | Tone::Deleted) {
let line = Bounds::cells(x, text_y + 0.5, width, 0.0);
grid.hairline(line, fg_of(g.tone));
}
}
}
i = end;
}
}
fn draw_highlights(
&self,
grid: &mut Grid,
area: Rect,
gutter: u16,
theme: &Theme,
registry: &Registry,
) {
let Some(path) = self.buffer.path() else {
return;
};
let revision = self.buffer.revision();
let mut cache = self.spans.borrow_mut();
if !cache.as_ref().is_some_and(|(r, _)| *r == revision) {
let bytes = self.buffer.rope().len_bytes();
if bytes <= MAX_INLINE_HIGHLIGHT {
*cache = Some((revision, registry.highlight(path, &self.buffer.text())));
self.spans_bytes.set(bytes);
} else if self.asked.get() != revision {
self.asked.set(revision);
let worker = self.worker.get_or_init(Worker::default);
worker.request(path, self.buffer.text(), revision);
}
}
let Some((cached, spans)) = cache.as_ref() else {
return;
};
let rope = self.buffer.rope();
let moved = rope.len_bytes() as isize - self.spans_bytes.get() as isize;
let edit = rope.char_to_byte(self.selections.primary().head.min(rope.len_chars()));
let shifted: Vec<Span>;
let spans = if *cached == revision || moved == 0 {
spans
} else {
let shift = |at: usize| {
if at >= edit.saturating_sub(moved.max(0) as usize) {
at.saturating_add_signed(moved).min(rope.len_bytes())
} else {
at
}
};
shifted = spans
.iter()
.map(|s| Span {
start: shift(s.start),
end: shift(s.end),
token: s.token,
})
.collect();
&shifted
};
let last_line = (self.top + area.h as usize).min(rope.len_lines());
let from = rope.line_to_byte(self.top);
let to = rope.line_to_byte(last_line);
let first = spans.partition_point(|s| s.end <= from);
for span in spans[first..].iter().take_while(|s| s.start < to) {
let fg = theme.token(span.token);
let (mut at, end) = (rope.byte_to_char(span.start), rope.byte_to_char(span.end));
while at < end {
let line = rope.char_to_line(at);
let line_end =
end.min(rope.line_to_char(line) + self.line_text(line).chars().count());
if line >= self.top && line < last_line && at < line_end {
let (a, b) = (self.column_of(at), self.column_of(line_end));
let x_from = a.max(self.left) - self.left;
let x_to = b.max(self.left) - self.left;
let rect = Rect::new(
(area.x + gutter).saturating_add(x_from as u16),
area.y + (line - self.top) as u16,
(x_to - x_from) as u16,
1,
);
let clipped = Rect::new(
rect.x,
rect.y,
rect.w.min(area.right().saturating_sub(rect.x)),
1,
);
grid.set_style(clipped, |style| cellix_render::Style { fg, ..style });
}
let next = rope.len_lines().min(line + 1);
if next == line + 1 && next < rope.len_lines() {
at = rope.line_to_char(next);
} else {
break;
}
}
}
}
fn draw_range(&self, grid: &mut Grid, area: Rect, gutter: u16, s: &Selection, bg: Rgb) {
let rope = self.buffer.rope();
let (first, last) = (rope.char_to_line(s.start()), rope.char_to_line(s.end()));
for line in first.max(self.top)..=last {
if line >= self.top + area.h as usize {
break;
}
let start = rope.line_to_char(line);
let from = self.column_of(s.start().max(start));
let line_end = start + self.line_text(line).chars().count();
let to = self.column_of(s.end().min(line_end));
let x_from = (area.x + gutter) as usize + from.saturating_sub(self.left);
let x_to = (area.x + gutter) as usize + to.saturating_sub(self.left);
let y = area.y + (line - self.top) as u16;
let rect = Rect::new(
x_from as u16,
y,
(x_to - x_from.min(x_to)).max(usize::from(line < last)) as u16,
1,
);
grid.set_style(rect, |style| cellix_render::Style { bg, ..style });
}
}
}