Fixed the View mode bugs I'd been putting off. Added build scripts.

This commit is contained in:
jess 2026-04-26 19:59:42 -07:00
parent 9b2de378ef
commit e8a7f655ec
7 changed files with 300 additions and 52 deletions

83
build.sh Executable file
View File

@ -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

66
debug.sh Executable file
View File

@ -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"

18
install.sh Executable file
View File

@ -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"

View File

@ -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(_:)))

View File

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

View File

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

View File

@ -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<LineMetric> = 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() {