From 43c16987973623937f7ef0f6206a5668c1322853 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 19 Apr 2026 17:05:04 -0700 Subject: [PATCH] Lasagna hat. Uh... no, it was tables which got some sorting sorted out, fixed the gutter to be drawn with the rest of the compositor's sweep. --- assets/Acord.svg | 21 +- viewport/src/editor.rs | 512 +++++++++++++++++++++++++++--------- viewport/src/handle.rs | 35 +++ viewport/src/table_block.rs | 178 +++++++++++-- viewport/src/text_widget.rs | 449 +++++++++++++++++++++++++------ 5 files changed, 966 insertions(+), 229 deletions(-) diff --git a/assets/Acord.svg b/assets/Acord.svg index 87a17c0..11b4463 100644 --- a/assets/Acord.svg +++ b/assets/Acord.svg @@ -14,10 +14,6 @@ - - - - @@ -30,9 +26,22 @@ + + + + + + + + + + + + + - - + + diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 95799cc..5f74560 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -74,6 +74,23 @@ pub enum Message { InsertTable, ToggleBold, ToggleItalic, + ToggleStrike, + ToggleUnderline, + ToggleBlockquote, + /// Wrap the selection in matching delimiters; if the selection is + /// already wrapped (markers immediately surround it, with or without + /// being included in the selection), unwrap it. + WrapWith(&'static str, &'static str), + /// Insert a paired `[]` / `{}` and place the cursor between them. + /// Only applied to `[` and `{`; quotes/parens deliberately do NOT pair + /// on type — use Cmd+"/'/9 to wrap a selection. + AutoPair(&'static str, &'static str), + /// Cmd+0: incremental scope exit. Each press closes the innermost + /// unclosed pair within the current block; once everything is closed, + /// jumps the cursor past the next outer scope's closing delimiter; once + /// fully at block scope, ensures a "newline sandwich" (cursor on a + /// blank line with one blank line of padding above and below). + FixUp, Evaluate, SmartEval, ZoomIn, @@ -1803,31 +1820,255 @@ impl EditorState { self.rebuild_modules(); } - fn toggle_wrap(&mut self, marker: &str) { - let mlen = marker.len(); - match self.content().selection() { - Some(sel) if sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= mlen * 2 => { - let inner = &sel[mlen..sel.len() - mlen]; + /// Wrap a selection in matching delimiters or unwrap an existing pair. + /// Used by Cmd+B (`**`), Cmd+I (`*`), Cmd+~ (`~~`), Cmd+", etc. + /// + /// Unwrap detection looks at characters IMMEDIATELY outside the + /// selection, not just inside it — so the selection can be the inner + /// text (without markers) and Cmd+B still toggles off. + /// + /// Star-marker parity rule: bold (`**`) unwraps when the surrounding + /// star count on each side is >= 2 AND even (2 → 0, 4 → 2, …). + /// Italic (`*`) unwraps when the count is odd (1, 3, 5 …). This + /// keeps `**bold**` + Cmd+I → wraps to `***bold***` (bold-italic), + /// not destructive; and `***both***` + Cmd+B → `*both*` (strips bold). + fn toggle_wrap(&mut self, open: &str, close: &str) { + let text = self.content().text(); + let cursor = self.content().cursor(); + let pos = byte_offset_for_cursor(&text, &cursor.position); + let (start, end) = match self.selection_byte_range(&text, pos) { + Some(range) => range, + None => { + // No selection: insert paired markers and park cursor between. + let s = format!("{open}{close}"); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(s)), + )); + for _ in 0..close.chars().count() { + self.content_mut().perform(text_widget::Action::Move(Motion::Left)); + } + self.reparse(); + return; + } + }; + + let selected = &text[start..end]; + let before = &text[..start]; + let after = &text[end..]; + + let star_marker = open.chars().all(|c| c == '*') && close == open; + if star_marker { + let mlen = open.len(); + // Sym-strip when markers are inside the selection itself. + if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= mlen * 2 { + let inner = &selected[mlen..selected.len() - mlen]; self.content_mut().perform(text_widget::Action::Edit( text_widget::Edit::Paste(Arc::new(inner.to_string())), )); + self.reparse(); + return; } - Some(sel) => { - let wrapped = format!("{marker}{sel}{marker}"); + let outer = count_trailing_char(before, '*').min(count_leading_char(after, '*')); + let should_unwrap = match mlen { + 2 => outer >= 2 && outer % 2 == 0, // bold + 1 => outer >= 1 && outer % 2 == 1, // italic + _ => outer >= mlen, + }; + if should_unwrap { + self.replace_range(start - mlen, end + mlen, selected); + return; + } + } else { + // Non-star markers: simple symmetric strip. + let olen = open.len(); + let clen = close.len(); + if selected.starts_with(open) && selected.ends_with(close) && selected.len() >= olen + clen { + let inner = &selected[olen..selected.len() - clen]; self.content_mut().perform(text_widget::Action::Edit( - text_widget::Edit::Paste(Arc::new(wrapped)), + text_widget::Edit::Paste(Arc::new(inner.to_string())), )); + self.reparse(); + return; } - None => { - let empty = format!("{marker}{marker}"); - self.content_mut().perform(text_widget::Action::Edit( - text_widget::Edit::Paste(Arc::new(empty)), - )); - for _ in 0..mlen { - self.content_mut().perform(text_widget::Action::Move(Motion::Left)); - } + if before.ends_with(open) && after.starts_with(close) { + self.replace_range(start - olen, end + clen, selected); + return; } } + + // Default: wrap. + let wrapped = format!("{open}{selected}{close}"); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(wrapped)), + )); + self.reparse(); + } + + /// Replace a byte range in the current content with `replacement`. Used + /// by toggle_wrap's unwrap path so we can rewrite text that sits OUTSIDE + /// the selection (the surrounding markers). + fn replace_range(&mut self, start: usize, end: usize, replacement: &str) { + let text = self.content().text(); + if start > end || end > text.len() { return; } + let mut new_text = String::with_capacity(text.len() - (end - start) + replacement.len()); + new_text.push_str(&text[..start]); + new_text.push_str(replacement); + new_text.push_str(&text[end..]); + // Rebuild the content with the new text and place cursor at end of + // replacement so successive toggles continue to operate at the + // same logical spot. + let cursor_byte = start + replacement.len(); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(new_text.clone())), + )); + // Position cursor at byte offset cursor_byte by walking from start. + let target = line_col_for_byte(&new_text, cursor_byte); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + for _ in 0..target.0 { + self.content_mut().perform(text_widget::Action::Move(Motion::Down)); + } + self.content_mut().perform(text_widget::Action::Move(Motion::Home)); + for _ in 0..target.1 { + self.content_mut().perform(text_widget::Action::Move(Motion::Right)); + } + self.reparse(); + } + + /// Compute the byte range of the current selection (start, end) or None + /// when no selection is active. + fn selection_byte_range(&self, text: &str, _cursor_pos: usize) -> Option<(usize, usize)> { + let sel = self.content().selection()?; + // We need the start position; use cursor + selection length to + // bracket. Selection is the text between selection-start and cursor; + // search both directions in the buffer to find a unique location. + // For toggle_wrap's purposes, we use the cursor position as the END + // and walk back by sel.len() to find start. This is correct when + // selection extends backward from the cursor; otherwise we fall + // back to a forward search. + let cursor = self.content().cursor(); + let cursor_byte = byte_offset_for_cursor(text, &cursor.position); + let len = sel.len(); + // Try cursor at end of selection. + if cursor_byte >= len && &text[cursor_byte - len..cursor_byte] == sel.as_str() { + return Some((cursor_byte - len, cursor_byte)); + } + // Try cursor at start of selection. + if cursor_byte + len <= text.len() && &text[cursor_byte..cursor_byte + len] == sel.as_str() { + return Some((cursor_byte, cursor_byte + len)); + } + // Fall back to searching the doc. + text.find(sel.as_str()).map(|s| (s, s + len)) + } + + /// Insert paired delimiters at the cursor and place the caret between + /// them. Used for `[` → `[|]` and `{` → `{|}`. Quotes/parens are + /// deliberately NOT auto-paired. + fn auto_pair(&mut self, open: &str, close: &str) { + let combined = format!("{open}{close}"); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(combined)), + )); + for _ in 0..close.chars().count() { + self.content_mut().perform(text_widget::Action::Move(Motion::Left)); + } + } + + /// Toggle blockquote prefix on the current line(s). With a selection + /// spanning multiple lines, prefix `> ` to each; if every line already + /// has `> `, strip it. + fn toggle_blockquote(&mut self) { + let text = self.content().text(); + let cursor = self.content().cursor(); + let lines: Vec<&str> = text.lines().collect(); + let cur_line = cursor.position.line.min(lines.len().saturating_sub(1)); + // Single-line toggle: simplest meaningful form. + if cur_line >= lines.len() { return; } + let line = lines[cur_line]; + let mut new_lines: Vec = lines.iter().map(|l| l.to_string()).collect(); + if let Some(rest) = line.strip_prefix("> ") { + new_lines[cur_line] = rest.to_string(); + } else { + new_lines[cur_line] = format!("> {line}"); + } + let new_text = new_lines.join("\n"); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(new_text)), + )); + self.reparse(); + } + + /// Cmd+0 catch-all. See `Message::FixUp` for the spec. + fn fix_up(&mut self) { + let text = self.content().text(); + let cursor = self.content().cursor(); + let pos = byte_offset_for_cursor(&text, &cursor.position); + // 1. Innermost unclosed delimiter? Close it. + if let Some(close) = innermost_unclosed_delim(&text[..pos]) { + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(close.to_string())), + )); + return; + } + // 2. Forward to the next outer scope's closing delimiter and step past it. + if let Some(jump_to) = next_closing_delim_after(&text, pos) { + let target = line_col_for_byte(&text, jump_to + 1); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + for _ in 0..target.0 { + self.content_mut().perform(text_widget::Action::Move(Motion::Down)); + } + self.content_mut().perform(text_widget::Action::Move(Motion::Home)); + for _ in 0..target.1 { + self.content_mut().perform(text_widget::Action::Move(Motion::Right)); + } + return; + } + // 3. At block scope: ensure newline sandwich. + self.ensure_newline_sandwich(); + } + + /// Move the cursor onto its own line with exactly one blank line of + /// padding above and below (3 newlines total around the caret), or up + /// to EOF on either side. + fn ensure_newline_sandwich(&mut self) { + let text = self.content().text(); + let cursor = self.content().cursor(); + let pos = byte_offset_for_cursor(&text, &cursor.position); + // Walk back: collapse trailing whitespace/newlines before pos to "\n\n". + let mut left = pos; + while left > 0 { + let c = text[..left].chars().rev().next().unwrap(); + if c == '\n' || c.is_whitespace() { left -= c.len_utf8(); } else { break; } + } + // Walk forward: collapse leading whitespace/newlines after pos to "\n\n". + let mut right = pos; + while right < text.len() { + let c = text[right..].chars().next().unwrap(); + if c == '\n' || c.is_whitespace() { right += c.len_utf8(); } else { break; } + } + let prefix = if left == 0 { String::new() } else { "\n\n".to_string() }; + let suffix = if right == text.len() { String::new() } else { "\n\n".to_string() }; + let middle = "\n"; + let new_text = format!("{}{}{}{}{}", + &text[..left], prefix, middle, suffix, &text[right..]); + let cursor_byte = left + prefix.len() + middle.len(); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + self.content_mut().perform(text_widget::Action::Select(Motion::DocumentEnd)); + self.content_mut().perform(text_widget::Action::Edit( + text_widget::Edit::Paste(Arc::new(new_text.clone())), + )); + let target = line_col_for_byte(&new_text, cursor_byte); + self.content_mut().perform(text_widget::Action::Move(Motion::DocumentStart)); + for _ in 0..target.0 { + self.content_mut().perform(text_widget::Action::Move(Motion::Down)); + } + self.content_mut().perform(text_widget::Action::Move(Motion::Home)); + for _ in 0..target.1 { + self.content_mut().perform(text_widget::Action::Move(Motion::Right)); + } self.reparse(); } @@ -2429,12 +2670,14 @@ impl EditorState { // Intentionally NOT calling run_eval() — see eval_segment_range // for the destruction-class bug this avoids. } - Message::ToggleBold => { - self.toggle_wrap("**"); - } - Message::ToggleItalic => { - self.toggle_wrap("*"); - } + Message::ToggleBold => self.toggle_wrap("**", "**"), + Message::ToggleItalic => self.toggle_wrap("*", "*"), + Message::ToggleStrike => self.toggle_wrap("~~", "~~"), + Message::ToggleUnderline => self.toggle_wrap("", ""), + Message::WrapWith(open, close) => self.toggle_wrap(open, close), + Message::ToggleBlockquote => self.toggle_blockquote(), + Message::AutoPair(open, close) => self.auto_pair(open, close), + Message::FixUp => self.fix_up(), Message::Evaluate => { self.run_eval(); } @@ -3276,6 +3519,8 @@ impl EditorState { if single_text_block { let is_focused = bi == self.focused_block; let cursor_line = tb.content.cursor().position.line; + let text = tb.content.text(); + let decors = compute_line_decors(&text); let anchored_items = self.build_anchored_items(tb.id); let editor = text_widget::TextEditor::new(&tb.content) @@ -3288,10 +3533,17 @@ impl EditorState { .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) + .show_gutter(true) + .gutter_offset(0) + .focused(is_focused) + .cursor_line(if is_focused { Some(cursor_line) } else { None }) + .line_indicator(self.line_indicator) + .gutter_rainbow(self.gutter_rainbow) + .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { - background: Background::Color(Color::TRANSPARENT), + background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, @@ -3313,52 +3565,7 @@ impl EditorState { ) .into(); - let cursorline: Element<'_, Message, Theme, iced_wgpu::Renderer> = - canvas::Canvas::new(Cursorline { - cursor_line: if is_focused { Some(cursor_line) } else { None }, - font_size: self.font_size, - top_pad: title_bar_h, - item_offsets: self.item_offsets(tb.id), - indicator: self.line_indicator, - }) - .width(Length::Fill) - .height(Length::Fill) - .into(); - - let editor_with_cursorline: Element<'_, Message, Theme, iced_wgpu::Renderer> = - iced_widget::stack![cursorline, editor_el] - .width(Length::Fill) - .height(Length::Fill) - .into(); - - let text = tb.content.text(); - let line_count = tb.content.line_count(); - let decors = compute_line_decors(&text); - let gutter = Gutter { - line_count, - global_line_offset: 0, - font_size: self.font_size, - scroll_offset: self.scroll_offset, - cursor_line: if is_focused { Some(cursor_line) } else { None }, - top_pad: title_bar_h, - line_decors: decors, - item_offsets: self.item_offsets(tb.id), - indicator: self.line_indicator, - rainbow: self.gutter_rainbow, - }; - let gw = gutter.gutter_width(); - - let gutter_canvas: Element<'_, Message, Theme, iced_wgpu::Renderer> = - canvas::Canvas::new(gutter) - .width(Length::Fixed(gw)) - .height(Length::Fill) - .into(); - - block_elements.push( - iced_widget::row![gutter_canvas, editor_with_cursorline] - .height(Length::Fill) - .into() - ); + block_elements.push(editor_el); } else { let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; let is_focused = bi == self.focused_block; @@ -3366,6 +3573,13 @@ impl EditorState { let anchored_items = self.build_anchored_items(tb.id); let items_h: f32 = anchored_items.iter().map(|a| a.height).sum(); let editor_h = (actual_lines as f32) * line_h + top_pad + 8.0 + items_h; + let cursor_line = tb.content.cursor().position.line; + let line_count = tb.content.line_count(); + let text = tb.content.text(); + let decors = compute_line_decors(&text); + let this_global_line = global_line; + global_line += line_count; + let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) @@ -3376,10 +3590,17 @@ impl EditorState { .wrapping(Wrapping::Word) .key_binding(macos_key_binding) .anchored(anchored_items) + .show_gutter(true) + .gutter_offset(this_global_line) + .focused(is_focused) + .cursor_line(if is_focused { Some(cursor_line) } else { None }) + .line_indicator(self.line_indicator) + .gutter_rainbow(self.gutter_rainbow) + .line_decors(decors) .style(|_theme, _status| { let p = palette::current(); text_widget::Style { - background: Background::Color(Color::TRANSPARENT), + background: Background::Color(p.base), border: Border::default(), placeholder: p.overlay0, value: p.text, @@ -3401,58 +3622,7 @@ impl EditorState { ) .into(); - let line_count = tb.content.line_count(); - let cursor_line = tb.content.cursor().position.line; - let text = tb.content.text(); - let decors = compute_line_decors(&text); - let gutter = Gutter { - line_count, - global_line_offset: global_line, - font_size: self.font_size, - scroll_offset: 0.0, - cursor_line: if is_focused { Some(cursor_line) } else { None }, - top_pad, - line_decors: decors, - item_offsets: self.item_offsets(tb.id), - indicator: self.line_indicator, - rainbow: self.gutter_rainbow, - }; - global_line += line_count; - let gw = gutter.gutter_width(); - - let cursorline: Element<'_, Message, Theme, iced_wgpu::Renderer> = - canvas::Canvas::new(Cursorline { - cursor_line: if is_focused { Some(cursor_line) } else { None }, - font_size: self.font_size, - top_pad, - item_offsets: self.item_offsets(tb.id), - indicator: self.line_indicator, - }) - .width(Length::Fill) - .height(Length::Fixed(editor_h)) - .into(); - - let editor_with_cursorline: Element<'_, Message, Theme, iced_wgpu::Renderer> = - iced_widget::stack![cursorline, editor_el] - .width(Length::Fill) - .height(Length::Fixed(editor_h)) - .into(); - - let gutter_canvas: Element<'_, Message, Theme, iced_wgpu::Renderer> = - canvas::Canvas::new(gutter) - .width(Length::Fixed(gw)) - .height(Length::Fixed(editor_h)) - .into(); - - block_elements.push( - iced_widget::container( - iced_widget::row![gutter_canvas, editor_with_cursorline] - ) - .width(Length::Fill) - .height(Length::Fixed(editor_h)) - .into() - ); - + block_elements.push(editor_el); } continue; } @@ -4281,8 +4451,12 @@ fn macos_key_binding(key_press: KeyPress) -> Option> { keyboard::Key::Character("-") if modifiers.logo() => { Some(Binding::Custom(Message::ZoomOut)) } - keyboard::Key::Character("0") if modifiers.logo() => { - Some(Binding::Custom(Message::ZoomReset)) + // Cmd+0 lives in handle.rs now (FixUp); Cmd+Shift+0 resets zoom. + keyboard::Key::Character("[") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { + Some(Binding::Custom(Message::AutoPair("[", "]"))) + } + keyboard::Key::Character("{") if !modifiers.logo() && !modifiers.alt() && !modifiers.control() => { + Some(Binding::Custom(Message::AutoPair("{", "}"))) } keyboard::Key::Named(key::Named::Backspace) if modifiers.alt() => { Some(Binding::Sequence(vec![ @@ -4377,6 +4551,96 @@ fn leading_whitespace(line: &str) -> &str { &line[..end] } +/// Count consecutive trailing occurrences of `c` at the end of `s`. +fn count_trailing_char(s: &str, c: char) -> usize { + s.chars().rev().take_while(|&x| x == c).count() +} + +/// Count consecutive leading occurrences of `c` at the start of `s`. +fn count_leading_char(s: &str, c: char) -> usize { + s.chars().take_while(|&x| x == c).count() +} + +/// Convert an iced `Position { line, column }` to a byte offset within +/// `text`. column is interpreted as char count (cosmic-text convention). +fn byte_offset_for_cursor(text: &str, pos: &text_widget::Position) -> usize { + let mut byte = 0usize; + let mut line_idx = 0usize; + for line in text.split_inclusive('\n') { + if line_idx == pos.line { + let col = pos.column; + for (i, _) in line.char_indices().take(col) { + byte += line.as_bytes()[i..i + 1].len(); + } + // Walk col chars precisely. + let mut walked = 0usize; + for (ci, _) in line.char_indices() { + if walked == col { return byte.saturating_sub(line.len()) + ci; } + walked += 1; + } + // col >= line length: clamp to end of line content (before \n). + return byte + line.trim_end_matches('\n').len(); + } + byte += line.len(); + line_idx += 1; + } + text.len() +} + +/// Inverse of `byte_offset_for_cursor`. Returns (line, column). +fn line_col_for_byte(text: &str, byte: usize) -> (usize, usize) { + let mut acc = 0usize; + let mut line_idx = 0usize; + for line in text.split_inclusive('\n') { + if byte < acc + line.len() { + let local = &line[..byte - acc]; + return (line_idx, local.chars().count()); + } + acc += line.len(); + line_idx += 1; + } + let last_line = text.lines().count().saturating_sub(1); + (last_line, text.lines().last().map(|l| l.chars().count()).unwrap_or(0)) +} + +/// Walk `text` left-to-right tracking a delimiter stack. Return the +/// `close` char of the innermost still-open pair, or None if balanced. +/// Pairs tracked: `()`, `[]`, `{}`. (Quotes/HTML are intentionally out +/// of scope — too ambiguous in markdown.) +fn innermost_unclosed_delim(text: &str) -> Option { + let mut stack: Vec = Vec::new(); + for c in text.chars() { + match c { + '(' => stack.push(')'), + '[' => stack.push(']'), + '{' => stack.push('}'), + ')' | ']' | '}' => { + if stack.last() == Some(&c) { stack.pop(); } + } + _ => {} + } + } + stack.last().copied() +} + +/// Find the byte offset of the next outer scope's CLOSING delimiter +/// after `pos`. Used by FixUp to step the cursor out one scope. +fn next_closing_delim_after(text: &str, pos: usize) -> Option { + let mut depth: i32 = 0; + let bytes = text.as_bytes(); + for i in pos..bytes.len() { + match bytes[i] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + if depth == 0 { return Some(i); } + depth -= 1; + } + _ => {} + } + } + None +} + /// Parse a markdown image reference `![alt](src)` from a line. Returns /// `(alt, src)` if found. Only matches if the `![` is the first /// non-whitespace on the line (inline images inside text are not rendered diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index d06bf9b..2b72cc6 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -297,6 +297,41 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::ToggleItalic); consumed.push(ev_idx); } + "u" => { + messages.push(Message::ToggleUnderline); + consumed.push(ev_idx); + } + "x" if modifiers.shift() => { + // Cmd+Shift+X: strikethrough (Cmd+S is reserved for save) + messages.push(Message::ToggleStrike); + consumed.push(ev_idx); + } + "." if modifiers.shift() => { + // Cmd+> : blockquote prefix + messages.push(Message::ToggleBlockquote); + consumed.push(ev_idx); + } + "\"" | "'" => { + // Cmd+" / Cmd+' wrap selection in matching quotes. + let q: &'static str = if c.as_str() == "\"" { "\"" } else { "'" }; + messages.push(Message::WrapWith(q, q)); + consumed.push(ev_idx); + } + "9" | "(" => { + // Cmd+9 (or Cmd+Shift+9 = Cmd+( ) wraps in parens. + messages.push(Message::WrapWith("(", ")")); + consumed.push(ev_idx); + } + "0" if modifiers.shift() => { + // Cmd+Shift+0: reset zoom (moved off Cmd+0 to make + // room for the FixUp catch-all). + messages.push(Message::ZoomReset); + consumed.push(ev_idx); + } + "0" => { + messages.push(Message::FixUp); + consumed.push(ev_idx); + } "e" => { messages.push(Message::SmartEval); consumed.push(ev_idx); diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index bb246e9..6510555 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -107,6 +107,9 @@ pub enum TableMessage { BeginColReorder(usize), BeginRowReorder(usize), EndDrag, + /// Click on a column-header sort arrow: cycles that column through + /// Neutral → Asc → Desc → Neutral and re-applies the composite sort. + CycleSort(usize), } /// Trait-implementing block for tables. Owns all the per-table mutable state @@ -163,8 +166,15 @@ pub struct TableBlock { pub drag_select_baseline: std::collections::HashSet<(usize, usize)>, pub last_cursor_x: f32, pub last_cursor_y: f32, + /// Composite sort. Each entry is `(col_idx, dir)`. The first entry is + /// the dominant sort key; later entries break ties within groups of + /// equal dominant values. Empty = no sort active (visual neutral). + pub sort_priority: Vec<(usize, SortDir)>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDir { Asc, Desc } + impl TableBlock { pub fn new(id: BlockId, rows: Vec>, start_line: usize) -> Self { Self::build(id, rows, start_line, false, None) @@ -223,9 +233,53 @@ impl TableBlock { drag_select_baseline: std::collections::HashSet::new(), last_cursor_x: 0.0, last_cursor_y: 0.0, + sort_priority: Vec::new(), } } + /// Cycle the sort state of `col`: Neutral → Asc → Desc → Neutral. + /// First click on a previously-neutral column appends it to the + /// END of the priority list (least dominant). Re-clicking advances + /// its direction in place; the third click removes it. + pub fn cycle_sort(&mut self, col: usize) { + if let Some(idx) = self.sort_priority.iter().position(|(c, _)| *c == col) { + match self.sort_priority[idx].1 { + SortDir::Asc => self.sort_priority[idx].1 = SortDir::Desc, + SortDir::Desc => { self.sort_priority.remove(idx); } + } + } else { + self.sort_priority.push((col, SortDir::Asc)); + } + self.apply_sort(); + } + + /// Sort state for a column, if any. Used by the header chrome to pick + /// the arrow tint and the optional precedence badge. + pub fn sort_state_for(&self, col: usize) -> Option<(SortDir, usize)> { + self.sort_priority.iter().enumerate().find_map(|(i, (c, d))| { + if *c == col { Some((*d, i)) } else { None } + }) + } + + /// Apply the composite sort to the data rows (everything below row 0, + /// which is the header). Stable across equal keys so existing intra- + /// group order is preserved. + pub fn apply_sort(&mut self) { + if self.sort_priority.is_empty() || self.rows.len() <= 2 { return; } + let priority = self.sort_priority.clone(); + let (_, tail) = self.rows.split_at_mut(1); + tail.sort_by(|a, b| { + for (col, dir) in &priority { + let av = a.get(*col).map(|s| s.as_str()).unwrap_or(""); + let bv = b.get(*col).map(|s| s.as_str()).unwrap_or(""); + let ord = compare_alphanumeric(av, bv); + let ord = if *dir == SortDir::Desc { ord.reverse() } else { ord }; + if ord != std::cmp::Ordering::Equal { return ord; } + } + std::cmp::Ordering::Equal + }); + } + pub fn col_count(&self) -> usize { self.col_widths.len() } @@ -599,6 +653,12 @@ impl TableBlock { start_y: self.last_cursor_y, }); } + TableMessage::CycleSort(col) => { + if self.read_only || col >= self.col_widths.len() { + return; + } + self.cycle_sort(col); + } TableMessage::EndDrag => { self.resize_drag = None; self.row_resize_drag = None; @@ -1053,29 +1113,67 @@ where } else { None }; - let letter_container = container( - text(letter) - .size(chrome_font) + let sort_state = block.sort_state_for(ci); + let arrow_color = |active: bool| -> Color { + if active { p.text } else { Color { a: 0.25, ..p.overlay0 } } + }; + let (up_active, down_active) = match sort_state { + Some((SortDir::Asc, _)) => (true, false), + Some((SortDir::Desc, _)) => (false, true), + None => (false, false), + }; + let arrows: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { + let up_glyph = text("\u{25B2}") + .size(chrome_font * 0.7) .font(EDITOR_FONT) - .color(oklab::lighten_for_size(p.overlay0, chrome_font)) - ) - .width(Length::Fixed(*w)) - .height(Length::Fixed(header_h)) - .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 6.0 }) - .style(move |_theme: &Theme| container::Style { - background: bg_color.map(Background::Color), - border: Border::default(), - text_color: None, - shadow: Shadow::default(), - snap: false, - }); - let letter_cell: Element<'a, Message, Theme, iced_wgpu::Renderer> = if chrome_active { - MouseArea::new(letter_container) - .on_press(on_msg(TableMessage::BeginColReorder(ci))) + .color(arrow_color(up_active)); + let down_glyph = text("\u{25BC}") + .size(chrome_font * 0.7) + .font(EDITOR_FONT) + .color(arrow_color(down_active)); + let stack = iced_widget::row![up_glyph, down_glyph] + .spacing(2.0) + .align_y(iced_wgpu::core::Alignment::Center); + MouseArea::new( + container(stack) + .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 4.0 }) + ) + .on_press(on_msg(TableMessage::CycleSort(ci))) .into() } else { - letter_container.into() + container(text("")).width(Length::Fixed(0.0)).into() }; + + let letter_inner: Element<'a, Message, Theme, iced_wgpu::Renderer> = iced_widget::row![ + MouseArea::new( + container( + text(letter) + .size(chrome_font) + .font(EDITOR_FONT) + .color(oklab::lighten_for_size(p.overlay0, chrome_font)) + ) + .width(Length::Fill) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 6.0 }) + ) + .on_press(on_msg(TableMessage::BeginColReorder(ci))), + arrows, + ] + .spacing(0.0) + .align_y(iced_wgpu::core::Alignment::Center) + .into(); + + let letter_container = container(letter_inner) + .width(Length::Fixed(*w)) + .height(Length::Fixed(header_h)) + .style(move |_theme: &Theme| container::Style { + background: bg_color.map(Background::Color), + border: Border::default(), + text_color: None, + shadow: Shadow::default(), + snap: false, + }); + let letter_cell: Element<'a, Message, Theme, iced_wgpu::Renderer> = + letter_container.into(); header_row_cells.push(letter_cell); header_row_cells.push( container(text("")) @@ -1342,6 +1440,48 @@ fn column_letter(mut idx: usize) -> String { s } +/// Natural alphanumeric comparison: contiguous digit runs compare as +/// integers so `R10` sorts after `R2`. Letter runs compare case-insensitive. +fn compare_alphanumeric(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + let mut ai = a.chars().peekable(); + let mut bi = b.chars().peekable(); + loop { + match (ai.peek(), bi.peek()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(&ca), Some(&cb)) => { + if ca.is_ascii_digit() && cb.is_ascii_digit() { + let mut an = 0u64; + let mut bn = 0u64; + while let Some(&c) = ai.peek() { + if !c.is_ascii_digit() { break; } + an = an.saturating_mul(10) + (c as u64 - b'0' as u64); + ai.next(); + } + while let Some(&c) = bi.peek() { + if !c.is_ascii_digit() { break; } + bn = bn.saturating_mul(10) + (c as u64 - b'0' as u64); + bi.next(); + } + match an.cmp(&bn) { + Ordering::Equal => continue, + non_eq => return non_eq, + } + } else { + let la = ca.to_ascii_lowercase(); + let lb = cb.to_ascii_lowercase(); + match la.cmp(&lb) { + Ordering::Equal => { ai.next(); bi.next(); continue; } + non_eq => return non_eq, + } + } + } + } + } +} + fn plus_button_style(_theme: &Theme, status: button::Status) -> button::Style { let p = palette::current(); let ws = palette::widget_surface(); diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index 9f797b3..57f8c5c 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -74,42 +74,59 @@ pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> { pub element: Element<'a, Message, Theme, iced_wgpu::Renderer>, } -/// Walk the content stream (text lines + anchored items) and map widget-space y to text-space y. -fn stream_y_to_text_y(y: f32, items: &[AnchoredItem<'_, M, T>], line_h: f32, line_count: usize) -> f32 { - let mut text_y = 0.0f32; - let mut widget_y = 0.0f32; - let mut item_idx = 0; +/// Per-logical-line metrics. Stored on State so layout publishes once +/// and every consumer (draw, cursor, hit-test) reads the same data. +#[derive(Clone, Default, Debug)] +pub struct LineMetric { + /// Widget-y of this line's first visual row (relative to text_bounds.y). + pub widget_y: f32, + /// Cosmic-buffer y of this line's first visual row. Buffer y advances + /// by line_h per visual row (wrapped lines occupy multiple rows). + pub buffer_y: f32, + /// Number of visual rows this logical line occupies after wrap. + pub visual_rows: usize, +} - for line in 0..line_count { - if y < widget_y + line_h { - return text_y + (y - widget_y); - } - text_y += line_h; - widget_y += line_h; - - while item_idx < items.len() && items[item_idx].after_line == line { - let ih = items[item_idx].height; - if y < widget_y + ih { - return text_y; - } - widget_y += ih; - item_idx += 1; +/// Translate a cosmic-buffer y (visual rows * line_h) into a widget y. +fn buffer_y_to_widget_y(metrics: &[LineMetric], buffer_y: f32) -> f32 { + if metrics.is_empty() { return buffer_y; } + for i in (0..metrics.len() - 1).rev() { + if metrics[i].buffer_y <= buffer_y { + return metrics[i].widget_y + (buffer_y - metrics[i].buffer_y); } } - text_y + (y - widget_y).max(0.0) + metrics[0].widget_y + (buffer_y - metrics[0].buffer_y) } -/// Cumulative height of anchored items before a given text line. -fn items_height_before_line(items: &[AnchoredItem<'_, M, T>], line: usize) -> f32 { - items.iter() - .filter(|it| it.after_line < line) - .map(|it| it.height) - .sum() +/// Translate a widget y into a cosmic-buffer y. Click/drag positions go +/// through this so cosmic-text receives the right visual row. +fn widget_y_to_buffer_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 { + if metrics.len() < 2 { return widget_y; } + let line_count = metrics.len() - 1; + for i in 0..line_count { + let line_top = metrics[i].widget_y; + let line_bot = line_top + metrics[i].visual_rows as f32 * line_h; + if widget_y < line_bot { + if widget_y < line_top { + return metrics[i].buffer_y; + } + return metrics[i].buffer_y + (widget_y - line_top); + } + let next_top = metrics[i + 1].widget_y; + if widget_y < next_top { + return metrics[i].buffer_y + metrics[i].visual_rows as f32 * line_h; + } + } + let tail = metrics.last().unwrap(); + tail.buffer_y + (widget_y - tail.widget_y).max(0.0) } -/// Total height of all anchored items. -fn total_items_height(items: &[AnchoredItem<'_, M, T>]) -> f32 { - items.iter().map(|it| it.height).sum() +/// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor +/// (full saturation), `1.0` at the far end of the fade window. +const GUTTER_FADE_CYCLES: f32 = 2.5; +fn gutter_fade_t(distance: usize) -> f32 { + let max_d = GUTTER_FADE_CYCLES * crate::syntax::USER_IDENT_PALETTE_SIZE as f32; + (distance as f32 / max_d).min(1.0) } /// Build iced Spans from a LayoutRun's glyphs, grouping consecutive glyphs by color. @@ -230,6 +247,11 @@ pub struct TextEditor< anchored_children: Vec>, gutter_offset: usize, is_focused_block: bool, + show_gutter: bool, + cursor_line: Option, + line_indicator: crate::editor::LineIndicator, + gutter_rainbow: bool, + line_decors: Vec, } impl<'a, Message, Theme> @@ -263,6 +285,11 @@ where anchored_children: Vec::new(), gutter_offset: 0, is_focused_block: false, + show_gutter: false, + cursor_line: None, + line_indicator: crate::editor::LineIndicator::On, + gutter_rainbow: false, + line_decors: Vec::new(), } } @@ -390,6 +417,11 @@ where anchored_children: self.anchored_children, gutter_offset: self.gutter_offset, is_focused_block: self.is_focused_block, + show_gutter: self.show_gutter, + cursor_line: self.cursor_line, + line_indicator: self.line_indicator, + gutter_rainbow: self.gutter_rainbow, + line_decors: self.line_decors, } } @@ -440,6 +472,170 @@ where self } + /// Reserve a left strip for line numbers + decoration stripes. + pub fn show_gutter(mut self, show: bool) -> Self { + self.show_gutter = show; + self + } + + /// Cursor's current line within this block. `None` when not focused. + /// Drives both the cursorline tint and the gutter rainbow center. + pub fn cursor_line(mut self, line: Option) -> Self { + self.cursor_line = line; + self + } + + pub fn line_indicator(mut self, ind: crate::editor::LineIndicator) -> Self { + self.line_indicator = ind; + self + } + + pub fn gutter_rainbow(mut self, on: bool) -> Self { + self.gutter_rainbow = on; + self + } + + pub fn line_decors(mut self, decors: Vec) -> Self { + self.line_decors = decors; + self + } + + /// Width of the gutter strip given a line count. Caller passes the + /// count so this never touches `self.content` (which would deadlock + /// when called from inside layout/draw — those already hold the + /// content's RefCell). + fn gutter_width_for(&self, line_count: usize) -> f32 { + if !self.show_gutter { return 0.0; } + let total = self.gutter_offset + line_count; + let count = if total == 0 { 1 } else { total }; + let digits = (count as f32).log10().floor() as usize + 1; + let font_size: f32 = self.text_size + .map(f32::from) + .unwrap_or(14.0); + let char_width = font_size * 0.6; + (digits.max(2) as f32 * char_width + 16.0).ceil() + } + + fn draw_gutter_line( + &self, + renderer: &mut iced_wgpu::Renderer, + line_i: usize, + bounds: Rectangle, + y: f32, + line_h: f32, + gw: f32, + p: &crate::palette::Palette, + font_size: f32, + ) { + use crate::syntax::LineDecor; + use crate::editor::LineIndicator; + + let gutter_left = bounds.x + self.padding.left; + let gutter_right = gutter_left + gw; + + let decor = self.line_decors.get(line_i).copied().unwrap_or(LineDecor::None); + match decor { + LineDecor::CodeBlock | LineDecor::FenceMarker => { + let bg = Color { a: 0.15, ..p.surface2 }; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(gutter_left, y), + Size::new(gw, line_h), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + Background::Color(bg), + ); + } + LineDecor::Blockquote => { + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(gutter_right - 3.0, y), + Size::new(3.0, line_h), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + Background::Color(p.lavender), + ); + } + LineDecor::HorizontalRule => { + let mid_y = y + line_h / 2.0; + let stroke_color = crate::oklab::lighten_for_size(p.overlay1, 1.0); + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(gutter_left + 4.0, mid_y - 0.5), + Size::new(gw - 8.0, 1.0), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + Background::Color(stroke_color), + ); + } + LineDecor::None => {} + } + + if self.line_indicator == LineIndicator::Off { + return; + } + + let raw_color = if self.gutter_rainbow { + match self.cursor_line { + Some(cl) if line_i == cl => p.text, + Some(cl) if line_i > cl => { + let d = line_i - cl - 1; + let hue = crate::syntax::rainbow_color(d as u32); + crate::oklab::desaturate(hue, gutter_fade_t(d)) + } + Some(cl) => { + let d = cl - line_i - 1; + let hue = crate::oklab::invert_hue(crate::syntax::rainbow_color(d as u32)); + crate::oklab::desaturate(hue, gutter_fade_t(d)) + } + None => p.surface2, + } + } else { + match self.cursor_line { + Some(cl) if line_i == cl => p.text, + _ => p.surface2, + } + }; + + let line_num = self.gutter_offset + line_i; + let label = match (self.line_indicator, self.cursor_line) { + (LineIndicator::Vim, Some(cl)) if line_i != cl => { + let d = if line_i > cl { line_i - cl } else { cl - line_i }; + format!("{d}") + } + _ => format!("{}", line_num + 1), + }; + + renderer.fill_text( + Text { + content: label, + bounds: Size::new(gw, line_h), + size: Pixels(font_size), + line_height: self.line_height, + font: Font::MONOSPACE, + align_x: text::Alignment::Right, + align_y: alignment::Vertical::Top, + shaping: text::Shaping::Basic, + wrapping: Wrapping::None, + }, + Point::new(gutter_right - 8.0, y), + crate::oklab::lighten_for_size(raw_color, font_size), + Rectangle::new( + Point::new(gutter_left, y), + Size::new(gw, line_h), + ), + ); + } + fn input_method<'b>( &self, state: &'b State, @@ -457,7 +653,12 @@ where let bounds = layout.bounds(); let internal = self.content.0.borrow_mut(); - let text_bounds = bounds.shrink(self.padding); + let gw = state.gutter_width.get(); + let effective_padding = Padding { + left: self.padding.left + gw, + ..self.padding + }; + let text_bounds = bounds.shrink(effective_padding); let translation = text_bounds.position() - Point::ORIGIN; let cursor = match internal.editor.selection() { @@ -471,13 +672,9 @@ where self.text_size.unwrap_or_else(|| renderer.default_size()), ); - let adjusted = if self.anchored_children.is_empty() { - cursor - } else { - let line_h: f32 = line_height.into(); - let line = (cursor.y / line_h).round() as usize; - let offset = items_height_before_line(&self.anchored_children, line); - Point::new(cursor.x, cursor.y + offset) + let adjusted = { + let metrics = state.line_metrics.borrow(); + Point::new(cursor.x, buffer_y_to_widget_y(&metrics, cursor.y)) }; let position = adjusted + translation; @@ -630,6 +827,15 @@ pub struct State { /// Paragraphs built during draw() — kept alive so the renderer's Weak refs /// survive until the prepare() phase processes them. retained_paragraphs: RefCell>, + /// Per-logical-line metrics published by `layout()`. Every consumer + /// (draw, cursor caret, click/drag hit-testing, IME) reads from this + /// same Vec — there is no parallel computation. Length = line_count + /// + 1, with the trailing sentinel marking widget/buffer y past the + /// last line. + line_metrics: RefCell>, + /// Gutter strip width, also published by layout. Same single-source + /// rule: events translate click x by reading this, never recomputing. + gutter_width: std::cell::Cell, } #[derive(Debug, Clone)] @@ -707,6 +913,8 @@ where highlighter_settings: self.highlighter_settings.clone(), highlighter_format_address: self.highlighter_format as usize, retained_paragraphs: RefCell::new(Vec::new()), + line_metrics: RefCell::new(Vec::new()), + gutter_width: std::cell::Cell::new(0.0), }) } @@ -756,8 +964,15 @@ where .min_height(self.min_height) .max_height(self.max_height); + let gw = self.gutter_width_for(internal.editor.line_count()); + state.gutter_width.set(gw); + let effective_padding = Padding { + left: self.padding.left + gw, + ..self.padding + }; + internal.editor.update( - limits.shrink(self.padding).max(), + limits.shrink(effective_padding).max(), self.font.unwrap_or_else(|| renderer.default_font()), self.text_size.unwrap_or_else(|| renderer.default_size()), self.line_height, @@ -768,19 +983,33 @@ where let line_h: f32 = self.line_height.to_absolute( self.text_size.unwrap_or_else(|| renderer.default_size()), ).into(); - let extra = total_items_height(&self.anchored_children); - // Compute child layouts at their stream positions + // Single source-of-truth: walk lines + anchored children once and + // build per-line metrics. Each LineMetric records the widget-y + + // buffer-y of that line's first visual row, plus the wrap count. + // Draw, cursor positioning, click/drag — every consumer reads from + // this same Vec so they cannot drift. let mut child_nodes = Vec::with_capacity(self.anchored_children.len()); let child_limits = layout::Limits::new( Size::ZERO, - Size::new(limits.shrink(self.padding).max().width, f32::INFINITY), + Size::new(limits.shrink(effective_padding).max().width, f32::INFINITY), ); - let mut stream_y = 0.0f32; + let buffer = internal.editor.buffer(); + let line_count = buffer.lines.len(); + let mut metrics: Vec = Vec::with_capacity(line_count + 1); + let mut widget_y = 0.0f32; + let mut buffer_y = 0.0f32; let mut next_child = 0; - let line_count = internal.editor.line_count(); for line in 0..line_count { - stream_y += line_h; + let visual_rows = buffer.lines[line] + .layout_opt() + .map(|v| v.len()) + .unwrap_or(1) + .max(1); + metrics.push(LineMetric { widget_y, buffer_y, visual_rows }); + let line_visual_h = visual_rows as f32 * line_h; + widget_y += line_visual_h; + buffer_y += line_visual_h; while next_child < self.anchored_children.len() && self.anchored_children[next_child].after_line == line { @@ -790,14 +1019,17 @@ where renderer, &child_limits, ); - node = node.move_to(Point::new(self.padding.left, self.padding.top + stream_y)); + node = node.move_to(Point::new( + self.padding.left + gw, + self.padding.top + widget_y, + )); child.height = node.bounds().height; - stream_y += child.height; + widget_y += child.height; child_nodes.push(node); next_child += 1; } } - // Remaining children after last line + // Remaining children after last line — they sit below all text. while next_child < self.anchored_children.len() { let child = &mut self.anchored_children[next_child]; let mut node = child.element.as_widget_mut().layout( @@ -805,18 +1037,27 @@ where renderer, &child_limits, ); - node = node.move_to(Point::new(self.padding.left, self.padding.top + stream_y)); + node = node.move_to(Point::new( + self.padding.left + gw, + self.padding.top + widget_y, + )); child.height = node.bounds().height; - stream_y += child.height; + widget_y += child.height; child_nodes.push(node); next_child += 1; } + // Push sentinel AFTER trailing children are placed, so the + // sentinel widget_y reflects the true bottom of the stream. + metrics.push(LineMetric { widget_y, buffer_y, visual_rows: 0 }); + let extra = widget_y - buffer_y; + *state.line_metrics.borrow_mut() = metrics; match self.height { Length::Fill | Length::FillPortion(_) | Length::Fixed(_) => { - let mut size = limits.max(); - size.height += extra; - layout::Node::with_children(size, child_nodes) + // Fixed/Fill: caller specified the height. Honor it as-is — + // anchored items live within that height; trailing space + // would otherwise create phantom gaps below the block. + layout::Node::with_children(limits.max(), child_nodes) } Length::Shrink => { let min_bounds = internal.editor.min_bounds(); @@ -923,13 +1164,13 @@ where match update { Update::Click(click) => { + let gw = state.gutter_width.get(); let action = match click.kind() { mouse::click::Kind::Single => { let mut pos = click.position(); - if !self.anchored_children.is_empty() { - let lc = self.content.0.borrow().editor.line_count(); - pos.y = stream_y_to_text_y(pos.y, &self.anchored_children, line_h, lc); - } + pos.x = (pos.x - gw).max(0.0); + let metrics = state.line_metrics.borrow(); + pos.y = widget_y_to_buffer_y(&metrics, pos.y, line_h); Action::Click(pos) } mouse::click::Kind::Double => Action::SelectWord, @@ -944,11 +1185,11 @@ where shell.capture_event(); } Update::Drag(position) => { + let gw = state.gutter_width.get(); let mut pos = position; - if !self.anchored_children.is_empty() { - let lc = self.content.0.borrow().editor.line_count(); - pos.y = stream_y_to_text_y(pos.y, &self.anchored_children, line_h, lc); - } + pos.x = (pos.x - gw).max(0.0); + let metrics = state.line_metrics.borrow(); + pos.y = widget_y_to_buffer_y(&metrics, pos.y, line_h); shell.publish(on_edit(Action::Drag(pos))); } Update::Release => { @@ -1176,11 +1417,33 @@ where style.background, ); - let text_bounds = bounds.shrink(self.padding); + let gw = state.gutter_width.get(); + let effective_padding = Padding { + left: self.padding.left + gw, + ..self.padding + }; + let text_bounds = bounds.shrink(effective_padding); let text_size = self.text_size.unwrap_or_else(|| renderer.default_size()); let line_h: f32 = self.line_height.to_absolute(text_size).into(); + // Gutter background — only the strip below top_pad so the title-bar + // / traffic-light area doesn't get painted. + if self.show_gutter && self.padding.top < bounds.height { + let p = crate::palette::current(); + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(bounds.x, bounds.y + self.padding.top), + Size::new(gw + self.padding.left, bounds.height - self.padding.top), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + Background::Color(p.crust), + ); + } + if internal.editor.is_empty() { if let Some(placeholder) = self.placeholder.clone() { renderer.fill_text( @@ -1200,19 +1463,13 @@ where text_bounds, ); } - } else if self.anchored_children.is_empty() { - renderer.fill_editor( - &internal.editor, - text_bounds.position(), - style.value, - text_bounds, - ); } else { // Sequential stream: text lines (layer 0) interleaved with - // anchored children (layer 1) in one continuous pass. + // anchored children (layer 1) in one continuous pass. Cursorline + // tint and gutter line numbers are drawn on the SAME y as the + // line's paragraph — single source of truth. let buffer = internal.editor.buffer(); let line_count = buffer.lines.len(); - let mut stream_y = 0.0f32; let mut child_idx = 0; let children_layouts: Vec<_> = layout.children().collect(); @@ -1222,6 +1479,7 @@ where { let mut paras = state.retained_paragraphs.borrow_mut(); paras.clear(); + let metrics = state.line_metrics.borrow(); for i in 0..line_count { let line_text = buffer.lines[i].text(); let glyphs: Vec = @@ -1229,9 +1487,10 @@ where .map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect()) .unwrap_or_default(); let spans = build_color_spans(line_text, &glyphs, f32::from(text_size)); + let visual_rows = metrics.get(i).map(|m| m.visual_rows).unwrap_or(1).max(1); paras.push(iced_graphics::text::Paragraph::with_spans(Text { content: spans.as_slice(), - bounds: Size::new(text_bounds.width, line_h), + bounds: Size::new(text_bounds.width, visual_rows as f32 * line_h), size: text_size, line_height: self.line_height, font, @@ -1243,9 +1502,45 @@ where } } + let p = crate::palette::current(); + let font_size_px: f32 = f32::from(text_size); let paras = state.retained_paragraphs.borrow(); + let metrics = state.line_metrics.borrow(); for line_i in 0..line_count { - let y = text_bounds.y + line_i as f32 * line_h + stream_y; + // Pull line position from the Vec layout published. + let m = match metrics.get(line_i) { + Some(m) => m, + None => continue, + }; + let y = text_bounds.y + m.widget_y; + let row_h = m.visual_rows as f32 * line_h; + + // Cursorline tint — full editor width (incl. gutter), + // covers all visual rows of the wrapped logical line. + if self.is_focused_block + && self.cursor_line == Some(line_i) + && self.line_indicator != crate::editor::LineIndicator::Off + { + let band = Color { a: 0.06, ..p.text }; + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + Point::new(bounds.x, y), + Size::new(bounds.width, row_h), + ), + border: Border::default(), + ..renderer::Quad::default() + }, + Background::Color(band), + ); + } + + // Gutter — line decor stripe + line number, in the strip + // between bounds.x and text_bounds.x. + if self.show_gutter { + self.draw_gutter_line(renderer, line_i, bounds, y, line_h, gw, &p, font_size_px); + } + renderer.fill_paragraph( ¶s[line_i], Point::new(text_bounds.x, y), @@ -1268,7 +1563,6 @@ where _viewport, ); } - stream_y += self.anchored_children[child_idx].height; child_idx += 1; } } @@ -1293,14 +1587,9 @@ where let translation = text_bounds.position() - Point::ORIGIN; if let Some(focus) = state.focus.as_ref() { + let metrics_for_cursor = state.line_metrics.borrow(); let adjust_y = |pos: Point| -> Point { - if self.anchored_children.is_empty() { - pos - } else { - let line = (pos.y / line_h).round() as usize; - let offset = items_height_before_line(&self.anchored_children, line); - Point::new(pos.x, pos.y + offset) - } + Point::new(pos.x, buffer_y_to_widget_y(&metrics_for_cursor, pos.y)) }; match internal.editor.selection() {