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 `` 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() {