tables bugs

This commit is contained in:
jess 2026-04-20 06:27:32 -07:00
parent 1b7377792f
commit 313b25a522
5 changed files with 518 additions and 29 deletions

View File

@ -145,6 +145,14 @@ pub enum Message {
/// Explicitly close the context menu (Escape key, etc.). Most other /// Explicitly close the context menu (Escape key, etc.). Most other
/// messages auto-close it via `update()`'s top-of-loop drop logic. /// messages auto-close it via `update()`'s top-of-loop drop logic.
HideContextMenu, 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 /// Escape from cell edit mode. The cell stays selected (highlighted) but
/// goes back to the static-text rendering — same as the Excel/Numbers /// goes back to the static-text rendering — same as the Excel/Numbers
/// gesture for "stop editing this cell". /// gesture for "stop editing this cell".
@ -1454,6 +1462,31 @@ impl EditorState {
!tb.is_eval_result && tb.focused_cell.is_some() !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::<TableBlock>() 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<String> {
let block = self.block_at(self.focused_block)?;
let tb = block.as_any().downcast_ref::<TableBlock>()?;
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) { pub fn set_lang_from_ext(&mut self, ext: &str) {
self.lang = lang_from_extension(ext); self.lang = lang_from_extension(ext);
} }
@ -1478,6 +1511,16 @@ impl EditorState {
self.copy_inline_result(bid, line); 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<crate::selection::BlockId> = 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::<TableBlock>() {
tb.check_hover_spillover();
}
}
}
} }
/// True if an eval debounce is still pending. Used by handle::render to keep /// 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 { pub fn has_pending_eval(&self) -> bool {
self.eval_dirty self.eval_dirty
|| self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press) || 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::<TableBlock>())
.is_some_and(|tb| tb.has_pending_hover())
})
} }
fn reparse(&mut self) { fn reparse(&mut self) {
@ -3224,6 +3272,14 @@ impl EditorState {
Message::HideContextMenu => { Message::HideContextMenu => {
self.context_menu = None; 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 => { Message::DeleteAllBlocks => {
// Cmd+Backspace with the whole document selected — wipe to a // Cmd+Backspace with the whole document selected — wipe to a
// single empty text block. Same destructive scope as // single empty text block. Same destructive scope as
@ -3568,23 +3624,26 @@ impl EditorState {
} else { } else {
let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; let top_pad = if bi == 0 { title_bar_h } else { 0.0 };
let is_focused = bi == self.focused_block; 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 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 cursor_line = tb.content.cursor().position.line;
let line_count = tb.content.line_count(); let line_count = tb.content.line_count();
let text = tb.content.text(); let text = tb.content.text();
let decors = compute_line_decors(&text); let decors = compute_line_decors(&text);
let this_global_line = global_line; let this_global_line = global_line;
global_line += line_count; 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) let editor = text_widget::TextEditor::new(&tb.content)
.id(block_editor_id(tb.id)) .id(block_editor_id(tb.id))
.on_action(move |action| Message::BlockAction(block_idx, action)) .on_action(move |action| Message::BlockAction(block_idx, action))
.font(syntax::EDITOR_FONT) .font(syntax::EDITOR_FONT)
.size(self.font_size) .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 }) .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 })
.wrapping(Wrapping::Word) .wrapping(Wrapping::Word)
.key_binding(macos_key_binding) .key_binding(macos_key_binding)
@ -3732,13 +3791,110 @@ impl EditorState {
// anywhere outside the menu hit the main content (still alive on // 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 // the layer below) AND auto-clear the menu via update()'s top-of-loop
// drop logic. // drop logic.
if let Some(menu_state) = &self.context_menu { let with_ctx: Element<'_, Message, Theme, iced_wgpu::Renderer> =
iced_widget::stack![inner, self.context_menu_view(menu_state)].into() 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 { } 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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::<TableBlock>()?;
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. /// Get (after_line, height) offset pairs for a block's anchored items.
fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> { fn item_offsets(&self, block_id: crate::selection::BlockId) -> Vec<(usize, f32)> {
let lh = self.line_height(); let lh = self.line_height();
@ -3980,6 +4136,15 @@ impl EditorState {
"Select all", "Select all",
Message::TableMsg(block_idx, TableMessage::SelectAll), 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), item("Delete table", Message::DeleteCurrentTable),
]; ];

View File

@ -332,6 +332,17 @@ pub fn render(handle: &mut ViewportHandle) {
messages.push(Message::FixUp); messages.push(Message::FixUp);
consumed.push(ev_idx); 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" => { "e" => {
messages.push(Message::SmartEval); messages.push(Message::SmartEval);
consumed.push(ev_idx); consumed.push(ev_idx);

View File

@ -32,6 +32,8 @@ pub struct BlockInfo {
/// - H1 -> root module (is_root = true) /// - H1 -> root module (is_root = true)
/// - H2 -> close current, start named module /// - H2 -> close current, start named module
/// - HR -> close current, start unnamed 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 /// - Everything else -> append to current module
/// ///
/// Unnamed modules are auto-named from their first `fn` or `let` /// Unnamed modules are auto-named from their first `fn` or `let`
@ -49,29 +51,21 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec<Module> {
for info in infos { for info in infos {
match (info.kind_tag, info.heading_level) { match (info.kind_tag, info.heading_level) {
("heading", 1) => { ("heading", 1) | ("heading", 2) => {
if seen_any || !current.block_ids.is_empty() { let absorbed_hr = take_dangling_hr(&current, infos);
if absorbed_hr.is_none() && (seen_any || !current.block_ids.is_empty()) {
finalize_unnamed(&mut current, &mut unnamed_counter, infos); finalize_unnamed(&mut current, &mut unnamed_counter, infos);
modules.push(current); modules.push(current);
} }
current = Module { let block_ids = match absorbed_hr {
name: normalize_name(&info.heading_text), Some(hr_id) => vec![hr_id, info.id],
heading_block: Some(info.id), None => vec![info.id],
block_ids: vec![info.id],
is_root: true,
}; };
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 { current = Module {
name: normalize_name(&info.heading_text), name: normalize_name(&info.heading_text),
heading_block: Some(info.id), heading_block: Some(info.id),
block_ids: vec![info.id], block_ids,
is_root: false, is_root: info.heading_level == 1,
}; };
seen_any = true; seen_any = true;
} }
@ -102,6 +96,19 @@ pub fn compute_modules(infos: &[BlockInfo]) -> Vec<Module> {
modules 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<BlockId> {
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. /// 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]) { fn finalize_unnamed(module: &mut Module, counter: &mut usize, infos: &[BlockInfo]) {
if !module.name.is_empty() { if !module.name.is_empty() {
@ -334,6 +341,61 @@ mod tests {
assert!(modules.is_empty()); 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] #[test]
fn text_before_any_heading() { fn text_before_any_heading() {
let infos = vec![ let infos = vec![

View File

@ -1,4 +1,5 @@
use iced_wgpu::core::widget::Id as WidgetId; use iced_wgpu::core::widget::Id as WidgetId;
use iced_wgpu::core::text::Wrapping;
use iced_wgpu::core::{ use iced_wgpu::core::{
Background, Border, Color, Element, Font, Length, Padding, Point, Shadow, Theme, 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 MIN_COL_WIDTH: f32 = 60.0;
const DEFAULT_COL_WIDTH: f32 = 120.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 { const CELL_PADDING: Padding = Padding {
top: 2.0, top: 2.0,
right: 8.0, right: 8.0,
@ -30,7 +37,6 @@ const PLUS_BUTTON_THICKNESS: f32 = 14.0;
/// 4px vertical padding + 2px border ≈ 23. /// 4px vertical padding + 2px border ≈ 23.
const ROW_HEIGHT_ESTIMATE: f32 = 23.0; const ROW_HEIGHT_ESTIMATE: f32 = 23.0;
const MIN_ROW_HEIGHT: f32 = 18.0; const MIN_ROW_HEIGHT: f32 = 18.0;
#[allow(dead_code)]
const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0; const ROW_RESIZE_HANDLE_HEIGHT: f32 = 3.0;
/// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH — /// Vertical gap between rows. Slightly tighter than RESIZE_HANDLE_WIDTH —
/// the horizontal gap stays at 4 so the resize handle has enough hit area. /// the horizontal gap stays at 4 so the resize handle has enough hit area.
@ -107,6 +113,21 @@ pub enum TableMessage {
BeginColReorder(usize), BeginColReorder(usize),
BeginRowReorder(usize), BeginRowReorder(usize),
EndDrag, 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 /// Click on a column-header sort arrow: cycles that column through
/// Neutral → Asc → Desc → Neutral and re-applies the composite sort. /// Neutral → Asc → Desc → Neutral and re-applies the composite sort.
CycleSort(usize), CycleSort(usize),
@ -170,6 +191,19 @@ pub struct TableBlock {
/// the dominant sort key; later entries break ties within groups of /// the dominant sort key; later entries break ties within groups of
/// equal dominant values. Empty = no sort active (visual neutral). /// equal dominant values. Empty = no sort active (visual neutral).
pub sort_priority: Vec<(usize, SortDir)>, 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)] #[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_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let col_widths = col_widths_override.unwrap_or_else(|| { let col_widths = col_widths_override.unwrap_or_else(|| {
// For eval result tables, size columns to fit content; for markdown // 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 { if is_eval_result {
(0..col_count) (0..col_count)
.map(|ci| { .map(|ci| {
@ -207,7 +242,15 @@ impl TableBlock {
}) })
.collect() .collect()
} else { } 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(); let row_count = rows.len();
@ -234,9 +277,84 @@ impl TableBlock {
last_cursor_x: 0.0, last_cursor_x: 0.0,
last_cursor_y: 0.0, last_cursor_y: 0.0,
sort_priority: Vec::new(), 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<String> {
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<String> = Vec::with_capacity(r_max - r_min + 1);
for r in r_min..=r_max {
let mut cells: Vec<String> = 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. /// Cycle the sort state of `col`: Neutral → Asc → Desc → Neutral.
/// First click on a previously-neutral column appends it to the /// First click on a previously-neutral column appends it to the
/// END of the priority list (least dominant). Re-clicking advances /// END of the priority list (least dominant). Re-clicking advances
@ -434,6 +552,19 @@ impl TableBlock {
self.focused_cell = Some((row, col)); self.focused_cell = Some((row, col));
self.is_active = true; self.is_active = true;
self.table_selected = false; 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) => { TableMessage::EditCell(row, col) => {
// Double click — selected AND editing. The editor's // Double click — selected AND editing. The editor's
@ -442,6 +573,8 @@ impl TableBlock {
// next frame. // next frame.
self.focused_cell = Some((row, col)); self.focused_cell = Some((row, col));
self.is_active = true; self.is_active = true;
self.hover_armed = None;
self.spillover = None;
} }
TableMessage::DeleteTable => { TableMessage::DeleteTable => {
// Handled at the editor level — the TableMsg arm in // Handled at the editor level — the TableMsg arm in
@ -488,6 +621,20 @@ impl TableBlock {
if self.drag_select_start.is_some() { if self.drag_select_start.is_some() {
self.apply_drag_to(row, col); 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 => { TableMessage::AddRow => {
if self.read_only { if self.read_only {
@ -653,6 +800,28 @@ impl TableBlock {
start_y: self.last_cursor_y, 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) => { TableMessage::CycleSort(col) => {
if self.read_only || col >= self.col_widths.len() { if self.read_only || col >= self.col_widths.len() {
return; return;
@ -1187,6 +1356,7 @@ where
for (ri, row) in block.rows.iter().enumerate() { for (ri, row) in block.rows.iter().enumerate() {
let is_header = ri == 0; let is_header = ri == 0;
let row_h = compute_row_height(block, ri, row, font_size, row_h);
let mut row_cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new(); let mut row_cells: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
if reserve_chrome { if reserve_chrome {
@ -1268,7 +1438,13 @@ where
if !read_only { if !read_only {
input = input.on_input(move |val| on_msg(TableMessage::CellChanged(r, c, val))); 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 { } else {
// Selected-but-not-editing or fully unfocused cell. Renders // Selected-but-not-editing or fully unfocused cell. Renders
// as a static text widget inside a container styled to match // as a static text widget inside a container styled to match
@ -1295,7 +1471,8 @@ where
let display = text(display_text) let display = text(display_text)
.size(font_size) .size(font_size)
.font(font) .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 container_style = move |_theme: &Theme| {
let ws = palette::widget_surface(); let ws = palette::widget_surface();
@ -1342,6 +1519,7 @@ where
) )
.interaction(Interaction::ResizingHorizontally) .interaction(Interaction::ResizingHorizontally)
.on_press(on_msg(TableMessage::BeginColResize(handle_col))) .on_press(on_msg(TableMessage::BeginColResize(handle_col)))
.on_double_click(on_msg(TableMessage::AutoFitCol(handle_col, font_size)))
.into(); .into();
row_cells.push(handle); row_cells.push(handle);
} else { } else {
@ -1357,6 +1535,26 @@ where
let row_el: Element<'a, Message, Theme, iced_wgpu::Renderer> = let row_el: Element<'a, Message, Theme, iced_wgpu::Renderer> =
iced_widget::row(row_cells).spacing(0.0).into(); iced_widget::row(row_cells).spacing(0.0).into();
col_elements.push(row_el); 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::<f32>()
+ 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> = let table: Element<'a, Message, Theme, iced_wgpu::Renderer> =
@ -1430,6 +1628,44 @@ where
outer 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::<usize>()
.max(1)
})
.max()
.unwrap_or(1);
(max_lines as f32 * line_h + pad_h).max(default_h)
}
fn column_letter(mut idx: usize) -> String { fn column_letter(mut idx: usize) -> String {
let mut s = String::new(); let mut s = String::new();
loop { loop {

View File

@ -996,8 +996,23 @@ where
); );
let buffer = internal.editor.buffer(); let buffer = internal.editor.buffer();
let line_count = buffer.lines.len(); 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<LineMetric> = Vec::with_capacity(line_count + 1); let mut metrics: Vec<LineMetric> = 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 buffer_y = 0.0f32;
let mut next_child = 0; let mut next_child = 0;
for line in 0..line_count { for line in 0..line_count {