From 1b7377792f377fa62aa7dc7b153855ba371faa5a Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 19 Apr 2026 21:06:06 -0700 Subject: [PATCH] Icon, Windows, Dead Code. I bought a pizza. Er, right, this is git. But the pizza was REALLY good --- assets/Acord.svg | 86 +++++++------- viewport/src/editor.rs | 248 +---------------------------------------- viewport/src/lib.rs | 48 ++++++-- windows/build.ps1 | 26 +++++ windows/debug.ps1 | 27 +++++ windows/src/app.rs | 92 +++++++++++++-- windows/src/menu.rs | 2 +- 7 files changed, 215 insertions(+), 314 deletions(-) create mode 100644 windows/build.ps1 create mode 100644 windows/debug.ps1 diff --git a/assets/Acord.svg b/assets/Acord.svg index 11b4463..0d07305 100644 --- a/assets/Acord.svg +++ b/assets/Acord.svg @@ -1,54 +1,48 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + Drop shadow + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 5f74560..86f1a32 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -4,12 +4,11 @@ use std::time::Instant; use iced_wgpu::core::keyboard::{self, Modifiers}; use iced_wgpu::core::keyboard::key; -use iced_wgpu::core::text::{Highlight, LineHeight, Wrapping}; +use iced_wgpu::core::text::{Highlight, Wrapping}; use iced_wgpu::core::{ border, padding, alignment, Background, Border, Color, Element, Font, Length, - Padding, Pixels, Point, Rectangle, Shadow, Theme, + Padding, Point, Shadow, Theme, }; -use iced_widget::canvas; use iced_widget::container; use iced_widget::markdown; use iced_widget::MouseArea; @@ -25,7 +24,7 @@ use crate::hr_block::HrBlock; use crate::oklab; use crate::palette; use crate::sidecar::{self, Sidecar, TableSidecar}; -use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors}; +use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, compute_line_decors}; use crate::table_block::{self, TableBlock, TableMessage}; use crate::text_block::TextBlock; use crate::tree_block::TreeBlock; @@ -4151,247 +4150,6 @@ fn context_menu_item_style( } } -/// Vim-style cursorline overlay. Renders the editor's base background and a -/// highlight band behind the focused logical line. Sits underneath the -/// `text_editor` in a `Stack` so the line shows through the editor's -/// transparent background. -/// -/// Wrapped lines render the highlight at the LOGICAL line's first visual row, -/// not at every visual row of a soft-wrapped span — iced doesn't expose the -/// per-visual-row layout coordinates from cosmic-text yet. -struct Cursorline { - cursor_line: Option, - font_size: f32, - top_pad: f32, - /// (after_line, height) pairs from anchored children — shifts y for lines below. - item_offsets: Vec<(usize, f32)>, - /// `Off` suppresses the row-highlight band; `On` and `Vim` show it. - indicator: LineIndicator, -} - -impl canvas::Program for Cursorline { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced_wgpu::Renderer, - _theme: &Theme, - bounds: Rectangle, - _cursor: iced_wgpu::core::mouse::Cursor, - ) -> Vec> { - let mut frame = canvas::Frame::new(renderer, bounds.size()); - let p = palette::current(); - - // Page background — replaces the text_editor's own bg, which is set - // transparent so this canvas shows through. - frame.fill_rectangle(Point::ORIGIN, bounds.size(), p.base); - - if let Some(line) = self.cursor_line { - if self.indicator != LineIndicator::Off { - let lh = self.font_size * 1.3; - let extra: f32 = self.item_offsets.iter() - .filter(|(after, _)| *after < line) - .map(|(_, h)| h) - .sum(); - let y = self.top_pad + line as f32 * lh + extra; - if y < bounds.height && y + lh > 0.0 { - // ~6% tint of the foreground color. Reads as a faint band in - // both light and dark themes without screaming. - let band = Color { a: 0.06, ..p.text }; - frame.fill_rectangle( - Point::new(0.0, y), - iced_wgpu::core::Size::new(bounds.width, lh), - band, - ); - } - } - } - - vec![frame.into_geometry()] - } -} - -struct Gutter { - line_count: usize, - global_line_offset: usize, - font_size: f32, - scroll_offset: f32, - /// Cursor line within this block, only when the block is focused. Drives - /// the rainbow line-number coloring; `None` falls back to a flat dim hue. - cursor_line: Option, - top_pad: f32, - line_decors: Vec, - item_offsets: Vec<(usize, f32)>, - indicator: LineIndicator, - rainbow: bool, -} - -/// Distance-driven fade ratio for the gutter rainbow. `0.0` at the cursor -/// (full saturation), `1.0` at the far end of the fade window (fully grey). -/// Width is 2.5 full passes through the shared 8-slot palette. -const GUTTER_FADE_CYCLES: f32 = 2.5; - -fn gutter_fade_t(distance: usize) -> f32 { - let max_d = GUTTER_FADE_CYCLES * syntax::USER_IDENT_PALETTE_SIZE as f32; - (distance as f32 / max_d).min(1.0) -} - -impl Gutter { - fn gutter_width(&self) -> f32 { - let total = self.global_line_offset + self.line_count; - let count = if total == 0 { 1 } else { total }; - let digits = (count as f32).log10().floor() as usize + 1; - let char_width = self.font_size * 0.6; - (digits.max(2) as f32 * char_width + 16.0).ceil() - } -} - -impl canvas::Program for Gutter { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced_wgpu::Renderer, - _theme: &Theme, - bounds: Rectangle, - _cursor: iced_wgpu::core::mouse::Cursor, - ) -> Vec> { - let mut frame = canvas::Frame::new(renderer, bounds.size()); - let lh = self.font_size * 1.3; - - // Fill the gutter background only below `top_pad` — the first block - // reserves that strip for the titlebar / traffic-light buttons, and - // painting it in crust draws an awkward rectangle behind the system - // window controls. - if self.top_pad < bounds.height { - frame.fill_rectangle( - Point::new(0.0, self.top_pad), - iced_wgpu::core::Size::new(bounds.width, bounds.height - self.top_pad), - palette::current().crust, - ); - } - - let visible_count = (bounds.height / lh).ceil() as usize + 1; - // Locally clamp `scroll_offset` against the gutter's own bounds — - // the editor's `Action::Scroll` ceiling uses `(line_count - 1) * lh`, - // which over-scrolls short documents (gutter slides off the top, - // shows empty). Keep the same first-line / sub-pixel math but on the - // bounded value so the gutter never disappears. - let content_h = self.line_count as f32 * lh; - let max_scroll = (content_h - bounds.height + self.top_pad).max(0.0); - let eff_scroll = self.scroll_offset.min(max_scroll); - let first_visible = (eff_scroll / lh).floor() as usize; - let sub_pixel = eff_scroll - first_visible as f32 * lh; - - let gw = self.gutter_width(); - - for i in 0..visible_count { - let line_idx = first_visible + i; - if line_idx >= self.line_count { - break; - } - let line_num = self.global_line_offset + line_idx; - let extra: f32 = self.item_offsets.iter() - .filter(|(after, _)| *after < line_idx) - .map(|(_, h)| h) - .sum(); - let y = self.top_pad + i as f32 * lh - sub_pixel + extra; - if y + lh < 0.0 || y > bounds.height { - continue; - } - - let decor = if line_idx < self.line_decors.len() { - self.line_decors[line_idx] - } else { - LineDecor::None - }; - let p = palette::current(); - - match decor { - LineDecor::CodeBlock | LineDecor::FenceMarker => { - frame.fill_rectangle( - Point::new(0.0, y), - iced_wgpu::core::Size::new(gw, lh), - Color { a: 0.15, ..p.surface2 }, - ); - } - LineDecor::Blockquote => { - frame.fill_rectangle( - Point::new(gw - 3.0, y), - iced_wgpu::core::Size::new(3.0, lh), - p.lavender, - ); - } - LineDecor::HorizontalRule => { - let mid_y = y + lh / 2.0; - let path = canvas::Path::line( - Point::new(4.0, mid_y), - Point::new(gw - 4.0, mid_y), - ); - frame.stroke(&path, canvas::Stroke::default() - .with_width(1.0) - .with_color(oklab::lighten_for_size(p.overlay1, 1.0))); - } - LineDecor::None => {} - } - - // `Off` skips the number entirely — gutter strip stays for - // layout (and decors still draw above), but no digits. - if self.indicator == LineIndicator::Off { - continue; - } - - let raw_color = if self.rainbow { - match self.cursor_line { - Some(cl) if line_idx == cl => p.text, - Some(cl) if line_idx > cl => { - let d = line_idx - cl - 1; - let hue = syntax::rainbow_color(d as u32); - oklab::desaturate(hue, gutter_fade_t(d)) - } - Some(cl) /* line_idx < cl */ => { - let d = cl - line_idx - 1; - let hue = oklab::invert_hue(syntax::rainbow_color(d as u32)); - oklab::desaturate(hue, gutter_fade_t(d)) - } - None => p.surface2, - } - } else { - // Plain gutter: cursor line bright, others dim. - match self.cursor_line { - Some(cl) if line_idx == cl => p.text, - _ => p.surface2, - } - }; - // Vim mode: relative numbers everywhere except the cursor line - // itself, which stays absolute (the standard vim hybrid look). - let label = match (self.indicator, self.cursor_line) { - (LineIndicator::Vim, Some(cl)) if line_idx != cl => { - let d = if line_idx > cl { line_idx - cl } else { cl - line_idx }; - format!("{d}") - } - _ => format!("{}", line_num + 1), - }; - frame.fill_text(canvas::Text { - content: label, - position: Point::new(gw - 8.0, y), - max_width: gw, - color: oklab::lighten_for_size(raw_color, self.font_size), - size: Pixels(self.font_size), - line_height: LineHeight::Relative(1.3), - font: Font::MONOSPACE, - align_x: iced_wgpu::core::text::Alignment::Right, - align_y: alignment::Vertical::Top, - shaping: iced_wgpu::core::text::Shaping::Basic, - }); - } - - vec![frame.into_geometry()] - } -} - // Strip obsolete inline-result lines from documents saved before eval // results moved into anchored child elements. diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index a20cf85..fde4326 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -48,10 +48,11 @@ pub struct ViewportHandle { pub needs_redraw: bool, } -/// Install a panic hook that flushes a full backtrace to stderr before -/// the process aborts. Called once on first viewport_create. Without this -/// the host (Swift / winit) often eats the panic message and the user sees -/// only a silent SIGABRT with no `.ips` file. +/// Install a panic hook that flushes a full backtrace to stderr AND to +/// `~/.acord/crash.log` before the process aborts. Called once on first +/// viewport_create. Without the file fallback, the Windows release build +/// (`#![windows_subsystem = "windows"]`) detaches the console and stderr +/// goes nowhere — users get a silent crash with no diagnostic surface. fn install_panic_hook() { use std::sync::Once; static ONCE: Once = Once::new(); @@ -59,18 +60,45 @@ fn install_panic_hook() { let prior = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { use std::io::Write; - let mut err = std::io::stderr().lock(); - let _ = writeln!(err, "===== ACORD RUST PANIC ====="); - let _ = writeln!(err, "{}", info); let bt = std::backtrace::Backtrace::force_capture(); - let _ = writeln!(err, "{}", bt); - let _ = writeln!(err, "============================"); - let _ = err.flush(); + let header = "===== ACORD RUST PANIC ====="; + let footer = "============================"; + { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "{}", header); + let _ = writeln!(err, "{}", info); + let _ = writeln!(err, "{}", bt); + let _ = writeln!(err, "{}", footer); + let _ = err.flush(); + } + if let Some(home) = dirs::home_dir() { + let dir = home.join(".acord"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("crash.log"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true).append(true).open(&path) + { + let _ = writeln!(f, "{} {}", header, chrono_now()); + let _ = writeln!(f, "{}", info); + let _ = writeln!(f, "{}", bt); + let _ = writeln!(f, "{}", footer); + } + } prior(info); })); }); } +/// Best-effort timestamp for the crash log header. Avoids pulling chrono +/// for one line — uses SystemTime::now() epoch seconds as a stable suffix. +fn chrono_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| format!("(epoch {}s)", d.as_secs())) + .unwrap_or_else(|_| String::from("(time unavailable)")) +} + #[unsafe(no_mangle)] pub extern "C" fn viewport_create( nsview: *mut c_void, diff --git a/windows/build.ps1 b/windows/build.ps1 new file mode 100644 index 0000000..a7a2c94 --- /dev/null +++ b/windows/build.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +Write-Host "Building Rust workspace (release)..." +cargo build --release -p acord-windows +if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } + +$exe = Join-Path $root "target\release\acord.exe" +if (-not (Test-Path $exe)) { throw "binary not found at $exe" } + +# Rasterize the SVG icon next to the exe so load_window_icon picks it up. +# Falls back silently if rsvg-convert isn't installed. +$svg = Join-Path $root "assets\Acord.svg" +$png = Join-Path (Split-Path -Parent $exe) "icon.png" +if (Test-Path $svg) { + if (Get-Command rsvg-convert -ErrorAction SilentlyContinue) { + Write-Host "Rasterizing icon..." + rsvg-convert --width 256 --height 256 $svg -o $png + } else { + Write-Host "rsvg-convert not found on PATH; skipping icon rasterization" + } +} + +Write-Host "Built: $exe" diff --git a/windows/debug.ps1 b/windows/debug.ps1 new file mode 100644 index 0000000..0cb51ad --- /dev/null +++ b/windows/debug.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +Write-Host "Building Rust workspace (debug)..." +$env:RUST_BACKTRACE = "1" +cargo build -p acord-windows +if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } + +$exe = Join-Path $root "target\debug\acord.exe" +if (-not (Test-Path $exe)) { throw "binary not found at $exe" } + +# Same icon rasterization as build.ps1 — debug builds want the icon too. +$svg = Join-Path $root "assets\Acord.svg" +$png = Join-Path (Split-Path -Parent $exe) "icon.png" +if (Test-Path $svg) { + if (Get-Command rsvg-convert -ErrorAction SilentlyContinue) { + rsvg-convert --width 256 --height 256 $svg -o $png + } +} + +# Foreground exec so panic output (RUST_BACKTRACE=1) lands in this terminal +# rather than vanishing on a detached console. Debug builds use the +# `console` subsystem by default so stderr is wired up automatically. +Write-Host "Launching $exe ..." +& $exe diff --git a/windows/src/app.rs b/windows/src/app.rs index 7beb158..0de2c29 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -1,4 +1,8 @@ -use std::ffi::{c_void, CString}; +use std::ffi::CString; +#[cfg(target_os = "windows")] +use std::ffi::c_void; +use std::path::PathBuf; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::{LogicalSize, PhysicalPosition}; @@ -26,6 +30,9 @@ pub struct App { cursor_pos: PhysicalPosition, scale: f32, modifiers: ModifiersState, + current_file: Option, + last_autosave_attempt: Instant, + last_autosaved_hash: Option, } impl App { @@ -38,6 +45,9 @@ impl App { cursor_pos: PhysicalPosition::new(0.0, 0.0), scale: 1.0, modifiers: ModifiersState::empty(), + current_file: None, + last_autosave_attempt: Instant::now(), + last_autosaved_hash: None, } } @@ -106,28 +116,39 @@ impl App { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); w.set_title(&format!("{name} - Acord")); } + self.current_file = Some(path); + self.last_autosaved_hash = None; } } } - fn save_file(&self) { - self.save_file_as(); + fn save_file(&mut self) { + match self.current_file.clone() { + Some(path) => self.write_to(&path), + None => self.save_file_as(), + } } - fn save_file_as(&self) { + fn save_file_as(&mut self) { let dialog = rfd::FileDialog::new() .add_filter("Markdown", &["md"]) .add_filter("All Files", &["*"]) .set_file_name("note.md"); if let Some(path) = dialog.save_file() { - let text_ptr = viewport_get_text(self.handle); - if !text_ptr.is_null() { - let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } - .to_string_lossy() - .into_owned(); - viewport_free_string(text_ptr); - let _ = std::fs::write(&path, text); - } + self.write_to(&path); + self.current_file = Some(path); + } + } + + fn write_to(&mut self, path: &std::path::Path) { + let text_ptr = viewport_get_text(self.handle); + if text_ptr.is_null() { return; } + let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } + .to_string_lossy() + .into_owned(); + viewport_free_string(text_ptr); + if std::fs::write(path, &text).is_ok() { + self.last_autosaved_hash = Some(text_hash(&text)); } } @@ -137,6 +158,35 @@ impl App { if let Some(w) = &self.window { w.set_title("Acord"); } + self.current_file = None; + self.last_autosaved_hash = None; + } + + /// Hash-gated autosave. Mirrors the macOS Swift `persistViewportToNotesDir`: + /// fires on a poll cadence, skips the disk write when the buffer hash + /// matches the last saved value. Without the hash gate this would rewrite + /// the note every poll tick (~MB/s on a busy doc). + fn try_autosave(&mut self) { + if self.handle.is_null() { return; } + let text_ptr = viewport_get_text(self.handle); + if text_ptr.is_null() { return; } + let text = unsafe { std::ffi::CStr::from_ptr(text_ptr) } + .to_string_lossy() + .into_owned(); + viewport_free_string(text_ptr); + + let hash = text_hash(&text); + if Some(hash) == self.last_autosaved_hash { return; } + + let path = self.current_file.clone().unwrap_or_else(|| { + self.config.notes_dir().join("Untitled.md") + }); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::write(&path, &text).is_ok() { + self.last_autosaved_hash = Some(hash); + } } fn winit_button(button: MouseButton) -> u8 { @@ -299,6 +349,13 @@ impl ApplicationHandler for App { while let Some(action) = AppMenu::poll() { self.dispatch_menu(action, _event_loop); } + // Hash-gated autosave on a 500ms cadence. The hash skip means + // an idle doc doesn't tick the disk; a typing doc writes once + // per cadence regardless of keystroke rate. + if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { + self.last_autosave_attempt = Instant::now(); + self.try_autosave(); + } // Request a redraw if the viewport has pending work. if let Some(w) = &self.window { if !self.handle.is_null() { @@ -313,6 +370,13 @@ impl ApplicationHandler for App { } } +fn text_hash(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + h.finish() +} + /// Map winit logical keys to the macOS-style keycodes the bridge expects. /// For Named keys, return the matching keycode. For character keys, the /// bridge ignores the keycode and uses the text parameter directly, so @@ -357,6 +421,10 @@ fn encode_modifiers(state: ModifiersState) -> u32 { if state.control_key() { flags |= 1 << 18; } if state.alt_key() { flags |= 1 << 19; } if state.super_key() { flags |= 1 << 20; } + // Mirror Ctrl→LOGO so the viewport's `modifiers.logo()` shortcut arms fire. + // Matches `decode_winit_modifiers` below; without this, only menu-accelerated + // shortcuts (B/I/T) reach the viewport on Windows. + if state.control_key() { flags |= 1 << 20; } flags } diff --git a/windows/src/menu.rs b/windows/src/menu.rs index 925ff2f..89e691b 100644 --- a/windows/src/menu.rs +++ b/windows/src/menu.rs @@ -71,7 +71,7 @@ impl AppMenu { let view = Submenu::new("View", true); view.append(&MenuItem::with_id("zoom_in", "Zoom In", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Equal)))).ok(); view.append(&MenuItem::with_id("zoom_out", "Zoom Out", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Minus)))).ok(); - view.append(&MenuItem::with_id("zoom_reset", "Reset Zoom", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Digit0)))).ok(); + view.append(&MenuItem::with_id("zoom_reset", "Reset Zoom", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Digit0)))).ok(); menu.append(&file).ok(); menu.append(&edit).ok();