diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 99e46c7..5aff247 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -67,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { observeDocumentText() syncThemeToViewport() + syncGutterPrefsToViewport() startAutosaveTimer() DocumentBrowserController.shared = DocumentBrowserController(appState: appState) @@ -582,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { @objc private func settingsDidChange() { window.backgroundColor = Theme.current.base syncThemeToViewport() + syncGutterPrefsToViewport() window.contentView?.needsDisplay = true } @@ -599,6 +601,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { viewport?.setTheme(name) } + private func syncGutterPrefsToViewport() { + viewport?.setLineIndicator(ConfigManager.shared.lineIndicatorMode) + viewport?.setGutterRainbow(ConfigManager.shared.gutterRainbow) + } + @objc private func toggleBrowser() { DocumentBrowserController.shared?.toggle() } diff --git a/src/AppState.swift b/src/AppState.swift index 82c6a0a..e19aad5 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -132,6 +132,11 @@ class AppState: ObservableObject { private var autoSaveDirty = false private var autoSaveCoolingDown = false private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave") + /// Per-note autosave file path, established on the first write and never + /// changed for the rest of the session. Stops the title-derived filename + /// from re-deriving on every keystroke and littering the notes directory + /// with `u.md`, `us.md`, `use.md`, ... + private var autoSavePaths: [UUID: URL] = [:] init() { let id = bridge.newDocument() @@ -158,10 +163,10 @@ class AppState: ObservableObject { let text = documentText let noteID = currentNoteID - let title = extractTitle(from: text) + let url = resolveAutoSaveURL(noteID: noteID, text: text) autoSaveQueue.async { [weak self] in - self?.writeAutoSaveFile(noteID: noteID, title: title, text: text) + Self.writeAutoSaveFile(at: url, text: text) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } self.autoSaveCoolingDown = false @@ -244,19 +249,47 @@ class AppState: ObservableObject { return cleaned.isEmpty ? UUID().uuidString : cleaned } - private func writeAutoSaveFile(noteID: UUID, title: String, text: String) { - let dir = ConfigManager.shared.autoSaveDirectory - let dirURL = URL(fileURLWithPath: dir) + /// Resolve the autosave file URL for `noteID`. First call for a noteID + /// derives a filename from the title (or the UUID when there's no title); + /// the resulting path is then locked in for the rest of the session, so + /// later keystrokes can't spawn a fresh file each time the title grows. + /// Must be called on the main thread (mutates `autoSavePaths`). + private func resolveAutoSaveURL(noteID: UUID, text: String) -> URL { + if let url = autoSavePaths[noteID] { + return url + } + let dirURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - + let title = extractTitle(from: text) let filename: String if title == "Untitled" { filename = noteID.uuidString.lowercased() } else { filename = sanitizeFilename(title) } - let fileURL = dirURL.appendingPathComponent(filename + ".md") - try? text.write(to: fileURL, atomically: true, encoding: .utf8) + let url = dirURL.appendingPathComponent(filename + ".md") + autoSavePaths[noteID] = url + return url + } + + /// Background-safe atomic write. No path resolution here — the URL was + /// resolved on the main thread before dispatch. + private static func writeAutoSaveFile(at url: URL, text: String) { + try? text.write(to: url, atomically: true, encoding: .utf8) + } + + /// Strip the `` sidecar comment from `text`. + /// The markdown body before the comment is the user's actual content; + /// non-markdown destinations (.rs, .json, .csv-source, etc.) must not + /// inherit the comment because it isn't valid syntax in those formats. + private static func stripArchiveForExternalSave(_ text: String) -> String { + var body = stripSidecarArchive(text) + // `stripSidecarArchive` keeps trailing whitespace — trim so we don't + // leave a flapping blank line where the comment used to be. + while body.hasSuffix("\n\n") { + body.removeLast() + } + return body } // MARK: - Note operations @@ -313,14 +346,9 @@ class AppState: ObservableObject { } func saveNote() { - let textToSave: String - if currentFileFormat.isCSV { - textToSave = markdownTableToCSV(documentText) - } else { - textToSave = documentText - } - bridge.setText(currentNoteID, text: textToSave) + bridge.setText(currentNoteID, text: documentText) if let url = currentFileURL { + let textToSave = textForExternalSave(format: currentFileFormat) try? textToSave.write(to: url, atomically: true, encoding: .utf8) } let _ = bridge.cacheSave(currentNoteID) @@ -330,18 +358,29 @@ class AppState: ObservableObject { func saveNoteToFile(_ url: URL) { let format = FileFormat.from(filename: url.lastPathComponent) - let textToSave: String - if format.isCSV { - textToSave = markdownTableToCSV(documentText) - } else { - textToSave = documentText - } + let textToSave = textForExternalSave(format: format) try? textToSave.write(to: url, atomically: true, encoding: .utf8) currentFileURL = url currentFileFormat = format + // An explicit save-to-disk locks the autosave path to the same file + // for the rest of the session — keystrokes after Save As shouldn't + // start a fresh autosave file under the old name. + if format.isMarkdown { + autoSavePaths[currentNoteID] = url + } modified = false } + /// Project the in-memory `documentText` onto the right shape for an + /// external file format. CSV gets converted from the markdown table, + /// non-markdown formats get the sidecar archive comment stripped (the + /// HTML comment isn't valid in .rs/.json/etc.), markdown passes through. + private func textForExternalSave(format: FileFormat) -> String { + if format.isCSV { return markdownTableToCSV(documentText) } + if format.isMarkdown { return documentText } + return AppState.stripArchiveForExternalSave(documentText) + } + func loadNoteFromFile(_ url: URL) { let format = FileFormat.from(filename: url.lastPathComponent) if let (id, text) = bridge.loadNote(path: url.path) { @@ -353,6 +392,15 @@ class AppState: ObservableObject { } else { documentText = text } + // Lock the autosave path to the loaded file when it lives in the + // notes dir. Outside that dir, the user picked their own path — + // we won't shadow it with an autosave duplicate. + let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + .standardizedFileURL + let parent = url.deletingLastPathComponent().standardizedFileURL + if format.isMarkdown && parent == dir { + autoSavePaths[id] = url + } modified = false let _ = bridge.cacheSave(id) evaluate() @@ -465,6 +513,9 @@ class AppState: ObservableObject { func deleteNote(_ id: UUID) { bridge.deleteNote(id) + if let url = autoSavePaths.removeValue(forKey: id) { + try? FileManager.default.removeItem(at: url) + } if id == currentNoteID { newNote() } @@ -474,6 +525,9 @@ class AppState: ObservableObject { func deleteNotes(_ ids: Set) { for id in ids { bridge.deleteNote(id) + if let url = autoSavePaths.removeValue(forKey: id) { + try? FileManager.default.removeItem(at: url) + } } if ids.contains(currentNoteID) { let remaining = noteList.first { !ids.contains($0.id) } @@ -505,9 +559,9 @@ class AppState: ObservableObject { /// state (including visible eval results). func writeAutosavedCopy(text: String) { let noteID = currentNoteID - let title = extractTitle(from: text) - autoSaveQueue.async { [weak self] in - self?.writeAutoSaveFile(noteID: noteID, title: title, text: text) + let url = resolveAutoSaveURL(noteID: noteID, text: text) + autoSaveQueue.async { + Self.writeAutoSaveFile(at: url, text: text) } } @@ -532,6 +586,9 @@ class AppState: ObservableObject { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { bridge.deleteNote(id) + if let url = autoSavePaths.removeValue(forKey: id) { + try? FileManager.default.removeItem(at: url) + } } } } diff --git a/src/ConfigManager.swift b/src/ConfigManager.swift index d23fd3e..d925b8a 100644 --- a/src/ConfigManager.swift +++ b/src/ConfigManager.swift @@ -53,6 +53,11 @@ class ConfigManager { set { config["lineIndicatorMode"] = newValue; save() } } + var gutterRainbow: Bool { + get { (config["gutterRainbow"] ?? "true") != "false" } + set { config["gutterRainbow"] = newValue ? "true" : "false"; save() } + } + var zoomLevel: CGFloat { get { CGFloat(Double(config["zoomLevel"] ?? "0") ?? 0) } set { config["zoomLevel"] = String(Double(newValue)); save() } diff --git a/src/IcedViewportView.swift b/src/IcedViewportView.swift index 5f441d8..bcc74f5 100644 --- a/src/IcedViewportView.swift +++ b/src/IcedViewportView.swift @@ -253,6 +253,18 @@ class IcedViewportView: NSView { } } + func setLineIndicator(_ mode: String) { + guard let h = viewportHandle else { return } + mode.withCString { cstr in + viewport_set_line_indicator(h, cstr) + } + } + + func setGutterRainbow(_ enabled: Bool) { + guard let h = viewportHandle else { return } + viewport_set_gutter_rainbow(h, enabled) + } + /// Returns 0 = Live, 1 = Editor, 2 = View. func renderMode() -> UInt32 { guard let h = viewportHandle else { return 0 } diff --git a/src/SettingsView.swift b/src/SettingsView.swift index 01cbf95..88e9aeb 100644 --- a/src/SettingsView.swift +++ b/src/SettingsView.swift @@ -32,6 +32,7 @@ enum LineIndicatorMode: String, CaseIterable { struct SettingsView: View { @State private var themeMode: String = ConfigManager.shared.themeMode @State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode + @State private var gutterRainbow: Bool = ConfigManager.shared.gutterRainbow @State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory var body: some View { @@ -53,6 +54,7 @@ struct SettingsView: View { } } .pickerStyle(.segmented) + Toggle("Gutter rainbow", isOn: $gutterRainbow) } Section("Auto-Save") { @@ -72,7 +74,7 @@ struct SettingsView: View { } } .formStyle(.grouped) - .frame(width: 400, height: 260) + .frame(width: 400, height: 300) .background(Color(ns: palette.base)) .onChange(of: themeMode) { ConfigManager.shared.themeMode = themeMode @@ -83,6 +85,10 @@ struct SettingsView: View { ConfigManager.shared.lineIndicatorMode = lineIndicatorMode NotificationCenter.default.post(name: .settingsChanged, object: nil) } + .onChange(of: gutterRainbow) { + ConfigManager.shared.gutterRainbow = gutterRainbow + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } .onChange(of: autoSaveDir) { ConfigManager.shared.autoSaveDirectory = autoSaveDir } diff --git a/src/Theme.swift b/src/Theme.swift index 09ed2ed..99e31f1 100644 --- a/src/Theme.swift +++ b/src/Theme.swift @@ -73,20 +73,20 @@ struct Theme { text: NSColor(red: 0.965, green: 0.954, blue: 0.969, alpha: 1), subtext0: NSColor(red: 0.679, green: 0.668, blue: 0.725, alpha: 1), subtext1: NSColor(red: 0.824, green: 0.813, blue: 0.852, alpha: 1), - red: NSColor(red: 0.914, green: 0.376, blue: 0.376, alpha: 1), - maroon: NSColor(red: 0.949, green: 0.416, blue: 0.584, alpha: 1), - peach: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), - yellow: NSColor(red: 0.988, green: 0.831, blue: 0.349, alpha: 1), - green: NSColor(red: 0.403, green: 0.972, blue: 0.534, alpha: 1), + red: NSColor(red: 0.973, green: 0.545, blue: 0.545, alpha: 1), + maroon: NSColor(red: 0.933, green: 0.506, blue: 0.639, alpha: 1), + peach: NSColor(red: 1.000, green: 0.667, blue: 0.396, alpha: 1), + yellow: NSColor(red: 1.000, green: 0.886, blue: 0.486, alpha: 1), + green: NSColor(red: 0.592, green: 0.925, blue: 0.671, alpha: 1), teal: NSColor(red: 0.310, green: 1.000, blue: 0.882, alpha: 1), - sky: NSColor(red: 0.403, green: 0.813, blue: 0.972, alpha: 1), + sky: NSColor(red: 0.404, green: 0.812, blue: 0.973, alpha: 1), sapphire: NSColor(red: 0.384, green: 0.635, blue: 0.949, alpha: 1), - blue: NSColor(red: 0.337, green: 0.475, blue: 0.988, alpha: 1), - lavender: NSColor(red: 1.000, green: 0.718, blue: 0.937, alpha: 1), - mauve: NSColor(red: 0.635, green: 0.282, blue: 0.980, alpha: 1), - pink: NSColor(red: 0.973, green: 0.345, blue: 0.718, alpha: 1), - flamingo: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), - rosewater: NSColor(red: 0.984, green: 0.639, blue: 0.757, alpha: 1) + blue: NSColor(red: 0.310, green: 0.643, blue: 0.992, alpha: 1), + lavender: NSColor(red: 0.957, green: 0.737, blue: 0.373, alpha: 1), + mauve: NSColor(red: 0.741, green: 0.494, blue: 0.984, alpha: 1), + pink: NSColor(red: 0.988, green: 0.545, blue: 0.808, alpha: 1), + flamingo: NSColor(red: 1.000, green: 0.718, blue: 0.937, alpha: 1), + rosewater: NSColor(red: 0.976, green: 0.639, blue: 0.984, alpha: 1) ) static let latte = CatppuccinPalette( diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 7383bdf..88ac8a0 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -13,10 +13,18 @@ #include #include +#define BASE_BOOST 0.30 + +#define THRESHOLD_PX 6.0 + #define EVAL_RESULT_KIND 24 #define EVAL_ERROR_KIND 25 +#define USER_IDENT_PALETTE_SIZE 8 + +#define USER_IDENT_HOP 3 + typedef struct TextPos TextPos; typedef struct ViewportHandle ViewportHandle; @@ -59,6 +67,10 @@ void viewport_free_string(char *s); void viewport_set_theme(struct ViewportHandle *handle, const char *name); +void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode); + +void viewport_set_gutter_rainbow(struct ViewportHandle *handle, bool enabled); + void viewport_send_command(struct ViewportHandle *handle, uint32_t command); /** diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index f25461e..71ecabc 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -12,6 +12,7 @@ use iced_wgpu::core::{ use iced_widget::canvas; use iced_widget::container; use iced_widget::markdown; +use iced_widget::MouseArea; use crate::text_widget::{self, Action, AnchoredItem, Binding, Cursor, KeyPress, Motion, Position, Status}; use iced_widget::text_input; use iced_wgpu::core::text::highlighter::Format; @@ -21,6 +22,7 @@ use crate::block::{Block as BlockTrait, ViewCtx}; use crate::blocks::{self, BoxedBlock}; use crate::heading_block::HeadingBlock; 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}; @@ -38,6 +40,29 @@ pub enum RenderMode { View, } +/// User-facing line-number gutter / cursorline behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineIndicator { + /// Absolute line numbers, full-row cursorline band. + On, + /// Hidden — no line numbers, no cursorline band. The gutter strip + /// stays at its layout width so the editor doesn't reflow. + Off, + /// Vim-style: relative line numbers (cursor line shows its absolute + /// number, others show signed distance), cursorline band on. + Vim, +} + +impl LineIndicator { + pub fn from_str(s: &str) -> Self { + match s { + "off" => LineIndicator::Off, + "vim" => LineIndicator::Vim, + _ => LineIndicator::On, + } + } +} + #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { @@ -117,9 +142,44 @@ pub enum Message { IndentTab, OutdentTab, SetRenderMode(RenderMode), + /// Mouse pressed on an inline `/=` result. Starts the long-press timer. + InlineResultPress { block_id: crate::selection::BlockId, after_line: usize }, + /// Mouse released anywhere after pressing on an inline result. Cancels + /// any pending long-press that hasn't fired yet. + InlineResultRelease, + /// Double-clicked an inline `/=` result. Copies the source line + result + /// to clipboard AND drops a `let = result` template two lines down. + InlineResultDoubleClick { block_id: crate::selection::BlockId, after_line: usize }, } pub const RESULT_PREFIX: &str = "→ "; + +/// Long-press / double-click state for the click-and-hold-on-result gesture. +#[derive(Debug, Clone)] +pub struct InlinePressState { + pub block_id: crate::selection::BlockId, + pub after_line: usize, + pub started_at: Instant, + pub fired_long_press: bool, +} + +const LONG_PRESS_MS: u128 = 300; + +/// Write `s` to the macOS system clipboard via `pbcopy`. Mirrors the +/// implementation in `handle.rs::MacClipboard::write` so the editor can copy +/// without threading a clipboard handle through update(). +fn pbcopy(s: &str) { + use std::io::Write; + if let Ok(mut child) = std::process::Command::new("pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(s.as_bytes()); + } + let _ = child.wait(); + } +} pub const ERROR_PREFIX: &str = "⚠ "; const EVAL_DEBOUNCE_MS: u128 = 300; @@ -319,6 +379,19 @@ pub struct EditorState { /// Cells whose raw text starts with `/=` and are not being edited render /// the computed value instead; anything not in this map renders raw. pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, + + /// Active long-press / pending-result-gesture state. Set by + /// `InlineResultPress`, cleared by `InlineResultRelease` / + /// `InlineResultDoubleClick`. `tick()` checks the elapsed time to fire + /// the copy when it crosses `LONG_PRESS_MS`. + pub inline_press: Option, + + /// Line-indicator preference: controls cursorline band + relative-vs- + /// absolute line numbers. Pushed in from Swift via FFI. + pub line_indicator: LineIndicator, + /// Whether the gutter line numbers cycle through the rainbow palette + /// based on distance from the cursor. Independent of `line_indicator`. + pub gutter_rainbow: bool, } /// Per-eval table name→id bookkeeping. `keys` is every alias a table is @@ -428,6 +501,9 @@ impl EditorState { computed_tables: Vec::new(), computed_trees: Vec::new(), computed_cells: HashMap::new(), + inline_press: None, + line_indicator: LineIndicator::On, + gutter_rainbow: true, } } @@ -1321,6 +1397,20 @@ impl EditorState { self.eval_dirty = false; self.run_eval(); } + // Fire the long-press copy at the threshold — if the user is still + // holding past LONG_PRESS_MS without having released, double-clicked, + // or moved off, drop the result onto the clipboard. + let due = self.inline_press.as_ref().is_some_and(|s| { + !s.fired_long_press && s.started_at.elapsed().as_millis() >= LONG_PRESS_MS + }); + if due { + if let Some(s) = self.inline_press.as_mut() { + s.fired_long_press = true; + let bid = s.block_id; + let line = s.after_line; + self.copy_inline_result(bid, line); + } + } } /// True if an eval debounce is still pending. Used by handle::render to keep @@ -1328,6 +1418,7 @@ impl EditorState { /// is arriving, so tick() eventually fires run_eval. pub fn has_pending_eval(&self) -> bool { self.eval_dirty + || self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press) } fn reparse(&mut self) { @@ -2918,9 +3009,100 @@ impl EditorState { self.set_focused_block(idx); } } + Message::InlineResultPress { block_id, after_line } => { + self.inline_press = Some(InlinePressState { + block_id, + after_line, + started_at: Instant::now(), + fired_long_press: false, + }); + } + Message::InlineResultRelease => { + self.inline_press = None; + } + Message::InlineResultDoubleClick { block_id, after_line } => { + self.inline_press = None; + self.handle_result_extract(block_id, after_line); + } } } + /// Look up the inline result for `(block_id, after_line)` and return its + /// raw value text (the part after the `→ ` prefix). `None` if no result + /// is attached or the result is an error. + fn inline_result_value(&self, block_id: crate::selection::BlockId, after_line: usize) -> Option { + let r = self.eval_results.iter().find(|r| { + r.anchor.block_id == block_id && r.anchor.after_line == after_line && !r.is_error + })?; + Some(r.text.trim_start_matches(RESULT_PREFIX).trim().to_string()) + } + + /// Read line `line_idx` from the TextBlock with the given id, if any. + fn read_line_at(&self, block_id: crate::selection::BlockId, line_idx: usize) -> Option { + let block = self.registry.get(&block_id)?; + let tb = block.as_any().downcast_ref::()?; + tb.content.line(line_idx).map(|l| l.text.to_string()) + } + + /// Copy `{line} → {value}` to clipboard. Used by both long-press (just + /// copy) and double-click (copy then insert template). + fn copy_inline_result(&self, block_id: crate::selection::BlockId, after_line: usize) { + let value = match self.inline_result_value(block_id, after_line) { + Some(v) => v, + None => return, + }; + let line = self.read_line_at(block_id, after_line).unwrap_or_default(); + let trimmed = line.trim_end(); + let clip = format!("{trimmed} {RESULT_PREFIX}{value}"); + pbcopy(&clip); + } + + /// Double-click on a result: copy + drop a `let = value` line two lines + /// below the source `/=`. Cursor lands right after `let ` so the user can + /// type the variable name. + fn handle_result_extract(&mut self, block_id: crate::selection::BlockId, after_line: usize) { + let value = match self.inline_result_value(block_id, after_line) { + Some(v) => v, + None => return, + }; + self.copy_inline_result(block_id, after_line); + + let block_idx = match self.layout.iter().position(|id| *id == block_id) { + Some(i) => i, + None => return, + }; + // Only TextBlocks accept text-buffer mutations through this path. + if self.text_block_at(block_idx).is_none() { return; } + + self.push_undo_snapshot(); + self.redo_stack.clear(); + self.set_focused_block(block_idx); + + // Move cursor to end of the source `/=` line. + let content = self.content_mut(); + content.perform(Action::Move(Motion::DocumentStart)); + for _ in 0..after_line { + content.perform(Action::Move(Motion::Down)); + } + content.perform(Action::Move(Motion::End)); + + // Drop a blank line then `let = value`. Two spaces between `let` and + // `=` — the user types the variable name into the gap. + let paste = format!("\n\nlet = {value}"); + content.perform(Action::Edit(text_widget::Edit::Paste(Arc::new(paste)))); + + // Cursor is at the end of `value`. Walk back past `value`, the `=`, + // and the two flanking spaces — landing right after `let `. + let back = 3 + value.chars().count(); + for _ in 0..back { + content.perform(Action::Move(Motion::Left)); + } + + self.last_edit = Instant::now(); + self.eval_dirty = true; + self.reparse(); + } + pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview { let settings = markdown::Settings::with_text_size(self.font_size, md_style()); @@ -2966,7 +3148,7 @@ impl EditorState { iced_widget::text(format!("{mode_label} Ln {line}, Col {col}")) .font(Font::MONOSPACE) .size(11.0) - .color(Color::WHITE) + .color(oklab::lighten_for_size(Color::WHITE, 11.0)) .into(), ]) ) @@ -3084,6 +3266,7 @@ impl EditorState { font_size: self.font_size, top_pad: title_bar_h, item_offsets: self.item_offsets(tb.id), + indicator: self.line_indicator, }) .width(Length::Fill) .height(Length::Fill) @@ -3103,10 +3286,12 @@ impl EditorState { global_line_offset: 0, font_size: self.font_size, scroll_offset: self.scroll_offset, - cursor_line, + cursor_line: if is_focused { Some(cursor_line) } else { None }, top_pad: title_bar_h, line_decors: decors, item_offsets: self.item_offsets(tb.id), + indicator: self.line_indicator, + rainbow: self.gutter_rainbow, }; let gw = gutter.gutter_width(); @@ -3172,10 +3357,12 @@ impl EditorState { global_line_offset: global_line, font_size: self.font_size, scroll_offset: 0.0, - cursor_line, + cursor_line: if is_focused { Some(cursor_line) } else { None }, top_pad, line_decors: decors, item_offsets: self.item_offsets(tb.id), + indicator: self.line_indicator, + rainbow: self.gutter_rainbow, }; global_line += line_count; let gw = gutter.gutter_width(); @@ -3186,6 +3373,7 @@ impl EditorState { font_size: self.font_size, top_pad, item_offsets: self.item_offsets(tb.id), + indicator: self.line_indicator, }) .width(Length::Fill) .height(Length::Fixed(editor_h)) @@ -3377,16 +3565,27 @@ impl EditorState { match item { LayerItem::Inline(r) => { let color = if r.is_error { p.red } else { p.green }; - let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = - iced_widget::container( - iced_widget::text(&r.text) - .font(syntax::EDITOR_FONT) - .size(self.font_size) - .color(color) - ) - .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) - .width(Length::Fill) - .into(); + let inner = iced_widget::container( + iced_widget::text(&r.text) + .font(syntax::EDITOR_FONT) + .size(self.font_size) + .color(oklab::lighten_for_size(color, self.font_size)) + ) + .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) + .width(Length::Fill); + // Errors don't carry a copyable result value, so they + // don't get the gesture wrapper. + let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = if r.is_error { + inner.into() + } else { + let bid = r.anchor.block_id; + let line = r.anchor.after_line; + MouseArea::new(inner) + .on_press(Message::InlineResultPress { block_id: bid, after_line: line }) + .on_release(Message::InlineResultRelease) + .on_double_click(Message::InlineResultDoubleClick { block_id: bid, after_line: line }) + .into() + }; anchored.push(AnchoredItem { after_line: *after_line, height: item.element_height(lh, self.font_size), @@ -3404,7 +3603,7 @@ impl EditorState { let mut txt = iced_widget::text(cell) .font(syntax::EDITOR_FONT) .size(self.font_size) - .color(p.text); + .color(oklab::lighten_for_size(p.text, self.font_size)); if is_header { txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT }); } @@ -3599,7 +3798,7 @@ impl EditorState { iced_widget::text(match_label) .font(Font::MONOSPACE) .size(11.0) - .color(p.overlay1) + .color(oklab::lighten_for_size(p.overlay1, 11.0)) .into(); let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { @@ -3709,6 +3908,8 @@ struct Cursorline { 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 { @@ -3730,21 +3931,23 @@ impl canvas::Program for Cursorline { frame.fill_rectangle(Point::ORIGIN, bounds.size(), p.base); if let Some(line) = self.cursor_line { - 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, - ); + 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, + ); + } } } @@ -3757,10 +3960,24 @@ struct Gutter { global_line_offset: usize, font_size: f32, scroll_offset: f32, - cursor_line: usize, + /// 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 { @@ -3799,9 +4016,17 @@ impl canvas::Program for Gutter { ); } - let first_visible = (self.scroll_offset / lh).floor() as usize; - let sub_pixel = self.scroll_offset - first_visible as f32 * lh; 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(); @@ -3850,21 +4075,53 @@ impl canvas::Program for Gutter { ); frame.stroke(&path, canvas::Stroke::default() .with_width(1.0) - .with_color(p.overlay1)); + .with_color(oklab::lighten_for_size(p.overlay1, 1.0))); } LineDecor::None => {} } - let color = if line_idx == self.cursor_line { - p.overlay1 + // `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 { - p.surface2 + // 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: format!("{}", line_num + 1), + content: label, position: Point::new(gw - 8.0, y), max_width: gw, - color, + color: oklab::lighten_for_size(raw_color, self.font_size), size: Pixels(self.font_size), line_height: LineHeight::Relative(1.3), font: Font::MONOSPACE, diff --git a/viewport/src/heading_block.rs b/viewport/src/heading_block.rs index 93ce4f1..52fae78 100644 --- a/viewport/src/heading_block.rs +++ b/viewport/src/heading_block.rs @@ -7,6 +7,7 @@ use iced_wgpu::core::font::Weight; use iced_widget::canvas; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::oklab; use crate::palette; use crate::selection::{BlockId, InnerPath}; @@ -87,7 +88,7 @@ impl canvas::Program for He content: self.text.clone(), position: Point::new(8.0, 4.0), max_width: bounds.width - 16.0, - color, + color: oklab::lighten_for_size(color, self.font_size), size: Pixels(self.font_size), line_height: LineHeight::Relative(1.4), font: Font { weight: self.level.weight(), ..Font::DEFAULT }, diff --git a/viewport/src/hr_block.rs b/viewport/src/hr_block.rs index 7099f84..f970136 100644 --- a/viewport/src/hr_block.rs +++ b/viewport/src/hr_block.rs @@ -2,6 +2,7 @@ use iced_wgpu::core::{mouse, Element, Length, Point, Rectangle, Theme}; use iced_widget::canvas; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::oklab; use crate::palette; use crate::selection::{BlockId, InnerPath}; @@ -26,11 +27,12 @@ impl canvas::Program for HR Point::new(margin, y), Point::new(bounds.width - margin, y), ); + let stroke_w = 1.0; frame.stroke( &path, canvas::Stroke::default() - .with_width(1.0) - .with_color(p.overlay0), + .with_width(stroke_w) + .with_color(oklab::lighten_for_size(p.overlay0, stroke_w)), ); vec![frame.into_geometry()] } diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index f52249c..eca25e3 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -9,6 +9,7 @@ mod handle; pub mod heading_block; pub mod hr_block; pub mod module; +pub mod oklab; pub mod palette; pub mod selection; pub mod sidecar; @@ -213,6 +214,27 @@ pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c } } +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_line_indicator(handle: *mut ViewportHandle, mode: *const c_char) { + let s = if mode.is_null() { + "on" + } else { + unsafe { CStr::from_ptr(mode) }.to_str().unwrap_or("on") + }; + if let Some(h) = unsafe { handle.as_mut() } { + h.state.line_indicator = editor::LineIndicator::from_str(s); + h.needs_redraw = true; + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn viewport_set_gutter_rainbow(handle: *mut ViewportHandle, enabled: bool) { + if let Some(h) = unsafe { handle.as_mut() } { + h.state.gutter_rainbow = enabled; + h.needs_redraw = true; + } +} + #[unsafe(no_mangle)] pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u32) { let h = match unsafe { handle.as_mut() } { diff --git a/viewport/src/oklab.rs b/viewport/src/oklab.rs new file mode 100644 index 0000000..4e0f116 --- /dev/null +++ b/viewport/src/oklab.rs @@ -0,0 +1,221 @@ +//! Perceptually uniform color operations. +//! +//! Wraps Björn Ottosson's OKLab. Used to compensate for the apparent dimming +//! of small glyphs and thin strokes that arises from antialiased coverage +//! blending against a near-black background. The compensation is calibrated +//! by rendered pixel size: smaller -> bigger boost, with a knee at +//! `THRESHOLD_PX`. Hue and chroma are preserved; only L is adjusted. + +use crate::palette; +use iced_wgpu::core::Color; + +pub const BASE_BOOST: f32 = 0.30; +pub const THRESHOLD_PX: f32 = 6.0; + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } +} + +fn linear_rgb_to_oklab([r, g, b]: [f32; 3]) -> [f32; 3] { + let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + [ + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, + ] +} + +fn oklab_to_linear_rgb([l_, a, b]: [f32; 3]) -> [f32; 3] { + let l = l_ + 0.3963377774 * a + 0.2158037573 * b; + let m = l_ - 0.1055613458 * a - 0.0638541728 * b; + let s = l_ - 0.0894841775 * a - 1.2914855480 * b; + let l = l * l * l; + let m = m * m * m; + let s = s * s * s; + [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ] +} + +fn to_oklab(c: Color) -> [f32; 3] { + linear_rgb_to_oklab([srgb_to_linear(c.r), srgb_to_linear(c.g), srgb_to_linear(c.b)]) +} + +fn from_oklab(lab: [f32; 3], alpha: f32) -> Color { + let [r, g, b] = oklab_to_linear_rgb(lab); + Color { + r: linear_to_srgb(r.clamp(0.0, 1.0)), + g: linear_to_srgb(g.clamp(0.0, 1.0)), + b: linear_to_srgb(b.clamp(0.0, 1.0)), + a: alpha, + } +} + +/// Linear-with-knee curve: full boost at `size_px = 0`, zero at and above +/// `THRESHOLD_PX`. Returns the unsigned magnitude — the caller decides the +/// sign (positive = lighten on dark bg, negative = darken on light bg). +pub fn size_boost(size_px: f32) -> f32 { + (BASE_BOOST * (1.0 - size_px / THRESHOLD_PX)).max(0.0) +} + +/// Add `l_delta` to OKLab L while preserving chroma. +pub fn lighten(color: Color, l_delta: f32) -> Color { + if l_delta == 0.0 { + return color; + } + let mut lab = to_oklab(color); + lab[0] = (lab[0] + l_delta).clamp(0.0, 1.0); + from_oklab(lab, color.a) +} + +/// Compensate for AA coverage blending: brighten on dark backgrounds +/// (where AA dims), darken on light backgrounds (where AA washes out). +/// Identity when `size_px >= THRESHOLD_PX`. +pub fn lighten_for_size(color: Color, size_px: f32) -> Color { + let mag = size_boost(size_px); + if mag == 0.0 { + return color; + } + let delta = if palette::is_dark() { mag } else { -mag }; + lighten(color, delta) +} + +/// Hue-rotate a color by 180° in OKLab while preserving lightness and +/// chroma magnitude — produces the perceptual complement (red→cyan, +/// blue→amber, yellow→indigo, green→magenta). Used by the gutter to render +/// "lines above the cursor" as the inverse of the rainbow used below. +pub fn invert_hue(color: Color) -> Color { + let mut lab = to_oklab(color); + lab[1] = -lab[1]; + lab[2] = -lab[2]; + from_oklab(lab, color.a) +} + +/// Drain chroma toward zero by `t` (0.0 = identity, 1.0 = grey at the same +/// L). Lightness is preserved, so a "faded red" stays as bright as the red +/// it came from — it just stops being red. Used by the gutter rainbow to +/// dissolve into neutral without dimming. +pub fn desaturate(color: Color, t: f32) -> Color { + let k = 1.0 - t.clamp(0.0, 1.0); + if k == 1.0 { return color; } + let mut lab = to_oklab(color); + lab[1] *= k; + lab[2] *= k; + from_oklab(lab, color.a) +} + +/// Perceptual interpolation between two colors. `t = 0` returns `a`, +/// `t = 1` returns `b`. +pub fn mix(a: Color, b: Color, t: f32) -> Color { + let la = to_oklab(a); + let lb = to_oklab(b); + let lab = [ + la[0] + (lb[0] - la[0]) * t, + la[1] + (lb[1] - la[1]) * t, + la[2] + (lb[2] - la[2]) * t, + ]; + from_oklab(lab, a.a + (b.a - a.a) * t) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx_eq(a: f32, b: f32, eps: f32) -> bool { + (a - b).abs() < eps + } + + fn color_eq(a: Color, b: Color, eps: f32) -> bool { + approx_eq(a.r, b.r, eps) + && approx_eq(a.g, b.g, eps) + && approx_eq(a.b, b.b, eps) + && approx_eq(a.a, b.a, eps) + } + + fn kicad_palette() -> Vec { + let p = &palette::KICAD; + vec![ + p.rosewater, p.flamingo, p.pink, p.mauve, p.red, p.maroon, p.peach, + p.yellow, p.green, p.teal, p.sky, p.sapphire, p.blue, p.lavender, + p.text, p.subtext1, p.subtext0, p.overlay2, p.overlay1, p.overlay0, + p.surface2, p.surface1, p.surface0, p.base, p.mantle, p.crust, + ] + } + + #[test] + fn roundtrip_kicad() { + for c in kicad_palette() { + let lab = to_oklab(c); + let back = from_oklab(lab, c.a); + assert!(color_eq(c, back, 1e-3), "roundtrip failed: {:?} -> {:?}", c, back); + } + } + + #[test] + fn size_boost_dark_theme_curve() { + palette::set_theme("kicad"); + assert!(approx_eq(size_boost(0.0), BASE_BOOST, 1e-6)); + assert!(approx_eq(size_boost(THRESHOLD_PX), 0.0, 1e-6)); + assert!(approx_eq(size_boost(THRESHOLD_PX * 2.0), 0.0, 1e-6)); + assert!(approx_eq(size_boost(THRESHOLD_PX / 2.0), BASE_BOOST / 2.0, 1e-6)); + } + + #[test] + fn size_boost_ignores_theme() { + palette::set_theme("latte"); + assert!(approx_eq(size_boost(0.0), BASE_BOOST, 1e-6)); + palette::set_theme("kicad"); + assert!(approx_eq(size_boost(0.0), BASE_BOOST, 1e-6)); + } + + #[test] + fn lighten_for_size_darkens_on_light() { + palette::set_theme("latte"); + let c = palette::LATTE.text; + let out = lighten_for_size(c, 1.0); + let lab_in = to_oklab(c); + let lab_out = to_oklab(out); + assert!(lab_out[0] < lab_in[0], "L should decrease on light theme"); + palette::set_theme("kicad"); + } + + #[test] + fn lighten_for_size_identity_above_threshold() { + palette::set_theme("kicad"); + let c = palette::KICAD.red; + // Above threshold: function short-circuits, returns input verbatim. + assert_eq!(lighten_for_size(c, THRESHOLD_PX + 1.0), c); + assert_eq!(lighten_for_size(c, THRESHOLD_PX), c); + } + + #[test] + fn lighten_preserves_chroma() { + // Use a mid-gamut swatch so an L+ bump doesn't clip in sRGB. + let c = palette::KICAD.overlay1; + let lab = to_oklab(c); + let bright = lighten(c, 0.10); + let lab2 = to_oklab(bright); + assert!(approx_eq(lab2[0], lab[0] + 0.10, 5e-3), "L: {} vs {}", lab2[0], lab[0] + 0.10); + assert!(approx_eq(lab2[1], lab[1], 5e-3), "a drift: {} vs {}", lab2[1], lab[1]); + assert!(approx_eq(lab2[2], lab[2], 5e-3), "b drift: {} vs {}", lab2[2], lab[2]); + } + + #[test] + fn mix_endpoints() { + let a = palette::KICAD.red; + let b = palette::KICAD.blue; + assert!(color_eq(mix(a, b, 0.0), a, 1e-3)); + assert!(color_eq(mix(a, b, 1.0), b, 1e-3)); + } +} diff --git a/viewport/src/palette.rs b/viewport/src/palette.rs index 88438ac..b3a7219 100644 --- a/viewport/src/palette.rs +++ b/viewport/src/palette.rs @@ -64,20 +64,21 @@ pub static MOCHA: Palette = Palette { /// contrast. The signature KiCad schematic-editor feel: vivid greens, /// bright cyans, punchy reds and yellows on a deep navy base. pub static KICAD: Palette = Palette { - rosewater: Color::from_rgb(0.984, 0.639, 0.757), - flamingo: Color::from_rgb(0.965, 0.533, 0.404), - pink: Color::from_rgb(0.973, 0.345, 0.718), - mauve: Color::from_rgb(0.635, 0.282, 0.980), - red: Color::from_rgb(0.914, 0.376, 0.376), - maroon: Color::from_rgb(0.949, 0.416, 0.584), - peach: Color::from_rgb(0.965, 0.533, 0.404), - yellow: Color::from_rgb(0.988, 0.831, 0.349), - green: Color::from_rgb(0.403, 0.972, 0.534), - teal: Color::from_rgb(0.310, 1.000, 0.882), - sky: Color::from_rgb(0.403, 0.813, 0.972), - sapphire: Color::from_rgb(0.384, 0.635, 0.949), - blue: Color::from_rgb(0.337, 0.475, 0.988), - lavender: Color::from_rgb(1.000, 0.718, 0.937), + // From acord-palette-used.svg, 13 user-kept swatches (rounded to f32). + rosewater: Color::from_rgb(0.976, 0.639, 0.984), // (249,163,251) light pink + flamingo: Color::from_rgb(1.000, 0.718, 0.937), // (255,183,239) pink-light + pink: Color::from_rgb(0.988, 0.545, 0.808), // (252,139,206) + mauve: Color::from_rgb(0.741, 0.494, 0.984), // (189,126,251) + red: Color::from_rgb(0.973, 0.545, 0.545), // (248,139,139) + maroon: Color::from_rgb(0.933, 0.506, 0.639), // (238,129,163) + peach: Color::from_rgb(1.000, 0.667, 0.396), // (255,170,101) + yellow: Color::from_rgb(1.000, 0.886, 0.486), // (255,226,124) + green: Color::from_rgb(0.592, 0.925, 0.671), // (151,236,171) + teal: Color::from_rgb(0.310, 1.000, 0.882), // (79,255,225) + sky: Color::from_rgb(0.404, 0.812, 0.973), // (103,207,248) + sapphire: Color::from_rgb(0.384, 0.635, 0.949), // unchanged — unused slot + blue: Color::from_rgb(0.310, 0.643, 0.992), // (79,164,253) + lavender: Color::from_rgb(0.957, 0.737, 0.373), // (244,188,95) amber accent text: Color::from_rgb(0.965, 0.954, 0.969), subtext1: Color::from_rgb(0.824, 0.813, 0.852), subtext0: Color::from_rgb(0.679, 0.668, 0.725), diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 53658b9..94b4a7e 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -39,8 +39,16 @@ const COR_TYPE_ANN: u8 = 64; // references resolve to the same slot so the name reads the same color // throughout the document. const USER_IDENT_BASE: u8 = 70; -const USER_IDENT_PALETTE_SIZE: u8 = 8; -const USER_IDENT_HOP: u32 = 3; +pub const USER_IDENT_PALETTE_SIZE: u8 = 8; +pub const USER_IDENT_HOP: u32 = 3; + +/// The 8-slot rainbow shared by user-identifier highlighting and the gutter +/// line-number rainbow. Same hop-of-3 walk through the same palette so the +/// two systems read as one design. +pub fn rainbow_color(idx: u32) -> Color { + let slot = ((idx * USER_IDENT_HOP) % USER_IDENT_PALETTE_SIZE as u32) as u8; + highlight_color(USER_IDENT_BASE + slot) +} const MD_HEADING_MARKER: u8 = 26; const MD_H1: u8 = 27; diff --git a/viewport/src/table_block.rs b/viewport/src/table_block.rs index fa31738..bb246e9 100644 --- a/viewport/src/table_block.rs +++ b/viewport/src/table_block.rs @@ -10,6 +10,7 @@ use iced_widget::MouseArea; use iced_wgpu::core::mouse::Interaction; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::oklab; use crate::palette; use crate::selection::{BlockId, InnerPath}; use crate::syntax::EDITOR_FONT; @@ -1056,7 +1057,7 @@ where text(letter) .size(chrome_font) .font(EDITOR_FONT) - .color(p.overlay0) + .color(oklab::lighten_for_size(p.overlay0, chrome_font)) ) .width(Length::Fixed(*w)) .height(Length::Fixed(header_h)) @@ -1108,7 +1109,7 @@ where text(label) .size(chrome_font) .font(EDITOR_FONT) - .color(p.overlay0) + .color(oklab::lighten_for_size(p.overlay0, chrome_font)) ) .width(Length::Fixed(ROW_NUMBER_WIDTH)) .padding(Padding { top: 4.0, right: 6.0, bottom: 0.0, left: 0.0 }) @@ -1196,7 +1197,7 @@ where let display = text(display_text) .size(font_size) .font(font) - .color(label_color); + .color(oklab::lighten_for_size(label_color, font_size)); let container_style = move |_theme: &Theme| { let ws = palette::widget_surface(); @@ -1212,7 +1213,7 @@ where container::Style { background, border: cell_border(), - text_color: Some(label_color), + text_color: Some(oklab::lighten_for_size(label_color, font_size)), shadow: Shadow::default(), snap: false, } diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index a091ae4..9f797b3 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -113,12 +113,16 @@ fn total_items_height(items: &[AnchoredItem<'_, M, T>]) -> f32 { } /// Build iced Spans from a LayoutRun's glyphs, grouping consecutive glyphs by color. +/// `font_size_px` drives perceptual brightness compensation against the +/// dark-theme background — see `oklab::lighten_for_size`. fn build_color_spans<'a>( text: &'a str, glyphs: &[cosmic_text::LayoutGlyph], + font_size_px: f32, ) -> Vec> { - fn cosmic_to_iced(c: cosmic_text::Color) -> Color { - Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0) + fn cosmic_to_iced(c: cosmic_text::Color, font_size_px: f32) -> Color { + let raw = Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0); + crate::oklab::lighten_for_size(raw, font_size_px) } if glyphs.is_empty() { @@ -135,7 +139,7 @@ fn build_color_spans<'a>( if end > seg_start { let mut span = Span::new(&text[seg_start..end]); if let Some(c) = cur_color { - span = span.color(cosmic_to_iced(c)); + span = span.color(cosmic_to_iced(c, font_size_px)); } spans.push(span); } @@ -147,7 +151,7 @@ fn build_color_spans<'a>( if seg_start < text.len() { let mut span = Span::new(&text[seg_start..]); if let Some(c) = cur_color { - span = span.color(cosmic_to_iced(c)); + span = span.color(cosmic_to_iced(c, font_size_px)); } spans.push(span); } @@ -1224,7 +1228,7 @@ where buffer.lines[i].layout_opt() .map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect()) .unwrap_or_default(); - let spans = build_color_spans(line_text, &glyphs); + let spans = build_color_spans(line_text, &glyphs, f32::from(text_size)); paras.push(iced_graphics::text::Paragraph::with_spans(Text { content: spans.as_slice(), bounds: Size::new(text_bounds.width, line_h), diff --git a/viewport/src/tree_block.rs b/viewport/src/tree_block.rs index f6e5a28..01ec6fe 100644 --- a/viewport/src/tree_block.rs +++ b/viewport/src/tree_block.rs @@ -7,6 +7,7 @@ use iced_widget::canvas; use iced_widget::container; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; +use crate::oklab; use crate::palette; use crate::selection::{BlockId, InnerPath}; @@ -172,7 +173,7 @@ impl canvas::Program for Tr &connector, canvas::Stroke::default() .with_width(1.0) - .with_color(connector_color), + .with_color(oklab::lighten_for_size(connector_color, 1.0)), ); if !node.is_last { @@ -184,7 +185,7 @@ impl canvas::Program for Tr &vert, canvas::Stroke::default() .with_width(1.0) - .with_color(connector_color), + .with_color(oklab::lighten_for_size(connector_color, 1.0)), ); } } @@ -209,7 +210,7 @@ impl canvas::Program for Tr content: display, position: Point::new(indent_x, y + 2.0), max_width: bounds.width - indent_x, - color: text_color, + color: oklab::lighten_for_size(text_color, self.font_size), size: Pixels(self.font_size), line_height: LineHeight::Relative(1.3), font: Font::MONOSPACE,