tables bugs
This commit is contained in:
parent
1b7377792f
commit
313b25a522
|
|
@ -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::<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) {
|
||||
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<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
|
||||
|
|
@ -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::<TableBlock>())
|
||||
.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.
|
||||
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 {
|
||||
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.
|
||||
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),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Module> {
|
|||
|
||||
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<Module> {
|
|||
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.
|
||||
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![
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
/// 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<Element<'a, Message, Theme, iced_wgpu::Renderer>> = 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::<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> =
|
||||
|
|
@ -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::<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 {
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -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<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 next_child = 0;
|
||||
for line in 0..line_count {
|
||||
|
|
|
|||
Loading…
Reference in New Issue