From e8a7f655ec1d01f0914cf3a4f35839921da7c842 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 26 Apr 2026 19:59:42 -0700 Subject: [PATCH] Fixed the View mode bugs I'd been putting off. Added build scripts. --- build.sh | 83 ++++++++++++++++++++++++++++++++ debug.sh | 66 ++++++++++++++++++++++++++ install.sh | 18 +++++++ src/TitleBarView.swift | 6 ++- viewport/src/editor.rs | 42 ++++++++++++++++ viewport/src/handle.rs | 42 ++++++++++------ viewport/src/text_widget.rs | 95 +++++++++++++++++++++++-------------- 7 files changed, 300 insertions(+), 52 deletions(-) create mode 100755 build.sh create mode 100755 debug.sh create mode 100755 install.sh diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ec5e9f4 --- /dev/null +++ b/build.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="$ROOT/build" +APP="$BUILD/bin/Acord.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +SDK=$(xcrun --show-sdk-path) + +RUST_LIB="$ROOT/target/release" +export MACOSX_DEPLOYMENT_TARGET=14.0 +export ZERO_AR_DATE=0 +echo "Building Rust workspace (release)..." +cd "$ROOT" && cargo build --release -p acord-viewport +if [ $? -ne 0 ]; then + echo "ERROR: Rust build failed" + exit 1 +fi + +if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then + echo "ERROR: libacord_viewport.a not found at $RUST_LIB" + exit 1 +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) + +# --- App icon from SVG via rsvg-convert --- +SVG="$ROOT/assets/Acord.svg" +if [ -f "$SVG" ]; then + echo "Generating app icon..." + ICONSET="$BUILD/AppIcon.iconset" + mkdir -p "$ICONSET" + for size in 16 32 64 128 256 512 1024; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$ICONSET/icon_${size}.png" + done + cp "$ICONSET/icon_16.png" "$ICONSET/icon_16x16.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_32x32.png" + cp "$ICONSET/icon_64.png" "$ICONSET/icon_32x32@2x.png" + cp "$ICONSET/icon_128.png" "$ICONSET/icon_128x128.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_256x256.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png" + cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png" + rm -f "$ICONSET"/icon_*.png.tmp "$ICONSET"/icon_16.png "$ICONSET"/icon_32.png "$ICONSET"/icon_64.png "$ICONSET"/icon_128.png "$ICONSET"/icon_256.png "$ICONSET"/icon_512.png "$ICONSET"/icon_1024.png + iconutil -c icns "$ICONSET" -o "$BUILD/AppIcon.icns" + rm -rf "$ICONSET" +fi + +# --- Bundle structure --- +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +if [ -f "$BUILD/AppIcon.icns" ]; then + cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" +fi + +# --- Compile Swift --- +echo "Compiling Swift (release)..." +swiftc \ + -target arm64-apple-macosx14.0 \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + -framework Cocoa \ + -framework SwiftUI \ + -framework Metal \ + -framework MetalKit \ + -framework QuartzCore \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -O \ + -o "$MACOS/Acord" \ + "$ROOT"/src/*.swift + +# --- Code sign --- +codesign --force --sign - "$APP" + +echo "Built: $APP" + +open /Users/pszsh/External/Repositories/Acord/build/bin/Acord.app diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..3508420 --- /dev/null +++ b/debug.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Debug build — same wiring as build.sh but unoptimised, with -g, and +# launched in the foreground so Rust panics print straight to this terminal +# (the panic hook in viewport/src/lib.rs flushes stderr before SIGABRT). + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="$ROOT/build" +APP="$BUILD/bin/Acord.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +SDK=$(xcrun --show-sdk-path) +RUST_LIB="$ROOT/target/debug" + +export MACOSX_DEPLOYMENT_TARGET=14.0 +export ZERO_AR_DATE=0 +export RUST_BACKTRACE=1 + +echo "Building Rust workspace (debug)..." +cd "$ROOT" && cargo build -p acord-viewport + +if [ ! -f "$RUST_LIB/libacord_viewport.a" ]; then + echo "ERROR: libacord_viewport.a not found at $RUST_LIB" + exit 1 +fi + +RUST_FLAGS=(-import-objc-header "$ROOT/viewport/include/acord.h" -L "$RUST_LIB" -lacord_viewport) + +# --- Bundle structure --- +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +if [ -f "$BUILD/AppIcon.icns" ]; then + cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" +fi + +# --- Compile Swift (debug) --- +echo "Compiling Swift (debug)..." +swiftc \ + -target arm64-apple-macosx14.0 \ + -sdk "$SDK" \ + "${RUST_FLAGS[@]}" \ + -framework Cocoa \ + -framework SwiftUI \ + -framework Metal \ + -framework MetalKit \ + -framework QuartzCore \ + -framework CoreGraphics \ + -framework CoreFoundation \ + -Onone -g \ + -o "$MACOS/Acord" \ + "$ROOT"/src/*.swift + +codesign --force --sign - "$APP" + +# --- Kill existing, launch in foreground so stderr lands here --- +pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true +sleep 0.3 + +echo +echo "Launching $MACOS/Acord — Rust panics will print below." +echo "(Ctrl+C to exit, or quit Acord normally.)" +echo "----------------------------------------------------------" +exec "$MACOS/Acord" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4db30fb --- /dev/null +++ b/install.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +DEST="/Applications/Acord.app" + +echo "Building release..." +"$ROOT/build.sh" + +# Kill running instance before replacing +pkill -f "Acord.app/Contents/MacOS/Acord" 2>/dev/null || true +sleep 0.5 + +echo "Installing to $DEST..." +rm -rf "$DEST" +cp -R "$ROOT/build/bin/Acord.app" "$DEST" + +echo "Installed: $DEST" diff --git a/src/TitleBarView.swift b/src/TitleBarView.swift index 446c0ba..2b7a707 100644 --- a/src/TitleBarView.swift +++ b/src/TitleBarView.swift @@ -69,6 +69,8 @@ class TitleBarView: NSView { label.lineBreakMode = .byTruncatingTail label.cell?.truncatesLastVisibleLine = true label.translatesAutoresizingMaskIntoConstraints = false + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.setContentHuggingPriority(.defaultLow, for: .horizontal) label.stringValue = "Untitled" editor.font = .systemFont(ofSize: 13, weight: .semibold) @@ -81,6 +83,8 @@ class TitleBarView: NSView { editor.focusRingType = .none editor.cell?.lineBreakMode = .byTruncatingTail editor.translatesAutoresizingMaskIntoConstraints = false + editor.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + editor.setContentHuggingPriority(.defaultLow, for: .horizontal) editor.isHidden = true editor.delegate = self @@ -94,7 +98,7 @@ class TitleBarView: NSView { editor.centerXAnchor.constraint(equalTo: centerXAnchor), editor.centerYAnchor.constraint(equalTo: centerYAnchor), - editor.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5), + editor.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.5), ]) let dblClick = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick(_:))) diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 5c62ea9..c3908d8 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -2517,7 +2517,48 @@ impl EditorState { }); } + /// Whether `message` is safe to dispatch while the editor is in + /// `RenderMode::View`. Allowlist: scroll, click/drag selection, + /// find, copy, zoom, focus, navigation — anything that doesn't + /// touch document content. Edit-shaped `text_widget::Action`s and + /// every mutating top-level `Message` get dropped at the gate. + fn message_is_view_safe(message: &Message) -> bool { + match message { + Message::SetRenderMode(_) => true, + Message::FocusBlock(_) => true, + Message::TogglePreview => true, + Message::MarkdownLink(_) => true, + Message::ZoomIn | Message::ZoomOut | Message::ZoomReset => true, + Message::ToggleFind | Message::HideFind => true, + Message::FindQueryChanged(_) + | Message::FindNext + | Message::FindPrev => true, + Message::ReplaceQueryChanged(_) => true, + Message::TableMoveUp + | Message::TableMoveDown + | Message::TableMoveLeft + | Message::TableMoveRight => true, + Message::SelectAllBlocks => true, + Message::ShowContextMenu { .. } | Message::HideContextMenu => true, + Message::CopyLiteral(_) | Message::CopyFocusedTableSelection => true, + Message::InlineResultPress { .. } | Message::InlineResultRelease => true, + Message::EditorAction(action) | Message::BlockAction(_, action) => { + !action.is_edit() + } + _ => false, + } + } + pub fn update(&mut self, message: Message) { + // View mode: drop anything that would change the document. Mode + // switches, focus, scroll, click/drag selection, find, copy, + // navigation — all pass through. Allowlist; new mutating + // messages should fall through to the default `false` arm and + // get dropped. + if self.render_mode == RenderMode::View && !Self::message_is_view_safe(&message) { + return; + } + // Drop whole-document selection on any message that isn't itself an // operation on that selection. Click, key press, table action — all // collapse the doc-wide selection back to single-block / single-cell. @@ -3533,6 +3574,7 @@ impl EditorState { col_items.push(status_bar.into()); iced_widget::column(col_items) + .width(Length::Fill) .height(Length::Fill) .into() } diff --git a/viewport/src/handle.rs b/viewport/src/handle.rs index 5a6fea4..7cbdcab 100644 --- a/viewport/src/handle.rs +++ b/viewport/src/handle.rs @@ -228,24 +228,33 @@ pub fn render(handle: &mut ViewportHandle) { new_cmd_a_armed = Some(false); } - // View mode: consume all events except mode-switch keys. - // Ctrl+I, Ctrl+/, Ctrl+Esc, `i`, `/` are handled by their own - // match arms below. Everything else is swallowed. + // View mode: drop the keys that would write to the document so + // the iced widget never sees them. `i` and `/` (mode-switch) + // fall through to their own match arms below; mouse events, + // scroll, navigation/selection keys, and modifier-prefixed + // shortcuts also pass through and get message-layer gated. if handle.state.render_mode == RenderMode::View { - let is_mode_switch = match event { + let is_typing_char = match event { Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(c), modifiers, .. - }) => { - (modifiers.control() && (c.as_str() == "i" || c.as_str() == "/")) - || (!modifiers.logo() && !modifiers.control() && !modifiers.alt() - && (c.as_str() == "i" || c.as_str() == "/")) + }) if !modifiers.logo() && !modifiers.control() && !modifiers.alt() => { + c.as_str() != "i" && c.as_str() != "/" } - Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(keyboard::key::Named::Escape), modifiers, .. - }) => modifiers.control(), _ => false, }; - if !is_mode_switch { + let is_destructive_named = matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named( + keyboard::key::Named::Backspace + | keyboard::key::Named::Delete + | keyboard::key::Named::Enter + | keyboard::key::Named::Tab + ), + .. + }) + ); + if is_typing_char || is_destructive_named { consumed.push(ev_idx); continue; } @@ -574,8 +583,8 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::ExitCellEdit); consumed.push(ev_idx); } else { - // Nothing to dismiss — chain mode switch. - // Live → Editor, Editor → View + // Nothing to dismiss — cycle modes: + // Live → Editor → View → Live. match handle.state.render_mode { RenderMode::Live => { messages.push(Message::SetRenderMode(RenderMode::Editor)); @@ -585,7 +594,10 @@ pub fn render(handle: &mut ViewportHandle) { messages.push(Message::SetRenderMode(RenderMode::View)); consumed.push(ev_idx); } - RenderMode::View => {} + RenderMode::View => { + messages.push(Message::SetRenderMode(RenderMode::Live)); + consumed.push(ev_idx); + } } } } diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index 33f27b2..cbc888d 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -80,27 +80,32 @@ pub struct AnchoredItem<'a, Message, Theme = iced_wgpu::core::Theme> { pub struct LineMetric { /// Widget-y of this line's first visual row (relative to text_bounds.y). pub widget_y: f32, - /// Cosmic-buffer y of this line's first visual row. Buffer y advances - /// by line_h per visual row (wrapped lines occupy multiple rows). - pub buffer_y: f32, + /// Cosmic-text's viewport-relative y of this line's first visual row — + /// matches the y produced by `Selection::Caret(position).y` and what + /// `Action::Click { y }` consumes (already scroll-adjusted, items + /// invisible to it). Diverges from `widget_y` whenever an anchored + /// item sits between this line and `scroll.line`. + pub viewport_y: f32, /// Number of visual rows this logical line occupies after wrap. pub visual_rows: usize, } -/// Translate a cosmic-buffer y (visual rows * line_h) into a widget y. -fn buffer_y_to_widget_y(metrics: &[LineMetric], buffer_y: f32) -> f32 { - if metrics.is_empty() { return buffer_y; } +/// Translate a cosmic-reported y (`Selection::Caret`, `Selection::Range`) +/// into our widget-y so cursor + selection rectangles draw on top of the +/// text rows the compositor actually rendered. +fn cosmic_y_to_widget_y(metrics: &[LineMetric], cosmic_y: f32, _line_h: f32) -> f32 { + if metrics.is_empty() { return cosmic_y; } for i in (0..metrics.len() - 1).rev() { - if metrics[i].buffer_y <= buffer_y { - return metrics[i].widget_y + (buffer_y - metrics[i].buffer_y); + if metrics[i].viewport_y <= cosmic_y { + return metrics[i].widget_y + (cosmic_y - metrics[i].viewport_y); } } - metrics[0].widget_y + (buffer_y - metrics[0].buffer_y) + metrics[0].widget_y + (cosmic_y - metrics[0].viewport_y) } -/// Translate a widget y into a cosmic-buffer y. Click/drag positions go -/// through this so cosmic-text receives the right visual row. -fn widget_y_to_buffer_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 { +/// Translate a widget-y (mouse coords) back into the y cosmic-text expects +/// for click/drag actions — the inverse of `cosmic_y_to_widget_y`. +fn widget_y_to_cosmic_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f32 { if metrics.len() < 2 { return widget_y; } let line_count = metrics.len() - 1; for i in 0..line_count { @@ -108,17 +113,17 @@ fn widget_y_to_buffer_y(metrics: &[LineMetric], widget_y: f32, line_h: f32) -> f let line_bot = line_top + metrics[i].visual_rows as f32 * line_h; if widget_y < line_bot { if widget_y < line_top { - return metrics[i].buffer_y; + return metrics[i].viewport_y; } - return metrics[i].buffer_y + (widget_y - line_top); + return metrics[i].viewport_y + (widget_y - line_top); } let next_top = metrics[i + 1].widget_y; if widget_y < next_top { - return metrics[i].buffer_y + metrics[i].visual_rows as f32 * line_h; + return metrics[i].viewport_y + metrics[i].visual_rows as f32 * line_h; } } let tail = metrics.last().unwrap(); - tail.buffer_y + (widget_y - tail.widget_y).max(0.0) + tail.viewport_y + (widget_y - tail.widget_y).max(0.0) } /// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor @@ -674,7 +679,7 @@ where let adjusted = { let metrics = state.line_metrics.borrow(); - Point::new(cursor.x, buffer_y_to_widget_y(&metrics, cursor.y)) + Point::new(cursor.x, cosmic_y_to_widget_y(&metrics, cursor.y, line_height.into())) }; let position = adjusted + translation; @@ -1001,30 +1006,42 @@ where // 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. + // Anchor cosmic's viewport-y at scroll.line top: cosmic's + // `Selection::Caret(position).y` for a cursor sitting on the very + // first visible visual row equals `-scroll.vertical`, regardless + // of how many logical lines came before. Pre-scroll lines are not + // shaped (`layout_opt() == None`) and contribute 0 visual rows in + // cosmic's own bookkeeping — mirror that so the two y-spaces + // agree. 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 = Vec::with_capacity(line_count + 1); - let mut widget_y = -scroll_offset_px; - let mut buffer_y = 0.0f32; + let mut widget_y = -scroll.vertical; + let mut viewport_y = -scroll.vertical; + // Pre-scroll lines: cosmic treats them as 0 rows, but we still + // need a widget-y so any bottom-anchored items render relative to + // a stable bottom. For the cursor/selection mapping to work the + // viewport_y must stay parked at -scroll.vertical for them + // (cosmic has no addressable y above scroll.line). + for _ in 0..scroll.line.min(line_count) { + metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 }); + } let mut next_child = 0; - for line in 0..line_count { + // Skip anchored children that sit above the scroll line. + while next_child < self.anchored_children.len() + && self.anchored_children[next_child].after_line < scroll.line + { + next_child += 1; + } + for line in scroll.line..line_count { let visual_rows = buffer.lines[line] .layout_opt() .map(|v| v.len()) .unwrap_or(1) .max(1); - metrics.push(LineMetric { widget_y, buffer_y, visual_rows }); + metrics.push(LineMetric { widget_y, viewport_y, visual_rows }); let line_visual_h = visual_rows as f32 * line_h; widget_y += line_visual_h; - buffer_y += line_visual_h; + viewport_y += line_visual_h; while next_child < self.anchored_children.len() && self.anchored_children[next_child].after_line == line { @@ -1063,8 +1080,8 @@ where } // Push sentinel AFTER trailing children are placed, so the // sentinel widget_y reflects the true bottom of the stream. - metrics.push(LineMetric { widget_y, buffer_y, visual_rows: 0 }); - let extra = widget_y - buffer_y; + metrics.push(LineMetric { widget_y, viewport_y, visual_rows: 0 }); + let extra = widget_y - viewport_y; *state.line_metrics.borrow_mut() = metrics; match self.height { @@ -1185,7 +1202,7 @@ where let mut pos = click.position(); pos.x = (pos.x - gw).max(0.0); let metrics = state.line_metrics.borrow(); - pos.y = widget_y_to_buffer_y(&metrics, pos.y, line_h); + pos.y = widget_y_to_cosmic_y(&metrics, pos.y, line_h); Action::Click(pos) } mouse::click::Kind::Double => Action::SelectWord, @@ -1204,7 +1221,7 @@ where let mut pos = position; pos.x = (pos.x - gw).max(0.0); let metrics = state.line_metrics.borrow(); - pos.y = widget_y_to_buffer_y(&metrics, pos.y, line_h); + pos.y = widget_y_to_cosmic_y(&metrics, pos.y, line_h); shell.publish(on_edit(Action::Drag(pos))); } Update::Release => { @@ -1527,6 +1544,12 @@ where Some(m) => m, None => continue, }; + // Pre-scroll lines carry visual_rows == 0 (cosmic hasn't + // shaped them, layout_opt returns None) — skip them so + // we don't draw unshaped paragraphs piled at the same y. + if m.visual_rows == 0 { + continue; + } let y = text_bounds.y + m.widget_y; let row_h = m.visual_rows as f32 * line_h; @@ -1604,7 +1627,7 @@ where if let Some(focus) = state.focus.as_ref() { let metrics_for_cursor = state.line_metrics.borrow(); let adjust_y = |pos: Point| -> Point { - Point::new(pos.x, buffer_y_to_widget_y(&metrics_for_cursor, pos.y)) + Point::new(pos.x, cosmic_y_to_widget_y(&metrics_for_cursor, pos.y, line_h)) }; match internal.editor.selection() {