From 39eab9a4c4104765eee950cd667a26968e05f759 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 28 May 2026 18:42:39 +0300 Subject: [PATCH] feat(viewport): draggable Model-tile dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiled Model layouts now show the inner edges between panes as 2-px dividers that act as resize grips: cursor on a divider → resize cue, press + drag moves the edge (with every tile sharing that edge updating together), release runs a collapse pass that absorbs any tile that fell below the viewcube-comfort minimum into the neighbour with the longest shared contact edge — drag a divider all the way across to remove a pane. - Scene grows `model_tile_edges`, `hit_model_tile_edge`, `move_model_tile_edge`, `collapse_small_model_tiles`. Edges are derived from the existing `model_tiles` rects (no extra storage). - App carries a `tile_drag` Option; `ViewportLeftPress` starts it ahead of tile activation / picking, `ViewportMove` drives the edge, `ViewportLeftRelease` collapses + clears. - `SelectionCanvas` draws the dividers and switches the cursor to `Resizing{Horizontally, Vertically}` over them. --- src/app/mod.rs | 18 ++- src/app/update.rs | 90 +++++++++++- src/app/view.rs | 8 ++ src/scene/mod.rs | 353 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui/overlay.rs | 83 +++++++++++ 5 files changed, 550 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 3437e1b2a..1e239d03c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,7 +11,18 @@ mod view; use document::DocumentTab; use crate::modules::ModuleEvent; -use crate::scene::CubeRegion; +use crate::scene::{CubeRegion, TileEdgeOrient}; + +/// In-flight drag of a Model-tile inner divider. `last_applied` is the +/// most recent normalized coordinate handed to `move_model_tile_edge` — +/// the next `ViewportMove` uses it as the `old_coord` argument so the +/// drag follows the same edge across frames even after sub-pixel +/// adjustments from the clamp inside `move_model_tile_edge`. +#[derive(Clone, Debug)] +pub struct TileDrag { + pub orient: TileEdgeOrient, + pub last_applied: f32, +} use crate::snap::Snapper; use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar}; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -73,6 +84,10 @@ pub(super) struct OpenCADStudio { /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, + /// Active drag of a Model-tile divider. Set on press over an inner + /// edge, updated on move, cleared on release (which also runs the + /// collapse pass). + tile_drag: Option, /// Show the UCS icon in the bottom-left corner of model space (UCSICON). show_ucs_icon: bool, /// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE). @@ -747,6 +762,7 @@ impl OpenCADStudio { show_grid: false, dyn_input: true, awaiting_vports: false, + tile_drag: None, show_ucs_icon: true, show_viewcube: true, show_properties: true, diff --git a/src/app/update.rs b/src/app/update.rs index 20e30ff44..cd059edfa 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -6,7 +6,9 @@ use super::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; use crate::scene::grip::{find_hit_grip, find_hit_grip_paper, GripEdit}; use crate::scene::object::GripApply; -use crate::scene::{self, hover_id, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX}; +use crate::scene::{ + self, hover_id, Scene, TileEdgeOrient, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, +}; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; @@ -15,6 +17,20 @@ use iced::window; use iced::{mouse, Task}; const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; +/// Pixel distance from a Model-tile inner divider that still registers as +/// a resize grip on the press. +const TILE_EDGE_HIT_PX: f32 = 4.0; +/// Normalized minimum tile size before `collapse_small_model_tiles` merges +/// it into a neighbour. Sized to comfortably contain the ViewCube + its +/// padding so a tile that's still GPU-rendered always has room to show +/// the gizmo. +fn tile_min_norm(canvas_w: f32, canvas_h: f32) -> (f32, f32) { + let px = VIEWCUBE_DRAW_PX + 2.0 * VIEWCUBE_PAD + 16.0; + ( + (px / canvas_w.max(1.0)).min(0.95), + (px / canvas_h.max(1.0)).min(0.95), + ) +} fn format_size(bytes: u64) -> String { const KB: f64 = 1024.0; @@ -1646,6 +1662,35 @@ impl OpenCADStudio { Message::ViewportMove(p) => { let i = self.active_tab; + + // A Model-tile divider drag short-circuits the rest of + // the move handling — the cursor neither pans the camera + // nor updates snap state while the user is resizing + // panes. + if let Some(drag) = self.tile_drag.as_mut() { + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + if vw > 1.0 && vh > 1.0 { + let new_coord = match drag.orient { + TileEdgeOrient::Vertical => (p.x / vw).clamp(0.0, 1.0), + TileEdgeOrient::Horizontal => (p.y / vh).clamp(0.0, 1.0), + }; + let (min_w, min_h) = tile_min_norm(vw, vh); + let min_size = match drag.orient { + TileEdgeOrient::Vertical => min_w, + TileEdgeOrient::Horizontal => min_h, + }; + self.tabs[i].scene.move_model_tile_edge( + drag.orient, + drag.last_applied, + new_coord, + min_size, + ); + drag.last_applied = new_coord; + self.tabs[i].scene.camera_generation += 1; + } + return Task::none(); + } + // Keep the ViewCube hover in sync as the cursor leaves the // hit-area overlay and moves over the rest of the viewport. // `hover_id` returns None outside the cube box, which clears @@ -2084,6 +2129,33 @@ impl OpenCADStudio { } } + // Tiled Model layout: an inner divider near the cursor + // becomes a resize grip. Start the drag and short-circuit + // (no pick / select / camera swap). Released on + // `ViewportLeftRelease`. + if self.tabs[i].active_cmd.is_none() + && self.tabs[i].scene.current_layout == "Model" + && vw > 1.0 + && vh > 1.0 + { + let canvas_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vw, + height: vh, + }; + if let Some(edge) = self.tabs[i] + .scene + .hit_model_tile_edge(p, canvas_bounds, TILE_EDGE_HIT_PX) + { + self.tile_drag = Some(crate::app::TileDrag { + orient: edge.orient, + last_applied: edge.coord, + }); + return Task::none(); + } + } + // Tiled Model layout: clicking a non-active tile activates // it (swapping in its camera) instead of selecting / drawing. if self.tabs[i].active_cmd.is_none() && vw > 1.0 && vh > 1.0 { @@ -2165,6 +2237,22 @@ impl OpenCADStudio { Message::ViewportLeftRelease => { let i = self.active_tab; + + // End an in-flight tile-divider drag. Any tile that fell + // below the minimum (viewcube fits comfortably) gets + // absorbed into its longest-contact neighbour, so the + // user can drag a divider all the way to one side to + // remove a pane. + if self.tile_drag.take().is_some() { + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let (min_w, min_h) = tile_min_norm(vw, vh); + self.tabs[i] + .scene + .collapse_small_model_tiles(min_w, min_h); + self.tabs[i].scene.camera_generation += 1; + return Task::none(); + } + let (p, is_click, is_down) = { let sel = self.tabs[i].scene.selection.borrow(); let p = match sel.last_move_pos { diff --git a/src/app/view.rs b/src/app/view.rs index 928001d0f..7455ce33e 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -371,6 +371,13 @@ impl OpenCADStudio { vec![] }; + // Model-space tile dividers (none in paper / single-tile layouts). + let tile_edges = if !is_paper { + tab.scene.model_tile_edges() + } else { + vec![] + }; + overlay::selection_overlay( sel, snap_info, @@ -380,6 +387,7 @@ impl OpenCADStudio { ost_points, tab.last_cursor_screen, !is_paper && self.show_viewcube, + tile_edges, ) }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bf69df43d..4949a6f0a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -439,6 +439,39 @@ pub(crate) struct ModelTile { pub(crate) camera: Camera, } +/// Tolerance for matching two normalized tile coordinates as "the same" +/// edge — drag math leaves small floating-point residue. +const TILE_EPS: f32 = 1e-4; + +#[derive(Copy, Clone, Debug)] +pub enum TileEdgeOrient { + Vertical, + Horizontal, +} + +/// One inner divider between Model tiles, exposed by [`Scene::model_tile_edges`]. +/// `coord` is the fixed axis (x for vertical, y for horizontal) and `span` +/// is the perpendicular extent over which the divider actually separates +/// tiles. All values are normalized to the 0..1 canvas. +#[derive(Clone, Debug)] +pub struct TileEdge { + pub orient: TileEdgeOrient, + pub coord: f32, + pub span: (f32, f32), +} + +#[derive(Copy, Clone, Debug)] +enum ContactSide { + Left, + Right, + Top, + Bottom, +} + +fn overlap_len(a: (f32, f32), b: (f32, f32)) -> f32 { + (a.1.min(b.1) - a.0.max(b.0)).max(0.0) +} + pub struct Scene { pub camera: Rc>, /// Model-space tiled viewport layout. One full-window tile by default; @@ -4463,6 +4496,326 @@ impl Scene { // ── Paper-space coordinate helpers ─────────────────────────────────── + /// Discover the inner divider edges between Model tiles. Each entry + /// is one draggable horizontal or vertical edge, with the span along + /// the perpendicular axis that the edge actually covers (the union + /// of touching tiles' extents). Coordinates are in normalized 0..1 + /// canvas space. Returns an empty list outside Model or for a + /// single-tile layout. + pub fn model_tile_edges(&self) -> Vec { + if self.current_layout != "Model" { + return vec![]; + } + let tiles = self.model_tiles.borrow(); + if tiles.len() < 2 { + return vec![]; + } + let mut out = Vec::new(); + // Collect candidate inner x's: any tile edge that's strictly + // inside (0, 1). Dedup by epsilon. + let mut xs: Vec = Vec::new(); + let mut ys: Vec = Vec::new(); + for t in tiles.iter() { + for x in [t.rect.x, t.rect.x + t.rect.width] { + if x > TILE_EPS && x < 1.0 - TILE_EPS { + xs.push(x); + } + } + for y in [t.rect.y, t.rect.y + t.rect.height] { + if y > TILE_EPS && y < 1.0 - TILE_EPS { + ys.push(y); + } + } + } + xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + xs.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + ys.dedup_by(|a, b| (*a - *b).abs() < TILE_EPS); + for x in xs { + let mut y0 = f32::INFINITY; + let mut y1 = f32::NEG_INFINITY; + let mut has_left = false; + let mut has_right = false; + for t in tiles.iter() { + if ((t.rect.x + t.rect.width) - x).abs() < TILE_EPS { + has_left = true; + y0 = y0.min(t.rect.y); + y1 = y1.max(t.rect.y + t.rect.height); + } + if (t.rect.x - x).abs() < TILE_EPS { + has_right = true; + y0 = y0.min(t.rect.y); + y1 = y1.max(t.rect.y + t.rect.height); + } + } + if has_left && has_right && y1 > y0 { + out.push(TileEdge { + orient: TileEdgeOrient::Vertical, + coord: x, + span: (y0, y1), + }); + } + } + for y in ys { + let mut x0 = f32::INFINITY; + let mut x1 = f32::NEG_INFINITY; + let mut has_top = false; + let mut has_bot = false; + for t in tiles.iter() { + if ((t.rect.y + t.rect.height) - y).abs() < TILE_EPS { + has_top = true; + x0 = x0.min(t.rect.x); + x1 = x1.max(t.rect.x + t.rect.width); + } + if (t.rect.y - y).abs() < TILE_EPS { + has_bot = true; + x0 = x0.min(t.rect.x); + x1 = x1.max(t.rect.x + t.rect.width); + } + } + if has_top && has_bot && x1 > x0 { + out.push(TileEdge { + orient: TileEdgeOrient::Horizontal, + coord: y, + span: (x0, x1), + }); + } + } + out + } + + /// Hit-test the inner Model-tile dividers against a pixel cursor. + /// `bounds` is the canvas pixel rectangle (origin = canvas top-left). + /// Returns the closest edge within `tolerance_px` pixels of the cursor + /// along its perpendicular axis, also requiring the cursor to lie + /// within the edge's actual span. + pub fn hit_model_tile_edge( + &self, + cursor_px: iced::Point, + bounds: iced::Rectangle, + tolerance_px: f32, + ) -> Option { + if bounds.width <= 0.0 || bounds.height <= 0.0 { + return None; + } + let cx = cursor_px.x - bounds.x; + let cy = cursor_px.y - bounds.y; + let nx = cx / bounds.width; + let ny = cy / bounds.height; + let tol_nx = tolerance_px / bounds.width; + let tol_ny = tolerance_px / bounds.height; + let mut best: Option<(f32, TileEdge)> = None; + for e in self.model_tile_edges() { + let (dist, in_span) = match e.orient { + TileEdgeOrient::Vertical => ( + (e.coord - nx).abs() / tol_nx.max(1e-9), + ny >= e.span.0 && ny <= e.span.1, + ), + TileEdgeOrient::Horizontal => ( + (e.coord - ny).abs() / tol_ny.max(1e-9), + nx >= e.span.0 && nx <= e.span.1, + ), + }; + if in_span && dist <= 1.0 { + if best.as_ref().map_or(true, |(d, _)| dist < *d) { + best = Some((dist, e)); + } + } + } + best.map(|(_, e)| e) + } + + /// Move the inner divider edge from `old_coord` to `new_coord`, both + /// in normalized 0..1 space. Adjusts every tile that touches the + /// edge on either side. `min_size` clamps the new coordinate so no + /// tile crosses into a non-positive width / height — caller still + /// runs `collapse_small_model_tiles` afterward to merge any tiles + /// that fell below the viewcube threshold. + pub fn move_model_tile_edge( + &self, + orient: TileEdgeOrient, + old_coord: f32, + new_coord: f32, + min_size: f32, + ) { + let mut tiles = self.model_tiles.borrow_mut(); + // Clamp the new coordinate so no tile becomes ≤ 0 wide / tall. + // (Sub-`min_size` results are still allowed — the collapse pass + // handles those.) + let new_coord = match orient { + TileEdgeOrient::Vertical => { + let mut lo = 0.0_f32; + let mut hi = 1.0_f32; + for t in tiles.iter() { + if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS { + lo = lo.max(t.rect.x + min_size * 0.25); + } + if (t.rect.x - old_coord).abs() < TILE_EPS { + hi = hi.min(t.rect.x + t.rect.width - min_size * 0.25); + } + } + new_coord.clamp(lo, hi) + } + TileEdgeOrient::Horizontal => { + let mut lo = 0.0_f32; + let mut hi = 1.0_f32; + for t in tiles.iter() { + if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS { + lo = lo.max(t.rect.y + min_size * 0.25); + } + if (t.rect.y - old_coord).abs() < TILE_EPS { + hi = hi.min(t.rect.y + t.rect.height - min_size * 0.25); + } + } + new_coord.clamp(lo, hi) + } + }; + for t in tiles.iter_mut() { + match orient { + TileEdgeOrient::Vertical => { + if ((t.rect.x + t.rect.width) - old_coord).abs() < TILE_EPS { + t.rect.width = (new_coord - t.rect.x).max(0.0); + } else if (t.rect.x - old_coord).abs() < TILE_EPS { + let old_right = t.rect.x + t.rect.width; + t.rect.x = new_coord; + t.rect.width = (old_right - new_coord).max(0.0); + } + } + TileEdgeOrient::Horizontal => { + if ((t.rect.y + t.rect.height) - old_coord).abs() < TILE_EPS { + t.rect.height = (new_coord - t.rect.y).max(0.0); + } else if (t.rect.y - old_coord).abs() < TILE_EPS { + let old_bottom = t.rect.y + t.rect.height; + t.rect.y = new_coord; + t.rect.height = (old_bottom - new_coord).max(0.0); + } + } + } + } + } + + /// Remove every tile whose width or height has dropped below the + /// supplied minima, absorbing each one's area into the neighbour + /// that shares the longest contact edge. Iterates until no tile is + /// too small (handles chains of collapses). Adjusts + /// `active_model_tile` so the live camera stays bound to a real + /// tile (preferring the neighbour that absorbed the active tile). + pub fn collapse_small_model_tiles(&self, min_w: f32, min_h: f32) { + let mut tiles = self.model_tiles.borrow_mut(); + loop { + if tiles.len() < 2 { + break; + } + let small = tiles + .iter() + .enumerate() + .find(|(_, t)| t.rect.width < min_w || t.rect.height < min_h) + .map(|(i, _)| i); + let Some(idx) = small else { break }; + let removed = tiles[idx].rect; + // Find the neighbour with the longest shared contact edge. + let mut best: Option<(usize, f32, ContactSide)> = None; + for (j, t) in tiles.iter().enumerate() { + if j == idx { + continue; + } + let probes = [ + ( + ContactSide::Left, + ((t.rect.x + t.rect.width) - removed.x).abs() < TILE_EPS, + overlap_len( + (t.rect.y, t.rect.y + t.rect.height), + (removed.y, removed.y + removed.height), + ), + ), + ( + ContactSide::Right, + (t.rect.x - (removed.x + removed.width)).abs() < TILE_EPS, + overlap_len( + (t.rect.y, t.rect.y + t.rect.height), + (removed.y, removed.y + removed.height), + ), + ), + ( + ContactSide::Top, + ((t.rect.y + t.rect.height) - removed.y).abs() < TILE_EPS, + overlap_len( + (t.rect.x, t.rect.x + t.rect.width), + (removed.x, removed.x + removed.width), + ), + ), + ( + ContactSide::Bottom, + (t.rect.y - (removed.y + removed.height)).abs() < TILE_EPS, + overlap_len( + (t.rect.x, t.rect.x + t.rect.width), + (removed.x, removed.x + removed.width), + ), + ), + ]; + for (side, touches, c) in probes { + if touches && c > 0.0 { + if best.map_or(true, |(_, len, _)| c > len) { + best = Some((j, c, side)); + } + } + } + } + if let Some((nbr_idx, _, side)) = best { + match side { + ContactSide::Left => { + tiles[nbr_idx].rect.width = + (removed.x + removed.width) - tiles[nbr_idx].rect.x; + } + ContactSide::Right => { + let old_right = + tiles[nbr_idx].rect.x + tiles[nbr_idx].rect.width; + tiles[nbr_idx].rect.x = removed.x; + tiles[nbr_idx].rect.width = old_right - removed.x; + } + ContactSide::Top => { + tiles[nbr_idx].rect.height = + (removed.y + removed.height) - tiles[nbr_idx].rect.y; + } + ContactSide::Bottom => { + let old_bottom = + tiles[nbr_idx].rect.y + tiles[nbr_idx].rect.height; + tiles[nbr_idx].rect.y = removed.y; + tiles[nbr_idx].rect.height = old_bottom - removed.y; + } + } + let active = self.active_model_tile.get(); + let new_active = if active == idx { + if nbr_idx > idx { nbr_idx - 1 } else { nbr_idx } + } else if active > idx { + active - 1 + } else { + active + }; + tiles.remove(idx); + self.active_model_tile + .set(new_active.min(tiles.len().saturating_sub(1))); + } else { + // Isolated tile (shouldn't happen with axis-aligned + // splits) — drop it and stretch the first remaining + // tile to fill the canvas so we don't leave a hole. + tiles.remove(idx); + let active = self.active_model_tile.get(); + self.active_model_tile + .set(active.saturating_sub(if active > idx { 1 } else { 0 }).min(tiles.len().saturating_sub(1))); + if let Some(first) = tiles.first_mut() { + first.rect = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }; + } + break; + } + } + } + /// Split the active Model tile in two. `horizontal` → a horizontal /// divider (top / bottom halves); otherwise a vertical divider (left / /// right). Both halves inherit the active tile's current camera; the diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index d19df5898..6333bb140 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -108,6 +108,7 @@ pub fn selection_overlay<'a>( ost_points: Vec, cursor_screen: Point, show_viewcube: bool, + tile_edges: Vec, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -118,6 +119,7 @@ pub fn selection_overlay<'a>( ost_points, cursor_screen, show_viewcube, + tile_edges, }) .width(Length::Fill) .height(Length::Fill) @@ -133,6 +135,7 @@ struct SelectionCanvas { ost_points: Vec, cursor_screen: Point, show_viewcube: bool, + tile_edges: Vec, } impl canvas::Program for SelectionCanvas { @@ -158,6 +161,43 @@ impl canvas::Program for SelectionCanvas { } } } + // Hover over a Model-tile divider → resize cursor cue. + if !self.tile_edges.is_empty() { + if let Some(pos) = cursor.position_in(bounds) { + const TOL_PX: f32 = 4.0; + for e in &self.tile_edges { + let (perp, edge_px, span_lo, span_hi, pos_along) = match e.orient { + crate::scene::TileEdgeOrient::Vertical => ( + pos.x, + e.coord * bounds.width, + e.span.0 * bounds.height, + e.span.1 * bounds.height, + pos.y, + ), + crate::scene::TileEdgeOrient::Horizontal => ( + pos.y, + e.coord * bounds.height, + e.span.0 * bounds.width, + e.span.1 * bounds.width, + pos.x, + ), + }; + if (perp - edge_px).abs() <= TOL_PX + && pos_along >= span_lo + && pos_along <= span_hi + { + return match e.orient { + crate::scene::TileEdgeOrient::Vertical => { + mouse::Interaction::ResizingHorizontally + } + crate::scene::TileEdgeOrient::Horizontal => { + mouse::Interaction::ResizingVertically + } + }; + } + } + } + } if cursor.is_over(bounds) { mouse::Interaction::Crosshair } else { @@ -175,6 +215,49 @@ impl canvas::Program for SelectionCanvas { ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); + // ── Tile dividers (Model-space tiled layout) ────────────────────── + // Drawn first so all other overlays sit on top — the line is just + // a visual cue for the resize-drag handles. + if !self.tile_edges.is_empty() { + const DIVIDER: Color = Color { + r: 0.40, + g: 0.45, + b: 0.55, + a: 0.85, + }; + for e in &self.tile_edges { + let (a, b) = match e.orient { + crate::scene::TileEdgeOrient::Vertical => { + let x = e.coord * bounds.width; + ( + Point::new(x, e.span.0 * bounds.height), + Point::new(x, e.span.1 * bounds.height), + ) + } + crate::scene::TileEdgeOrient::Horizontal => { + let y = e.coord * bounds.height; + ( + Point::new(e.span.0 * bounds.width, y), + Point::new(e.span.1 * bounds.width, y), + ) + } + }; + let path = canvas::Path::new(|p| { + p.move_to(a); + p.line_to(b); + }); + frame.stroke( + &path, + canvas::Stroke { + width: 2.0, + style: canvas::Style::Solid(DIVIDER), + line_cap: canvas::LineCap::Butt, + ..Default::default() + }, + ); + } + } + // ── Grid display ────────────────────────────────────────────────── if let Some(ref g) = self.grid { // Clip to the active tile's rectangle so grid lines don't spill