diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 86f1a32..5c62ea9 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -145,6 +145,14 @@ pub enum Message { /// Explicitly close the context menu (Escape key, etc.). Most other /// messages auto-close it via `update()`'s top-of-loop drop logic. HideContextMenu, + /// Push a literal string into the clipboard out-channel. Used by the + /// table spillover popup's copy button and by Cmd+C-on-selected-cell + /// where the value is already in hand at dispatch time. + CopyLiteral(String), + /// Cmd+C while the focused block is a table — copy the current selection + /// (or the spillover cell, if open) as TSV. Dispatched from handle.rs + /// when the keyboard event would otherwise reach a non-cell-edit context. + CopyFocusedTableSelection, /// Escape from cell edit mode. The cell stays selected (highlighted) but /// goes back to the static-text rendering — same as the Excel/Numbers /// gesture for "stop editing this cell". @@ -1454,6 +1462,31 @@ impl EditorState { !tb.is_eval_result && tb.focused_cell.is_some() } + /// True when handle.rs should intercept Cmd+C and route it to the + /// table-cell copy path instead of letting iced's text widget handle it. + /// Conditions: focused block is a table; not currently editing a cell + /// (cell-edit mode delegates to text_input's own copy); and either a + /// selection is non-empty or a spillover popup is open. + pub(crate) fn should_intercept_table_copy(&self) -> bool { + if self.editing.is_some() { return false; } + let Some(block) = self.block_at(self.focused_block) else { return false; }; + let Some(tb) = block.as_any().downcast_ref::() else { return false; }; + !tb.selection.is_empty() || tb.spillover.is_some() + } + + /// Build the clipboard payload from the focused table — selection takes + /// precedence over spillover; spillover provides the single-cell payload + /// when no explicit selection exists. None if neither applies. + fn copy_focused_table_selection(&self) -> Option { + let block = self.block_at(self.focused_block)?; + let tb = block.as_any().downcast_ref::()?; + if !tb.selection.is_empty() { + return tb.copy_selection_payload(); + } + let (r, c) = tb.spillover?; + tb.rows.get(r).and_then(|row| row.get(c)).cloned() + } + pub fn set_lang_from_ext(&mut self, ext: &str) { self.lang = lang_from_extension(ext); } @@ -1478,6 +1511,16 @@ impl EditorState { self.copy_inline_result(bid, line); } } + // Table hover-to-spillover dwell: each table polls its own armed + // timer and opens the popup once the 3s threshold passes. + let block_ids: Vec = self.layout.clone(); + for id in block_ids { + if let Some(block) = self.registry.get_mut(&id) { + if let Some(tb) = block.as_any_mut().downcast_mut::() { + tb.check_hover_spillover(); + } + } + } } /// True if an eval debounce is still pending. Used by handle::render to keep @@ -1486,6 +1529,11 @@ impl EditorState { pub fn has_pending_eval(&self) -> bool { self.eval_dirty || self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press) + || self.layout.iter().any(|id| { + self.registry.get(id) + .and_then(|b| b.as_any().downcast_ref::()) + .is_some_and(|tb| tb.has_pending_hover()) + }) } fn reparse(&mut self) { @@ -3224,6 +3272,14 @@ impl EditorState { Message::HideContextMenu => { self.context_menu = None; } + Message::CopyLiteral(text) => { + self.pending_clipboard = Some(text); + } + Message::CopyFocusedTableSelection => { + if let Some(text) = self.copy_focused_table_selection() { + self.pending_clipboard = Some(text); + } + } Message::DeleteAllBlocks => { // Cmd+Backspace with the whole document selected — wipe to a // single empty text block. Same destructive scope as @@ -3568,23 +3624,26 @@ impl EditorState { } else { let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; let is_focused = bi == self.focused_block; - let actual_lines = tb.content.line_count().max(1); 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 _ = line_h; // text_widget::layout owns the height now + // Length::Shrink lets text_widget::layout publish the + // actual rendered height (visual_rows × line_h + items + // + padding). Computing it here from logical line count + // undercounts when wrap fires, which leaves the next + // block sitting on top of this block's tail. let editor = text_widget::TextEditor::new(&tb.content) .id(block_editor_id(tb.id)) .on_action(move |action| Message::BlockAction(block_idx, action)) .font(syntax::EDITOR_FONT) .size(self.font_size) - .height(Length::Fixed(editor_h)) + .height(Length::Shrink) .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 }) .wrapping(Wrapping::Word) .key_binding(macos_key_binding) @@ -3732,13 +3791,110 @@ impl EditorState { // anywhere outside the menu hit the main content (still alive on // the layer below) AND auto-clear the menu via update()'s top-of-loop // drop logic. - if let Some(menu_state) = &self.context_menu { - iced_widget::stack![inner, self.context_menu_view(menu_state)].into() + let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> = + if let Some(menu_state) = &self.context_menu { + iced_widget::stack![inner, self.context_menu_view(menu_state)].into() + } else { + inner + }; + + // Spillover popup overlay — opens when wrap is off and the user + // clicks a clipped cell. Only one is open at a time per editor. + if let Some(popup) = self.spillover_view() { + iced_widget::stack![with_ctx, popup].into() } else { - inner + with_ctx } } + /// Find the first table block with an open spillover and render its + /// popup. Returns None when no spillover is active. The popup is + /// fixed-positioned at the top-center of the viewport — close enough + /// for now; cell-anchored positioning is a polish pass away. + fn spillover_view(&self) -> Option> { + let p = palette::current(); + let cell_text = self.layout.iter() + .filter_map(|id| self.registry.get(id)) + .find_map(|block| { + let tb = block.as_any().downcast_ref::()?; + let (r, c) = tb.spillover?; + tb.rows.get(r).and_then(|row| row.get(c)).cloned() + })?; + + let copy_btn = iced_widget::button( + iced_widget::text("Copy") + .size(11.0) + .font(syntax::EDITOR_FONT) + ) + .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) + .style(context_menu_item_style) + .on_press(Message::CopyLiteral(cell_text.clone())); + + let close_btn = iced_widget::button( + iced_widget::text("\u{2715}") + .size(11.0) + .font(syntax::EDITOR_FONT) + ) + .padding(Padding { top: 2.0, right: 8.0, bottom: 2.0, left: 8.0 }) + .style(context_menu_item_style) + .on_press(Message::FocusedTableOp(TableMessage::CloseSpillover)); + + let header = iced_widget::row![ + iced_widget::Space::new().width(Length::Fill).height(Length::Shrink), + copy_btn, + close_btn, + ] + .spacing(4.0) + .align_y(iced_wgpu::core::Alignment::Center); + + let body = iced_widget::scrollable( + iced_widget::container( + iced_widget::text(cell_text) + .size(self.font_size) + .font(syntax::EDITOR_FONT) + .color(p.text) + ) + .padding(Padding { top: 6.0, right: 12.0, bottom: 6.0, left: 12.0 }) + .width(Length::Fill) + ) + .height(Length::Fixed(220.0)); + + let popup = iced_widget::container( + iced_widget::column![header, body].spacing(2.0) + ) + .padding(Padding { top: 6.0, right: 6.0, bottom: 6.0, left: 6.0 }) + .width(Length::Fixed(420.0)) + .style(move |_theme: &Theme| iced_widget::container::Style { + background: Some(Background::Color(p.surface0)), + border: Border { + color: p.surface1, + width: 1.0, + radius: 4.0.into(), + }, + text_color: Some(p.text), + shadow: iced_wgpu::core::Shadow::default(), + snap: false, + }); + + // Position via Shrink-sized leading spacers (same trick as the + // context menu overlay) — Fill+padding triggers a viewport-wide + // re-layout on every popup open and steals events. + let popup_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = popup.into(); + let v_spacer = iced_widget::Space::new() + .width(Length::Shrink) + .height(Length::Fixed(60.0)); + let h_spacer = iced_widget::Space::new() + .width(Length::Fixed(120.0)) + .height(Length::Shrink); + Some( + iced_widget::column![ + v_spacer, + iced_widget::row![h_spacer, popup_el] + ] + .into() + ) + } + /// Get (after_line, height) offset pairs for a block's anchored items. fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> { let lh = self.line_height(); @@ -3980,6 +4136,15 @@ impl EditorState { "Select all", Message::TableMsg(block_idx, TableMessage::SelectAll), ), + { + let wrap_on = self.table_block_at(block_idx) + .map(|tb| tb.wrap) + .unwrap_or(true); + item( + if wrap_on { "Wrap: on" } else { "Wrap: off" }, + Message::TableMsg(block_idx, TableMessage::ToggleWrap), + ) + }, item("Delete table", Message::DeleteCurrentTable), ]; diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 2b72cc6..41cb780 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -332,6 +332,17 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::FixUp); consumed.push(ev_idx); } + "c" => { + // Table cell copy: when the focused block is a table + // with a selection (or an open spillover popup), Cmd+C + // copies the cell payload before iced's text widget + // sees the event. Otherwise let it fall through so + // text-block / cell-edit copy keep working. + if handle.state.should_intercept_table_copy() { + messages.push(Message::CopyFocusedTableSelection); + consumed.push(ev_idx); + } + } "e" => { messages.push(Message::SmartEval); consumed.push(ev_idx); diff --git a/viewport/src/module.rs b/viewport/src/module.rs index e3c7690..9125294 100644 --- a/viewport/src/module.rs +++ b/viewport/src/module.rs @@ -32,6 +32,8 @@ pub struct BlockInfo { /// - H1 -> root module (is_root = true) /// - H2 -> close current, start named module /// - HR -> close current, start unnamed module +/// - HR immediately followed by H1/H2 -> absorbed into the heading module +/// so the divider counts as decoration, not its own dangling block. /// - Everything else -> append to current module /// /// Unnamed modules are auto-named from their first `fn` or `let` @@ -49,29 +51,21 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec { for info in infos { match (info.kind_tag, info.heading_level) { - ("heading", 1) => { - if seen_any || !current.block_ids.is_empty() { + ("heading", 1) | ("heading", 2) => { + let absorbed_hr = take_dangling_hr(¤t, infos); + if absorbed_hr.is_none() && (seen_any || !current.block_ids.is_empty()) { finalize_unnamed(&mut current, &mut unnamed_counter, infos); modules.push(current); } - current = Module { - name: normalize_name(&info.heading_text), - heading_block: Some(info.id), - block_ids: vec![info.id], - is_root: true, + let block_ids = match absorbed_hr { + Some(hr_id) => vec![hr_id, info.id], + None => vec![info.id], }; - seen_any = true; - } - ("heading", 2) => { - if seen_any || !current.block_ids.is_empty() { - finalize_unnamed(&mut current, &mut unnamed_counter, infos); - modules.push(current); - } current = Module { name: normalize_name(&info.heading_text), heading_block: Some(info.id), - block_ids: vec![info.id], - is_root: false, + block_ids, + is_root: info.heading_level == 1, }; seen_any = true; } @@ -102,6 +96,19 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec { modules } +/// Returns the HR block id if `current` is a freshly-opened HR-only module +/// (one block, no heading) — meaning the HR immediately precedes the caller's +/// heading and should be folded into it. None otherwise. +fn take_dangling_hr(current: &Module, infos: &[BlockInfo]) -> Option { + if current.block_ids.len() != 1 || current.heading_block.is_some() { + return None; + } + let only_id = current.block_ids[0]; + infos.iter() + .find(|i| i.id == only_id && i.kind_tag == "hr") + .map(|i| i.id) +} + /// If a module has no name, derive one from its first `fn`/`let` declaration. fn finalize_unnamed(module: &mut Module, counter: &mut usize, infos: &[BlockInfo]) { if !module.name.is_empty() { @@ -334,6 +341,61 @@ mod tests { assert!(modules.is_empty()); } + #[test] + fn hr_collapses_into_following_h2() { + let infos = vec![ + info(1, "text", 0, "", "preamble"), + info(2, "hr", 0, "", ""), + info(3, "heading", 2, "Section", ""), + info(4, "text", 0, "", "content"), + ]; + let modules = compute_modules(&infos); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].block_ids, vec![1]); + assert_eq!(modules[1].name, "section"); + assert_eq!(modules[1].block_ids, vec![2, 3, 4]); + } + + #[test] + fn hr_collapses_into_following_h1() { + let infos = vec![ + info(1, "text", 0, "", "preamble"), + info(2, "hr", 0, "", ""), + info(3, "heading", 1, "Title", ""), + ]; + let modules = compute_modules(&infos); + assert_eq!(modules.len(), 2); + assert_eq!(modules[1].name, "title"); + assert!(modules[1].is_root); + assert_eq!(modules[1].block_ids, vec![2, 3]); + } + + #[test] + fn hr_does_not_collapse_when_followed_by_text() { + let infos = vec![ + info(1, "hr", 0, "", ""), + info(2, "text", 0, "", "let total = 1"), + ]; + let modules = compute_modules(&infos); + assert_eq!(modules.len(), 1); + assert_eq!(modules[0].block_ids, vec![1, 2]); + assert_eq!(modules[0].name, "total"); + } + + #[test] + fn consecutive_hrs_only_last_is_absorbed() { + let infos = vec![ + info(1, "hr", 0, "", ""), + info(2, "hr", 0, "", ""), + info(3, "heading", 2, "Section", ""), + ]; + let modules = compute_modules(&infos); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].block_ids, vec![1]); + assert_eq!(modules[1].name, "section"); + assert_eq!(modules[1].block_ids, vec![2, 3]); + } + #[test] fn text_before_any_heading() { let infos = vec![ diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index 6510555..5ae6db4 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -1,4 +1,5 @@ use iced_wgpu::core::widget::Id as WidgetId; +use iced_wgpu::core::text::Wrapping; use iced_wgpu::core::{ Background, Border, Color, Element, Font, Length, Padding, Point, Shadow, Theme, }; @@ -17,6 +18,12 @@ use crate::syntax::EDITOR_FONT; const MIN_COL_WIDTH: f32 = 60.0; const DEFAULT_COL_WIDTH: f32 = 120.0; +/// Sanity cap for double-click auto-fit. Drag past it for explicit override. +const AUTO_FIT_MAX: f32 = 600.0; +/// Approximate monospace glyph advance at the editor's default font size. +/// Used when the renderer's actual font_size isn't available (e.g. during +/// table construction). Tracks `font_size * 0.6` for size 13. +const APPROX_CHAR_W: f32 = 7.8; const CELL_PADDING: Padding = Padding { top: 2.0, right: 8.0, @@ -30,7 +37,6 @@ const PLUS_BUTTON_THICKNESS: f32 = 14.0; /// 4px vertical padding + 2px border ≈ 23. const ROW_HEIGHT_ESTIMATE: f32 = 23.0; const MIN_ROW_HEIGHT: f32 = 18.0; -#[allow(dead_code)] const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0; /// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH — /// the horizontal gap stays at 4 so the resize handle has enough hit area. @@ -107,6 +113,21 @@ pub enum TableMessage { BeginColReorder(usize), BeginRowReorder(usize), EndDrag, + /// Double-click on the column resize handle: fit width to the widest + /// cell content in the column. f32 carries the current font_size so the + /// pixel width tracks zoom level. + AutoFitCol(usize, f32), + /// Toggle the per-table word-wrap mode. Wrap on (default): rows grow to + /// fit; nothing clips. Wrap off: cells clip; spillover popup reveals + /// content on click or 3s hover. + ToggleWrap, + /// Open the spillover popup for a cell. Replaces any existing spillover + /// (only one open per table at a time). Click on a clipped cell when + /// `wrap == false`. + OpenSpillover(usize, usize), + /// Close the active spillover popup. Click outside, ESC, or any cell + /// selection change. + CloseSpillover, /// Click on a column-header sort arrow: cycles that column through /// Neutral → Asc → Desc → Neutral and re-applies the composite sort. CycleSort(usize), @@ -170,6 +191,19 @@ pub struct TableBlock { /// 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)>, + /// When true (default), cell text word-wraps and each row grows to fit + /// the tallest wrapped cell — no content ever clips. When false, content + /// is hard-clipped at the cell bounds and the spillover popup reveals + /// the full text on click or hover. + pub wrap: bool, + /// Currently spilled-over cell, if any. Only one popup at a time per + /// table. Set by click or 3s hover when `wrap == false`. + pub spillover: Option<(usize, usize)>, + /// Cell currently being hovered with the dwell timer running. Captured + /// on CellEnter; consumed by `tick_hover` after the 3s threshold to + /// open the spillover popup. Cleared on any meaningful interaction + /// (click, edit, drag, scroll) so a brief mouseover never triggers. + pub hover_armed: Option<(usize, usize, std::time::Instant)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -194,7 +228,8 @@ impl TableBlock { let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0); let col_widths = col_widths_override.unwrap_or_else(|| { // For eval result tables, size columns to fit content; for markdown - // tables, use a uniform default width. + // tables, fit each column to its header so the new wrap-on default + // gives short headers a tight column and lets long body text wrap. if is_eval_result { (0..col_count) .map(|ci| { @@ -207,7 +242,15 @@ impl TableBlock { }) .collect() } else { - vec![DEFAULT_COL_WIDTH; col_count] + let header = rows.first().map(|r| r.as_slice()).unwrap_or(&[]); + (0..col_count) + .map(|ci| { + let chars = header.get(ci).map(|s| s.chars().count()).unwrap_or(0); + let raw = chars as f32 * APPROX_CHAR_W + + CELL_PADDING.left + CELL_PADDING.right; + raw.max(DEFAULT_COL_WIDTH).min(AUTO_FIT_MAX) + }) + .collect() } }); let row_count = rows.len(); @@ -234,9 +277,84 @@ impl TableBlock { last_cursor_x: 0.0, last_cursor_y: 0.0, sort_priority: Vec::new(), + wrap: true, + spillover: None, + hover_armed: None, } } + /// 3s dwell threshold for hover-to-spillover. Independent of the eval + /// debounce so a slow-typing user doesn't accidentally trigger popups. + pub fn check_hover_spillover(&mut self) -> bool { + if self.wrap { self.hover_armed = None; return false; } + let Some((r, c, started)) = self.hover_armed else { return false; }; + if started.elapsed().as_millis() < 3000 { return false; } + if self.spillover == Some((r, c)) { self.hover_armed = None; return false; } + if r >= self.rows.len() || c >= self.col_widths.len() { + self.hover_armed = None; + return false; + } + self.spillover = Some((r, c)); + self.hover_armed = None; + true + } + + /// Has a hover dwell timer running. Used by `has_pending_eval`-equivalent + /// to keep the vsync loop ticking until the 3s threshold fires. + pub fn has_pending_hover(&self) -> bool { + !self.wrap && self.hover_armed.is_some() + } + + /// Build the canonical clipboard payload for the current selection. + /// Single cell: just the cell text. Multiple cells: TSV — tabs between + /// columns, newlines between rows. Excel/Numbers/Sheets parse this + /// natively when pasted back in. Returns None if nothing is selected. + pub fn copy_selection_payload(&self) -> Option { + if self.selection.is_empty() { + return None; + } + if self.selection.len() == 1 { + let &(r, c) = self.selection.iter().next()?; + return self.rows.get(r).and_then(|row| row.get(c)).cloned(); + } + let r_min = self.selection.iter().map(|&(r, _)| r).min()?; + let r_max = self.selection.iter().map(|&(r, _)| r).max()?; + let c_min = self.selection.iter().map(|&(_, c)| c).min()?; + let c_max = self.selection.iter().map(|&(_, c)| c).max()?; + let mut lines: Vec = Vec::with_capacity(r_max - r_min + 1); + for r in r_min..=r_max { + let mut cells: Vec = Vec::with_capacity(c_max - c_min + 1); + for c in c_min..=c_max { + let cell = if self.selection.contains(&(r, c)) { + self.rows.get(r).and_then(|row| row.get(c)).cloned().unwrap_or_default() + } else { + String::new() + }; + cells.push(cell); + } + lines.push(cells.join("\t")); + } + Some(lines.join("\n")) + } + + /// Resize `col` to fit its widest cell content (header + body) at + /// `font_size`. Width = max char count × monospace char width + horizontal + /// padding, clamped to [MIN_COL_WIDTH, AUTO_FIT_MAX]. The cap keeps a + /// pathological cell from blowing the table off-screen — drag past it + /// for explicit override. + pub fn auto_fit_col(&mut self, col: usize, font_size: f32) { + if col >= self.col_widths.len() { return; } + let max_chars = self.rows.iter() + .filter_map(|r| r.get(col)) + .map(|s| s.chars().count()) + .max() + .unwrap_or(0); + let char_w = font_size * 0.6; + let pad = CELL_PADDING.left + CELL_PADDING.right; + let raw = max_chars as f32 * char_w + pad; + self.col_widths[col] = raw.max(MIN_COL_WIDTH).min(AUTO_FIT_MAX); + } + /// 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 @@ -434,6 +552,19 @@ impl TableBlock { self.focused_cell = Some((row, col)); self.is_active = true; self.table_selected = false; + self.hover_armed = None; + // Wrap-off mode: a click that lands on a different cell + // re-targets the spillover popup. Clicking the same cell + // again toggles it closed so the user can dismiss without + // an explicit ESC. + if !self.wrap { + self.spillover = match self.spillover { + Some(prev) if prev == (row, col) => None, + _ => Some((row, col)), + }; + } else { + self.spillover = None; + } } TableMessage::EditCell(row, col) => { // Double click — selected AND editing. The editor's @@ -442,6 +573,8 @@ impl TableBlock { // next frame. self.focused_cell = Some((row, col)); self.is_active = true; + self.hover_armed = None; + self.spillover = None; } TableMessage::DeleteTable => { // Handled at the editor level — the TableMsg arm in @@ -488,6 +621,20 @@ impl TableBlock { if self.drag_select_start.is_some() { self.apply_drag_to(row, col); } + // Hover-to-spillover dwell: only meaningful with wrap off + // (clipped cells are the ones that benefit). Re-arming on a + // different cell resets the timer; same-cell re-entry leaves + // the existing timer alone so a tiny twitch doesn't restart + // the dwell. + if !self.wrap { + let already_armed = matches!( + self.hover_armed, + Some((r, c, _)) if r == row && c == col + ); + if !already_armed { + self.hover_armed = Some((row, col, std::time::Instant::now())); + } + } } TableMessage::AddRow => { if self.read_only { @@ -653,6 +800,28 @@ impl TableBlock { start_y: self.last_cursor_y, }); } + TableMessage::AutoFitCol(col, font_size) => { + if self.read_only || col >= self.col_widths.len() { + return; + } + self.auto_fit_col(col, font_size); + } + TableMessage::ToggleWrap => { + if self.read_only { return; } + self.wrap = !self.wrap; + // Switching to wrap-on auto-closes any open spillover — + // wrapped content is no longer clipped, so the popup is moot. + if self.wrap { self.spillover = None; } + } + TableMessage::OpenSpillover(row, col) => { + if self.wrap { return; } + if row < self.rows.len() && col < self.col_widths.len() { + self.spillover = Some((row, col)); + } + } + TableMessage::CloseSpillover => { + self.spillover = None; + } TableMessage::CycleSort(col) => { if self.read_only || col >= self.col_widths.len() { return; @@ -1187,6 +1356,7 @@ where for (ri, row) in block.rows.iter().enumerate() { let is_header = ri == 0; + let row_h = compute_row_height(block, ri, row, font_size, row_h); let mut row_cells: Vec> = Vec::new(); if reserve_chrome { @@ -1268,7 +1438,13 @@ where if !read_only { input = input.on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val))); } - input.into() + // Pin the wrapper to row_h so a manually-resized row keeps its + // height when the user double-clicks to enter edit mode — + // text_input alone would snap back to its natural font-size height. + container(input) + .width(Length::Fixed(width)) + .height(Length::Fixed(row_h)) + .into() } else { // Selected-but-not-editing or fully unfocused cell. Renders // as a static text widget inside a container styled to match @@ -1295,7 +1471,8 @@ where let display = text(display_text) .size(font_size) .font(font) - .color(oklab::lighten_for_size(label_color, font_size)); + .color(oklab::lighten_for_size(label_color, font_size)) + .wrapping(if block.wrap { Wrapping::Word } else { Wrapping::None }); let container_style = move |_theme: &Theme| { let ws = palette::widget_surface(); @@ -1342,6 +1519,7 @@ where ) .interaction(Interaction::ResizingHorizontally) .on_press(on_msg(TableMessage::BeginColResize(handle_col))) + .on_double_click(on_msg(TableMessage::AutoFitCol(handle_col, font_size))) .into(); row_cells.push(handle); } else { @@ -1357,6 +1535,26 @@ where let row_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = iced_widget::row(row_cells).spacing(0.0).into(); col_elements.push(row_el); + + // Row resize band — 3px hit area below each row, drags row height. + // Skipped for read_only tables (eval results aren't meant to be + // structurally edited). + if !read_only { + let resize_row = ri; + let band_w: f32 = (if reserve_chrome { ROW_NUMBER_WIDTH } else { 0.0 }) + + block.col_widths.iter().sum::() + + RESIZE_HANDLE_WIDTH * block.col_widths.len() as f32; + let band: Element<'a, Message, Theme, iced_wgpu::Renderer> = + MouseArea::new( + container(text(" ")) + .width(Length::Fixed(band_w)) + .height(Length::Fixed(ROW_RESIZE_HANDLE_HEIGHT)) + ) + .interaction(Interaction::ResizingVertically) + .on_press(on_msg(TableMessage::BeginRowResize(resize_row))) + .into(); + col_elements.push(band); + } } let table: Element<'a, Message, Theme, iced_wgpu::Renderer> = @@ -1430,6 +1628,44 @@ where outer } +/// Wrap-aware row height. Manual override wins. Then if wrap is on, fit to +/// the tallest wrapped cell (chars × char_w / col_width gives an approximate +/// line count). Otherwise fall back to the default single-line row height. +fn compute_row_height( + block: &TableBlock, + ri: usize, + row: &[String], + font_size: f32, + default_h: f32, +) -> f32 { + if let Some(h) = block.row_heights.get(ri).copied().flatten() { + return h; + } + if !block.wrap { + return default_h; + } + let line_h = font_size * 1.3; + let char_w = font_size * 0.6; + let pad_h = CELL_PADDING.top + CELL_PADDING.bottom + 2.0; + let max_lines = row.iter().enumerate() + .map(|(ci, cell)| { + let w = block.col_widths.get(ci).copied().unwrap_or(DEFAULT_COL_WIDTH); + let usable_w = (w - CELL_PADDING.left - CELL_PADDING.right).max(1.0); + let chars_per_line = (usable_w / char_w).floor().max(1.0) as usize; + // Honor explicit \n in addition to wrap-driven breaks. + cell.lines() + .map(|line| { + let n = line.chars().count().max(1); + (n + chars_per_line - 1) / chars_per_line + }) + .sum::() + .max(1) + }) + .max() + .unwrap_or(1); + (max_lines as f32 * line_h + pad_h).max(default_h) +} + fn column_letter(mut idx: usize) -> String { let mut s = String::new(); loop { diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index 57f8c5c..33f27b2 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -996,8 +996,23 @@ where ); let buffer = internal.editor.buffer(); let line_count = buffer.lines.len(); + // Seed widget_y from cosmic-text's internal scroll so the metrics + // we publish reflect ACTUAL on-screen positions, not no-scroll + // positions. Without this seeding, draw renders text at unscrolled + // y while the cursor (computed via cosmic's scroll-aware selection) + // appears to drift — the classic "two sources of truth" violation. + let scroll = buffer.scroll(); + let mut scroll_offset_px: f32 = scroll.vertical; + for i in 0..scroll.line.min(line_count) { + let visual_rows = buffer.lines[i] + .layout_opt() + .map(|v| v.len()) + .unwrap_or(1) + .max(1); + scroll_offset_px += visual_rows as f32 * line_h; + } let mut metrics: Vec = Vec::with_capacity(line_count + 1); - let mut widget_y = 0.0f32; + let mut widget_y = -scroll_offset_px; let mut buffer_y = 0.0f32; let mut next_child = 0; for line in 0..line_count {