From 32c66cd330bc8a1dfd69f48b80d0d4bff86fb403 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 10 May 2026 00:18:23 -0700 Subject: [PATCH] Free mode: Z index positioning of blocks. --- viewport/src/editor.rs | 356 ++++++++++++++++++++++++++++++++++------- viewport/src/handle.rs | 3 + 2 files changed, 302 insertions(+), 57 deletions(-) diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 70ee96d..12c62ce 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Instant; @@ -179,6 +179,10 @@ pub enum Message { ImagePromotePress { block_id: crate::selection::BlockId, after_line: usize, src: String }, /// mouse released after a block or image promote press. PromoteRelease, + /// mouse pressed on a resize band of a free-layer object. + ResizePress { node_id: FreeNodeId, horiz: bool, vert: bool }, + /// mouse released after a resize press. + ResizeRelease, ToggleMenu(MenuCategory), CloseMenu, Shell(ShellAction), @@ -348,6 +352,16 @@ pub struct PromoteDragState { pub fallback_table_idx: Option, } +/// pending drag state for resizing a free-layer object from an edge band. +#[derive(Debug, Clone)] +pub struct ResizeDragState { + pub node_id: FreeNodeId, + pub start_cursor: Point, + pub start_size: (f32, f32), + pub axes: (bool, bool), + pub snapshot_pushed: bool, +} + impl LayerItem<'_> { fn element_height(&self, line_h: f32, font_size: f32) -> f32 { match self { @@ -370,6 +384,8 @@ struct UndoSnapshot { text: String, cursor_line: usize, cursor_col: usize, + free_placements: HashMap, + frozen_doc_size: Option<(f32, f32)>, } #[derive(PartialEq, Eq, Clone, Copy)] @@ -478,6 +494,9 @@ pub struct EditorState { pub frozen_doc_size: Option<(f32, f32)>, pub viewport_size: (f32, f32), pub promote_drag: Option, + pub promote_snapshot_pushed: bool, + pub resize_drag: Option, + pub active_free: Option, } #[derive(Debug, Clone)] @@ -598,6 +617,9 @@ impl EditorState { frozen_doc_size: None, viewport_size: (0.0, 0.0), promote_drag: None, + promote_snapshot_pushed: false, + resize_drag: None, + active_free: None, } } @@ -614,6 +636,11 @@ impl EditorState { if dist_sq < threshold_sq && !was_escalated { return false; } + if !was_escalated && !self.promote_snapshot_pushed { + self.push_undo_snapshot(); + self.redo_stack.clear(); + self.promote_snapshot_pushed = true; + } if let Some(pd) = self.promote_drag.as_mut() { pd.escalated = true; } let placement = FreePlacement { layer, @@ -629,15 +656,111 @@ impl EditorState { true } + /// applies the current cursor delta to any active resize drag. + pub fn tick_resize_drag(&mut self) -> bool { + let (node_id, dx, dy, start_size, axes, snapshot_pushed) = { + let Some(rd) = self.resize_drag.as_ref() else { return false }; + let dx = self.cursor_pos.x - rd.start_cursor.x; + let dy = self.cursor_pos.y - rd.start_cursor.y; + (rd.node_id.clone(), dx, dy, rd.start_size, rd.axes, rd.snapshot_pushed) + }; + let new_w = if axes.0 { (start_size.0 + dx).max(60.0) } else { start_size.0 }; + let new_h = if axes.1 { (start_size.1 + dy).max(40.0) } else { start_size.1 }; + let changed = self + .free_placements + .get(&node_id) + .map(|p| (p.w - new_w).abs() > 0.5 || (p.h - new_h).abs() > 0.5) + .unwrap_or(false); + if !changed { return false; } + if !snapshot_pushed { + self.push_undo_snapshot(); + self.redo_stack.clear(); + if let Some(rd) = self.resize_drag.as_mut() { rd.snapshot_pushed = true; } + } + if let Some(p) = self.free_placements.get_mut(&node_id) { + p.w = new_w; + p.h = new_h; + } + true + } + + /// returns the layout block ids belonging to the module anchored by the given block. + pub fn module_block_ids(&self, anchor: crate::selection::BlockId) -> Vec { + let Some(start) = self.layout.iter().position(|id| *id == anchor) else { return Vec::new() }; + let anchor_block = match self.registry.get(&anchor) { + Some(b) => b, + None => return Vec::new(), + }; + let any = anchor_block.as_any(); + let is_module_anchor = any.is::() || any.is::(); + if !is_module_anchor { + return vec![anchor]; + } + let mut ids = vec![anchor]; + for &next_id in &self.layout[start + 1..] { + let Some(block) = self.registry.get(&next_id) else { break }; + let any = block.as_any(); + if any.is::() || any.is::() { + break; + } + ids.push(next_id); + } + ids + } + + /// returns the layer-0 natural size for a given free-layer node. + pub fn natural_size_for_node(&self, node_id: &FreeNodeId) -> (f32, f32) { + let viewport_w = if self.viewport_size.0 > 0.0 { self.viewport_size.0 } else { 800.0 }; + let default_w = viewport_w.min(700.0).max(300.0); + match node_id { + FreeNodeId::Block(block_id) => { + let module = self.module_block_ids(*block_id); + let mut total_h: f32 = 0.0; + let mut max_w: f32 = default_w; + for id in &module { + let Some(block) = self.registry.get(id) else { continue }; + let any = block.as_any(); + if let Some(tab) = any.downcast_ref::() { + let w: f32 = tab.col_widths.iter().sum::() + 60.0; + let h = (tab.rows.len().max(1) as f32) * (self.font_size * 1.6) + 16.0; + total_h += h; + if w > max_w { max_w = w; } + } else if any.is::() { + total_h += self.font_size * 2.4 + 8.0; + } else if any.is::() { + total_h += 24.0; + } else if let Some(tb) = any.downcast_ref::() { + let lc = tb.content.line_count() as f32; + total_h += lc * self.font_size * 1.3 + 16.0; + } + } + (max_w, total_h.max(80.0)) + } + FreeNodeId::Image(block_id, after_line, _src) => { + let img = self.computed_images.iter().find(|i| + i.anchor.block_id == *block_id && i.anchor.after_line == *after_line + ); + match img { + Some(img) => (img.display_height * 4.0 / 3.0, img.display_height), + None => (default_w, 200.0), + } + } + _ => (default_w, 200.0), + } + } + /// arms a drag promotion for any free-layer node in live mode. fn start_promote(&mut self, node_id: FreeNodeId, fallback_table_idx: Option) { if !matches!(self.render_mode, RenderMode::Live) { return; } let existing = self.free_placements.get(&node_id).copied(); - let default_size = (360.0, 240.0); let (origin, size, layer) = match existing { Some(p) => ((p.x, p.y), (p.w, p.h), p.layer), - None => ((self.cursor_pos.x, self.cursor_pos.y), default_size, 1), + None => { + let s = self.natural_size_for_node(&node_id); + ((self.cursor_pos.x, self.cursor_pos.y), s, 1) + } }; + self.active_free = Some(node_id.clone()); self.promote_drag = Some(PromoteDragState { node_id, start_cursor: self.cursor_pos, @@ -647,6 +770,7 @@ impl EditorState { escalated: false, fallback_table_idx, }); + self.promote_snapshot_pushed = false; } /// arms a corner-drag promotion for the table at a layout index. @@ -2448,6 +2572,8 @@ impl EditorState { text: self.get_clean_text(), cursor_line: cursor.position.line, cursor_col: cursor.position.column, + free_placements: self.free_placements.clone(), + frozen_doc_size: self.frozen_doc_size, } } @@ -2497,6 +2623,8 @@ impl EditorState { position: Position { line: snap.cursor_line, column: snap.cursor_col }, selection: None, }); + self.free_placements = snap.free_placements.clone(); + self.frozen_doc_size = snap.frozen_doc_size; } fn perform_undo(&mut self) { @@ -3447,6 +3575,21 @@ impl EditorState { Message::PromoteRelease => { self.promote_drag = None; } + Message::ResizePress { node_id, horiz, vert } => { + if !matches!(self.render_mode, RenderMode::Live) { return; } + if self.active_free.as_ref() != Some(&node_id) { return; } + let Some(p) = self.free_placements.get(&node_id).copied() else { return }; + self.resize_drag = Some(ResizeDragState { + node_id, + start_cursor: self.cursor_pos, + start_size: (p.w, p.h), + axes: (horiz, vert), + snapshot_pushed: false, + }); + } + Message::ResizeRelease => { + self.resize_drag = None; + } } } @@ -3644,9 +3787,19 @@ impl EditorState { let lang_for_block = self.lang_str(); + let hidden_blocks: HashSet = self + .free_placements + .keys() + .filter_map(|id| match id { + FreeNodeId::Block(bid) => Some(*bid), + _ => None, + }) + .flat_map(|bid| self.module_block_ids(bid)) + .collect(); + let mut global_line = 0usize; for (bi, &block_id) in self.layout.iter().enumerate() { - if self.free_placements.contains_key(&FreeNodeId::Block(block_id)) { + if hidden_blocks.contains(&block_id) { continue; } let block = self.registry.get(&block_id).unwrap(); @@ -3708,59 +3861,12 @@ impl EditorState { block_elements.push(editor_el); } else { let top_pad = if bi == 0 { title_bar_h } else { 0.0 }; - let is_focused = bi == self.focused_block; - let anchored_items = self.build_anchored_items(tb.id); - 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 - - 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::Shrink) - .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 }) - .wrapping(Wrapping::Word) - .key_binding(macos_key_binding) - .anchored(anchored_items) - .show_gutter(true) - .gutter_offset(this_global_line) - .focused(is_focused) - .cursor_line(if is_focused { Some(cursor_line) } else { None }) - .line_indicator(self.line_indicator) - .gutter_rainbow(self.gutter_rainbow) - .line_decors(decors) - .style(|_theme, _status| { - let p = palette::current(); - text_widget::Style { - background: Background::Color(p.base), - border: Border::default(), - placeholder: p.overlay0, - value: p.text, - selection: Color { a: 0.4, ..p.blue }, - } - }); - - let settings = SyntaxSettings { - lang: lang_for_block.clone(), - source: tb.content.text(), - }; - let editor_el: Element<'_, Message, Theme, iced_wgpu::Renderer> = editor - .highlight_with::( - settings, - |highlight, _theme| Format { - color: Some(syntax::highlight_color(highlight.kind)), - font: syntax::highlight_font(highlight.kind), - }, - ) - .into(); - - block_elements.push(editor_el); + let _ = lang_for_block; // build_text_block_widget reads lang_str directly + block_elements.push(self.build_text_block_widget(tb, block_idx, this_global_line, top_pad)); } continue; } @@ -4125,6 +4231,64 @@ impl EditorState { anchored } + /// builds the text-editor widget for a text block at a layout index. + fn build_text_block_widget<'a>( + &'a self, + tb: &'a TextBlock, + block_idx: usize, + this_global_line: usize, + top_pad: f32, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> { + let is_focused = block_idx == self.focused_block; + let anchored_items = self.build_anchored_items(tb.id); + let cursor_line = tb.content.cursor().position.line; + let text = tb.content.text(); + let decors = compute_line_decors(&text); + let lang_for_block = self.lang_str(); + + 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::Shrink) + .padding(Padding { top: top_pad, right: 8.0, bottom: 4.0, left: 8.0 }) + .wrapping(Wrapping::Word) + .key_binding(macos_key_binding) + .anchored(anchored_items) + .show_gutter(true) + .gutter_offset(this_global_line) + .focused(is_focused) + .cursor_line(if is_focused { Some(cursor_line) } else { None }) + .line_indicator(self.line_indicator) + .gutter_rainbow(self.gutter_rainbow) + .line_decors(decors) + .style(|_theme, _status| { + let p = palette::current(); + text_widget::Style { + background: Background::Color(p.base), + border: Border::default(), + placeholder: p.overlay0, + value: p.text, + selection: Color { a: 0.4, ..p.blue }, + } + }); + + let settings = SyntaxSettings { + lang: lang_for_block, + source: tb.content.text(), + }; + editor + .highlight_with::( + settings, + |highlight, _theme| Format { + color: Some(syntax::highlight_color(highlight.kind)), + font: syntax::highlight_font(highlight.kind), + }, + ) + .into() + } + /// builds a column of cell rows from a computed table. fn build_computed_table_widget<'a>( &self, @@ -4206,6 +4370,9 @@ impl EditorState { let bi = self.layout.iter().position(|id| *id == block_id)?; let block = self.registry.get(&block_id)?; let any = block.as_any(); + if let Some(tb) = any.downcast_ref::() { + return Some(self.build_text_block_widget(tb, bi, 0, 0.0)); + } if let Some(tab) = any.downcast_ref::() { let editing_cell = match self.editing.as_ref() { Some(path) if path.block_id == tab.id => match &path.inner { @@ -4246,6 +4413,51 @@ impl EditorState { None } + /// builds a column of overlay widgets for every block in a module. + fn build_free_module_widget( + &self, + anchor: crate::selection::BlockId, + ) -> Option> { + let ids = self.module_block_ids(anchor); + if ids.is_empty() { return None; } + if ids.len() == 1 { + return self.build_free_block_widget(ids[0]); + } + let parts: Vec> = ids + .iter() + .filter_map(|id| self.build_free_block_widget(*id)) + .collect(); + if parts.is_empty() { return None; } + Some(iced_widget::column(parts).into()) + } + + /// builds a single resize band aligned to one or two edges of a placement. + fn build_resize_band( + &self, + node_id: FreeNodeId, + horiz: bool, + vert: bool, + align_x: alignment::Horizontal, + align_y: alignment::Vertical, + zone_w: Length, + zone_h: Length, + ) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { + let zone = iced_widget::container( + iced_widget::Space::new().width(Length::Fill).height(Length::Fill) + ) + .width(zone_w) + .height(zone_h); + let area = MouseArea::new(zone) + .on_press(Message::ResizePress { node_id, horiz, vert }) + .on_release(Message::ResizeRelease); + iced_widget::container(area) + .width(Length::Fill) + .height(Length::Fill) + .align_x(align_x) + .align_y(align_y) + .into() + } + /// stacks free-placed objects at absolute positions over the editor body. fn build_free_overlay(&self) -> Option> { if self.free_placements.is_empty() { @@ -4263,7 +4475,7 @@ impl EditorState { .find(|ct| ct.anchor.block_id == *block_id && ct.anchor.after_line == *after_line) .map(|ct| self.build_computed_table_widget(ct)), FreeNodeId::Block(block_id) => self - .build_free_block_widget(*block_id) + .build_free_module_widget(*block_id) .map(|el| self.wrap_block_with_promote(el, *block_id)), FreeNodeId::Image(block_id, after_line, src) => self .computed_images @@ -4297,14 +4509,44 @@ impl EditorState { }; let Some(inner) = inner_opt else { continue }; - let sized = iced_widget::container(iced_widget::scrollable(inner)) + let is_active = self.active_free.as_ref() == Some(id); + let band = (self.font_size * 1.3) * 0.5; + let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if is_active { + let right = self.build_resize_band( + id.clone(), + true, false, + alignment::Horizontal::Right, alignment::Vertical::Top, + Length::Fixed(band), Length::Fixed((placement.h - band).max(0.0)), + ); + let bottom = self.build_resize_band( + id.clone(), + false, true, + alignment::Horizontal::Left, alignment::Vertical::Bottom, + Length::Fixed((placement.w - band).max(0.0)), Length::Fixed(band), + ); + let corner = self.build_resize_band( + id.clone(), + true, true, + alignment::Horizontal::Right, alignment::Vertical::Bottom, + Length::Fixed(band), Length::Fixed(band), + ); + iced_widget::stack![iced_widget::scrollable(inner), right, bottom, corner].into() + } else { + iced_widget::scrollable(inner).into() + }; + let sized = iced_widget::container(body) .width(Length::Fixed(placement.w)) .height(Length::Fixed(placement.h)) - .style(|_theme: &Theme| { + .style(move |_theme: &Theme| { let p = palette::current(); + let (border_color, border_w) = if is_active { + (p.blue, 2.0) + } else { + (p.surface1, 1.0) + }; container::Style { background: Some(Background::Color(p.base)), - border: Border { color: p.surface1, width: 1.0, radius: 4.0.into() }, + border: Border { color: border_color, width: border_w, radius: 4.0.into() }, text_color: None, shadow: Shadow::default(), snap: false, diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 6af8cb5..e1f4552 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -753,6 +753,9 @@ pub fn render(handle: &mut ViewportHandle) { if handle.state.tick_promote_drag() { handle.needs_redraw = true; } + if handle.state.tick_resize_drag() { + handle.needs_redraw = true; + } } handle.state.sync_focused_cell(focused_id.as_ref());