Free mode: Z index positioning of blocks.

This commit is contained in:
jess 2026-05-10 00:18:23 -07:00
parent 4e3dfd8e85
commit 32c66cd330
2 changed files with 302 additions and 57 deletions

View File

@ -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<usize>,
}
/// 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<FreeNodeId, FreePlacement>,
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<PromoteDragState>,
pub promote_snapshot_pushed: bool,
pub resize_drag: Option<ResizeDragState>,
pub active_free: Option<FreeNodeId>,
}
#[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<crate::selection::BlockId> {
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::<HeadingBlock>() || any.is::<HrBlock>();
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::<HeadingBlock>() || any.is::<HrBlock>() {
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::<TableBlock>() {
let w: f32 = tab.col_widths.iter().sum::<f32>() + 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::<HeadingBlock>() {
total_h += self.font_size * 2.4 + 8.0;
} else if any.is::<HrBlock>() {
total_h += 24.0;
} else if let Some(tb) = any.downcast_ref::<TextBlock>() {
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<usize>) {
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<crate::selection::BlockId> = 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::<SyntaxHighlighter>(
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::<SyntaxHighlighter>(
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::<TextBlock>() {
return Some(self.build_text_block_widget(tb, bi, 0, 0.0));
}
if let Some(tab) = any.downcast_ref::<TableBlock>() {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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<Element<'_, Message, Theme, iced_wgpu::Renderer>> = 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<Element<'_, Message, Theme, iced_wgpu::Renderer>> {
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,

View File

@ -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());