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
|
/// 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),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(¤t, 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![
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue