diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index bcaf24a..cb36056 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -171,6 +171,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { mainMenu.addItem(buildFileMenu()) mainMenu.addItem(buildEditMenu()) mainMenu.addItem(buildRenderMenu()) + mainMenu.addItem(buildModeMenu()) mainMenu.addItem(buildViewMenu()) mainMenu.addItem(buildWindowMenu()) @@ -345,6 +346,32 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { return item } + private func buildModeMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Mode") + + let freeItem = NSMenuItem(title: "Free", action: #selector(setLayoutModeFree), keyEquivalent: "") + freeItem.target = self + menu.addItem(freeItem) + + let relativeItem = NSMenuItem(title: "Relative", action: #selector(setLayoutModeRelative), keyEquivalent: "") + relativeItem.target = self + menu.addItem(relativeItem) + + let anchoredItem = NSMenuItem(title: "Anchored", action: #selector(setLayoutModeAnchored), keyEquivalent: "") + anchoredItem.target = self + menu.addItem(anchoredItem) + + menu.addItem(.separator()) + + let snappingItem = NSMenuItem(title: "Snapping", action: #selector(toggleSnapping), keyEquivalent: "") + snappingItem.target = self + menu.addItem(snappingItem) + + item.submenu = menu + return item + } + private func buildViewMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "View") @@ -647,6 +674,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { let mode = viewport?.renderMode() ?? 0 + let layout = viewport?.layoutMode() ?? 0 switch menuItem.action { case #selector(setLiveMode): menuItem.state = mode == 0 ? .on : .off @@ -654,6 +682,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { menuItem.state = mode == 1 ? .on : .off case #selector(setViewMode): menuItem.state = mode == 2 ? .on : .off + case #selector(setLayoutModeFree): + menuItem.state = layout == 0 ? .on : .off + case #selector(setLayoutModeRelative): + menuItem.state = layout == 1 ? .on : .off + case #selector(setLayoutModeAnchored): + menuItem.state = layout == 2 ? .on : .off + case #selector(toggleSnapping): + menuItem.state = (viewport?.snapping() ?? false) ? .on : .off default: break } @@ -676,6 +712,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { viewport?.sendCommand(13) } + @objc private func setLayoutModeFree() { + viewport?.sendCommand(17) + } + + @objc private func setLayoutModeRelative() { + viewport?.sendCommand(18) + } + + @objc private func setLayoutModeAnchored() { + viewport?.sendCommand(19) + } + + @objc private func toggleSnapping() { + viewport?.sendCommand(20) + } + @objc private func formatDocument() { viewport?.sendCommand(10) } diff --git a/macos/src/IcedViewportView.swift b/macos/src/IcedViewportView.swift index 32b76dc..8635f39 100644 --- a/macos/src/IcedViewportView.swift +++ b/macos/src/IcedViewportView.swift @@ -298,6 +298,17 @@ class IcedViewportView: NSView { return viewport_render_mode(h) } + /// Returns 0 = Free, 1 = Relative, 2 = Anchored. + func layoutMode() -> UInt32 { + guard let h = viewportHandle else { return 0 } + return viewport_layout_mode(h) + } + + func snapping() -> Bool { + guard let h = viewportHandle else { return false } + return viewport_snapping(h) + } + func setSettingsView(themeMode: String, lineIndicator: String, gutterRainbow: Bool, autoSaveDir: String) { guard let h = viewportHandle else { return } themeMode.withCString { t in diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 8ca16ff..7263ea1 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -173,4 +173,14 @@ void browser_send_command(struct BrowserHandle *handle, uint32_t command); uint32_t viewport_render_mode(struct ViewportHandle *handle); +/** + * returns 0 for free, 1 for relative, 2 for anchored layout mode. + */ +uint32_t viewport_layout_mode(struct ViewportHandle *handle); + +/** + * returns true when 0.25-line snapping is on for drags and resizes. + */ +bool viewport_snapping(struct ViewportHandle *handle); + #endif /* ACORD_VIEWPORT_H */ diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 12c62ce..56b6c3f 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -67,6 +67,16 @@ pub enum RenderMode { View, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutMode { + /// objects float on layers above zero with absolute coordinates + Free, + /// objects stay on layer 0 and reorder by drag + Relative, + /// objects sit at fixed positions and layer 0 wraps around the cutouts + Anchored, +} + /// gutter line-number and cursorline display mode #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineIndicator { @@ -183,6 +193,10 @@ pub enum Message { ResizePress { node_id: FreeNodeId, horiz: bool, vert: bool }, /// mouse released after a resize press. ResizeRelease, + /// switches between free, relative, and anchored layout modes. + SetLayoutMode(LayoutMode), + /// toggles 0.25-line snap on drags and resizes. + ToggleSnapping, ToggleMenu(MenuCategory), CloseMenu, Shell(ShellAction), @@ -193,6 +207,7 @@ pub enum MenuCategory { File, Edit, Render, + Mode, View, } @@ -214,10 +229,11 @@ pub enum ShellAction { } #[cfg(any(target_os = "linux", target_os = "windows"))] -const MENU_CATS: [(MenuCategory, &'static str); 4] = [ +const MENU_CATS: [(MenuCategory, &'static str); 5] = [ (MenuCategory::File, "File"), (MenuCategory::Edit, "Edit"), (MenuCategory::Render, "Render"), + (MenuCategory::Mode, "Mode"), (MenuCategory::View, "View"), ]; @@ -497,6 +513,8 @@ pub struct EditorState { pub promote_snapshot_pushed: bool, pub resize_drag: Option, pub active_free: Option, + pub layout_mode: LayoutMode, + pub snapping: bool, } #[derive(Debug, Clone)] @@ -620,9 +638,19 @@ impl EditorState { promote_snapshot_pushed: false, resize_drag: None, active_free: None, + layout_mode: LayoutMode::Free, + snapping: false, } } + /// rounds a coordinate to the nearest 0.25-line increment. + fn snap_to_grid(&self, value: f32) -> f32 { + if !self.snapping { return value; } + let unit = self.font_size * 0.325; + if unit <= 0.0 { return value; } + (value / unit).round() * unit + } + /// applies the current cursor delta to any active promote drag. pub fn tick_promote_drag(&mut self) -> bool { let (node_id, layer, origin, size, dx, dy, was_escalated) = { @@ -644,8 +672,8 @@ impl EditorState { if let Some(pd) = self.promote_drag.as_mut() { pd.escalated = true; } let placement = FreePlacement { layer, - x: origin.0 + dx, - y: origin.1 + dy, + x: self.snap_to_grid(origin.0 + dx), + y: self.snap_to_grid(origin.1 + dy), w: size.0, h: size.1, }; @@ -664,8 +692,8 @@ impl EditorState { 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 new_w = if axes.0 { self.snap_to_grid((start_size.0 + dx).max(60.0)) } else { start_size.0 }; + let new_h = if axes.1 { self.snap_to_grid((start_size.1 + dy).max(40.0)) } else { start_size.1 }; let changed = self .free_placements .get(&node_id) @@ -752,6 +780,7 @@ impl EditorState { /// 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; } + if !matches!(self.layout_mode, LayoutMode::Free) { return; } let existing = self.free_placements.get(&node_id).copied(); let (origin, size, layer) = match existing { Some(p) => ((p.x, p.y), (p.w, p.h), p.layer), @@ -3590,6 +3619,13 @@ impl EditorState { Message::ResizeRelease => { self.resize_drag = None; } + Message::SetLayoutMode(mode) => { + self.layout_mode = mode; + self.snapping = !matches!(mode, LayoutMode::Free); + } + Message::ToggleSnapping => { + self.snapping = !self.snapping; + } } } @@ -4807,6 +4843,13 @@ impl EditorState { sep(), item("Evaluate", "Ctrl+E", Message::SmartEval), ], + MenuCategory::Mode => vec![ + item("Free", if matches!(self.layout_mode, LayoutMode::Free) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Free)), + item("Relative", if matches!(self.layout_mode, LayoutMode::Relative) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Relative)), + item("Anchored", if matches!(self.layout_mode, LayoutMode::Anchored) { "•" } else { "" }, Message::SetLayoutMode(LayoutMode::Anchored)), + sep(), + item("Snapping", if self.snapping { "✓" } else { "" }, Message::ToggleSnapping), + ], MenuCategory::View => vec![ item("Zoom In", "Ctrl+=", Message::ZoomIn), item("Zoom Out", "Ctrl+-", Message::ZoomOut), @@ -4825,6 +4868,7 @@ impl EditorState { MenuCategory::File => "Export as Rust Library".len(), MenuCategory::Edit => "Insert Table".len(), MenuCategory::Render => "Evaluate".len(), + MenuCategory::Mode => "Anchored".len(), MenuCategory::View => "Reset Zoom".len(), }; let max_hint_chars = 13_usize; // widest hint string in chars diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index a139663..8af8f33 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -450,6 +450,10 @@ pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u3 12 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::Editor)), 13 => h.state.update(editor::Message::SetRenderMode(editor::RenderMode::View)), 16 => h.state.settings_open = !h.state.settings_open, + 17 => h.state.update(editor::Message::SetLayoutMode(editor::LayoutMode::Free)), + 18 => h.state.update(editor::Message::SetLayoutMode(editor::LayoutMode::Relative)), + 19 => h.state.update(editor::Message::SetLayoutMode(editor::LayoutMode::Anchored)), + 20 => h.state.update(editor::Message::ToggleSnapping), _ => return, }; h.needs_redraw = true; @@ -660,3 +664,27 @@ pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 { editor::RenderMode::View => 2, } } + +/// returns 0 for free, 1 for relative, 2 for anchored layout mode. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_layout_mode(handle: *mut ViewportHandle) -> u32 { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return 0, + }; + match h.state.layout_mode { + editor::LayoutMode::Free => 0, + editor::LayoutMode::Relative => 1, + editor::LayoutMode::Anchored => 2, + } +} + +/// returns true when 0.25-line snapping is on for drags and resizes. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_snapping(handle: *mut ViewportHandle) -> bool { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return false, + }; + h.state.snapping +}