forked from jess/Acord
1
0
Fork 0

Switched to Oklab color space + a few other tricks to get Cosmic Text + wgpu to bend to my will.

Fixed VIM mode + added rainbow formula to gutter since it worked so great for args/terms.
This commit is contained in:
jess 2026-04-16 15:28:22 -07:00
parent ccffb9149d
commit 03284a694e
17 changed files with 724 additions and 107 deletions

View File

@ -67,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
observeDocumentText() observeDocumentText()
syncThemeToViewport() syncThemeToViewport()
syncGutterPrefsToViewport()
startAutosaveTimer() startAutosaveTimer()
DocumentBrowserController.shared = DocumentBrowserController(appState: appState) DocumentBrowserController.shared = DocumentBrowserController(appState: appState)
@ -582,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
@objc private func settingsDidChange() { @objc private func settingsDidChange() {
window.backgroundColor = Theme.current.base window.backgroundColor = Theme.current.base
syncThemeToViewport() syncThemeToViewport()
syncGutterPrefsToViewport()
window.contentView?.needsDisplay = true window.contentView?.needsDisplay = true
} }
@ -599,6 +601,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
viewport?.setTheme(name) viewport?.setTheme(name)
} }
private func syncGutterPrefsToViewport() {
viewport?.setLineIndicator(ConfigManager.shared.lineIndicatorMode)
viewport?.setGutterRainbow(ConfigManager.shared.gutterRainbow)
}
@objc private func toggleBrowser() { @objc private func toggleBrowser() {
DocumentBrowserController.shared?.toggle() DocumentBrowserController.shared?.toggle()
} }

View File

@ -132,6 +132,11 @@ class AppState: ObservableObject {
private var autoSaveDirty = false private var autoSaveDirty = false
private var autoSaveCoolingDown = false private var autoSaveCoolingDown = false
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave") 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() { init() {
let id = bridge.newDocument() let id = bridge.newDocument()
@ -158,10 +163,10 @@ class AppState: ObservableObject {
let text = documentText let text = documentText
let noteID = currentNoteID let noteID = currentNoteID
let title = extractTitle(from: text) let url = resolveAutoSaveURL(noteID: noteID, text: text)
autoSaveQueue.async { [weak self] in 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 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.autoSaveCoolingDown = false self.autoSaveCoolingDown = false
@ -244,19 +249,47 @@ class AppState: ObservableObject {
return cleaned.isEmpty ? UUID().uuidString : cleaned return cleaned.isEmpty ? UUID().uuidString : cleaned
} }
private func writeAutoSaveFile(noteID: UUID, title: String, text: String) { /// Resolve the autosave file URL for `noteID`. First call for a noteID
let dir = ConfigManager.shared.autoSaveDirectory /// derives a filename from the title (or the UUID when there's no title);
let dirURL = URL(fileURLWithPath: dir) /// 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) try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
let title = extractTitle(from: text)
let filename: String let filename: String
if title == "Untitled" { if title == "Untitled" {
filename = noteID.uuidString.lowercased() filename = noteID.uuidString.lowercased()
} else { } else {
filename = sanitizeFilename(title) filename = sanitizeFilename(title)
} }
let fileURL = dirURL.appendingPathComponent(filename + ".md") let url = dirURL.appendingPathComponent(filename + ".md")
try? text.write(to: fileURL, atomically: true, encoding: .utf8) 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 `<!-- acord-archive ... -->` 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 // MARK: - Note operations
@ -313,14 +346,9 @@ class AppState: ObservableObject {
} }
func saveNote() { func saveNote() {
let textToSave: String bridge.setText(currentNoteID, text: documentText)
if currentFileFormat.isCSV {
textToSave = markdownTableToCSV(documentText)
} else {
textToSave = documentText
}
bridge.setText(currentNoteID, text: textToSave)
if let url = currentFileURL { if let url = currentFileURL {
let textToSave = textForExternalSave(format: currentFileFormat)
try? textToSave.write(to: url, atomically: true, encoding: .utf8) try? textToSave.write(to: url, atomically: true, encoding: .utf8)
} }
let _ = bridge.cacheSave(currentNoteID) let _ = bridge.cacheSave(currentNoteID)
@ -330,18 +358,29 @@ class AppState: ObservableObject {
func saveNoteToFile(_ url: URL) { func saveNoteToFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent) let format = FileFormat.from(filename: url.lastPathComponent)
let textToSave: String let textToSave = textForExternalSave(format: format)
if format.isCSV {
textToSave = markdownTableToCSV(documentText)
} else {
textToSave = documentText
}
try? textToSave.write(to: url, atomically: true, encoding: .utf8) try? textToSave.write(to: url, atomically: true, encoding: .utf8)
currentFileURL = url currentFileURL = url
currentFileFormat = format 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 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) { func loadNoteFromFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent) let format = FileFormat.from(filename: url.lastPathComponent)
if let (id, text) = bridge.loadNote(path: url.path) { if let (id, text) = bridge.loadNote(path: url.path) {
@ -353,6 +392,15 @@ class AppState: ObservableObject {
} else { } else {
documentText = text 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 modified = false
let _ = bridge.cacheSave(id) let _ = bridge.cacheSave(id)
evaluate() evaluate()
@ -465,6 +513,9 @@ class AppState: ObservableObject {
func deleteNote(_ id: UUID) { func deleteNote(_ id: UUID) {
bridge.deleteNote(id) bridge.deleteNote(id)
if let url = autoSavePaths.removeValue(forKey: id) {
try? FileManager.default.removeItem(at: url)
}
if id == currentNoteID { if id == currentNoteID {
newNote() newNote()
} }
@ -474,6 +525,9 @@ class AppState: ObservableObject {
func deleteNotes(_ ids: Set<UUID>) { func deleteNotes(_ ids: Set<UUID>) {
for id in ids { for id in ids {
bridge.deleteNote(id) bridge.deleteNote(id)
if let url = autoSavePaths.removeValue(forKey: id) {
try? FileManager.default.removeItem(at: url)
}
} }
if ids.contains(currentNoteID) { if ids.contains(currentNoteID) {
let remaining = noteList.first { !ids.contains($0.id) } let remaining = noteList.first { !ids.contains($0.id) }
@ -505,9 +559,9 @@ class AppState: ObservableObject {
/// state (including visible eval results). /// state (including visible eval results).
func writeAutosavedCopy(text: String) { func writeAutosavedCopy(text: String) {
let noteID = currentNoteID let noteID = currentNoteID
let title = extractTitle(from: text) let url = resolveAutoSaveURL(noteID: noteID, text: text)
autoSaveQueue.async { [weak self] in autoSaveQueue.async {
self?.writeAutoSaveFile(noteID: noteID, title: title, text: text) Self.writeAutoSaveFile(at: url, text: text)
} }
} }
@ -532,6 +586,9 @@ class AppState: ObservableObject {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { if trimmed.isEmpty {
bridge.deleteNote(id) bridge.deleteNote(id)
if let url = autoSavePaths.removeValue(forKey: id) {
try? FileManager.default.removeItem(at: url)
}
} }
} }
} }

View File

@ -53,6 +53,11 @@ class ConfigManager {
set { config["lineIndicatorMode"] = newValue; save() } set { config["lineIndicatorMode"] = newValue; save() }
} }
var gutterRainbow: Bool {
get { (config["gutterRainbow"] ?? "true") != "false" }
set { config["gutterRainbow"] = newValue ? "true" : "false"; save() }
}
var zoomLevel: CGFloat { var zoomLevel: CGFloat {
get { CGFloat(Double(config["zoomLevel"] ?? "0") ?? 0) } get { CGFloat(Double(config["zoomLevel"] ?? "0") ?? 0) }
set { config["zoomLevel"] = String(Double(newValue)); save() } set { config["zoomLevel"] = String(Double(newValue)); save() }

View File

@ -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. /// Returns 0 = Live, 1 = Editor, 2 = View.
func renderMode() -> UInt32 { func renderMode() -> UInt32 {
guard let h = viewportHandle else { return 0 } guard let h = viewportHandle else { return 0 }

View File

@ -32,6 +32,7 @@ enum LineIndicatorMode: String, CaseIterable {
struct SettingsView: View { struct SettingsView: View {
@State private var themeMode: String = ConfigManager.shared.themeMode @State private var themeMode: String = ConfigManager.shared.themeMode
@State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode @State private var lineIndicatorMode: String = ConfigManager.shared.lineIndicatorMode
@State private var gutterRainbow: Bool = ConfigManager.shared.gutterRainbow
@State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory @State private var autoSaveDir: String = ConfigManager.shared.autoSaveDirectory
var body: some View { var body: some View {
@ -53,6 +54,7 @@ struct SettingsView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
Toggle("Gutter rainbow", isOn: $gutterRainbow)
} }
Section("Auto-Save") { Section("Auto-Save") {
@ -72,7 +74,7 @@ struct SettingsView: View {
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
.frame(width: 400, height: 260) .frame(width: 400, height: 300)
.background(Color(ns: palette.base)) .background(Color(ns: palette.base))
.onChange(of: themeMode) { .onChange(of: themeMode) {
ConfigManager.shared.themeMode = themeMode ConfigManager.shared.themeMode = themeMode
@ -83,6 +85,10 @@ struct SettingsView: View {
ConfigManager.shared.lineIndicatorMode = lineIndicatorMode ConfigManager.shared.lineIndicatorMode = lineIndicatorMode
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
.onChange(of: gutterRainbow) {
ConfigManager.shared.gutterRainbow = gutterRainbow
NotificationCenter.default.post(name: .settingsChanged, object: nil)
}
.onChange(of: autoSaveDir) { .onChange(of: autoSaveDir) {
ConfigManager.shared.autoSaveDirectory = autoSaveDir ConfigManager.shared.autoSaveDirectory = autoSaveDir
} }

View File

@ -73,20 +73,20 @@ struct Theme {
text: NSColor(red: 0.965, green: 0.954, blue: 0.969, alpha: 1), 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), 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), 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), red: NSColor(red: 0.973, green: 0.545, blue: 0.545, alpha: 1),
maroon: NSColor(red: 0.949, green: 0.416, blue: 0.584, alpha: 1), maroon: NSColor(red: 0.933, green: 0.506, blue: 0.639, alpha: 1),
peach: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), peach: NSColor(red: 1.000, green: 0.667, blue: 0.396, alpha: 1),
yellow: NSColor(red: 0.988, green: 0.831, blue: 0.349, alpha: 1), yellow: NSColor(red: 1.000, green: 0.886, blue: 0.486, alpha: 1),
green: NSColor(red: 0.403, green: 0.972, blue: 0.534, 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), 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), 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), blue: NSColor(red: 0.310, green: 0.643, blue: 0.992, alpha: 1),
lavender: NSColor(red: 1.000, green: 0.718, blue: 0.937, alpha: 1), lavender: NSColor(red: 0.957, green: 0.737, blue: 0.373, alpha: 1),
mauve: NSColor(red: 0.635, green: 0.282, blue: 0.980, alpha: 1), mauve: NSColor(red: 0.741, green: 0.494, blue: 0.984, alpha: 1),
pink: NSColor(red: 0.973, green: 0.345, blue: 0.718, alpha: 1), pink: NSColor(red: 0.988, green: 0.545, blue: 0.808, alpha: 1),
flamingo: NSColor(red: 0.965, green: 0.533, blue: 0.404, alpha: 1), flamingo: NSColor(red: 1.000, green: 0.718, blue: 0.937, alpha: 1),
rosewater: NSColor(red: 0.984, green: 0.639, blue: 0.757, alpha: 1) rosewater: NSColor(red: 0.976, green: 0.639, blue: 0.984, alpha: 1)
) )
static let latte = CatppuccinPalette( static let latte = CatppuccinPalette(

View File

@ -13,10 +13,18 @@
#include <stdint.h> #include <stdint.h>
#include <stdlib.h> #include <stdlib.h>
#define BASE_BOOST 0.30
#define THRESHOLD_PX 6.0
#define EVAL_RESULT_KIND 24 #define EVAL_RESULT_KIND 24
#define EVAL_ERROR_KIND 25 #define EVAL_ERROR_KIND 25
#define USER_IDENT_PALETTE_SIZE 8
#define USER_IDENT_HOP 3
typedef struct TextPos TextPos; typedef struct TextPos TextPos;
typedef struct ViewportHandle ViewportHandle; 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_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); void viewport_send_command(struct ViewportHandle *handle, uint32_t command);
/** /**

View File

@ -12,6 +12,7 @@ use iced_wgpu::core::{
use iced_widget::canvas; use iced_widget::canvas;
use iced_widget::container; use iced_widget::container;
use iced_widget::markdown; use iced_widget::markdown;
use iced_widget::MouseArea;
use crate::text_widget::{self, Action, AnchoredItem, Binding, Cursor, KeyPress, Motion, Position, Status}; use crate::text_widget::{self, Action, AnchoredItem, Binding, Cursor, KeyPress, Motion, Position, Status};
use iced_widget::text_input; use iced_widget::text_input;
use iced_wgpu::core::text::highlighter::Format; 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::blocks::{self, BoxedBlock};
use crate::heading_block::HeadingBlock; use crate::heading_block::HeadingBlock;
use crate::hr_block::HrBlock; use crate::hr_block::HrBlock;
use crate::oklab;
use crate::palette; use crate::palette;
use crate::sidecar::{self, Sidecar, TableSidecar}; use crate::sidecar::{self, Sidecar, TableSidecar};
use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors}; use crate::syntax::{self, SyntaxHighlighter, SyntaxSettings, LineDecor, compute_line_decors};
@ -38,6 +40,29 @@ pub enum RenderMode {
View, 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)] #[derive(Debug, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub enum Message { pub enum Message {
@ -117,9 +142,44 @@ pub enum Message {
IndentTab, IndentTab,
OutdentTab, OutdentTab,
SetRenderMode(RenderMode), 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 = ""; 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 = ""; pub const ERROR_PREFIX: &str = "";
const EVAL_DEBOUNCE_MS: u128 = 300; 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 /// Cells whose raw text starts with `/=` and are not being edited render
/// the computed value instead; anything not in this map renders raw. /// the computed value instead; anything not in this map renders raw.
pub computed_cells: HashMap<(crate::selection::BlockId, u32, u32), acord_core::interp::Value>, 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<InlinePressState>,
/// 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 /// Per-eval table name→id bookkeeping. `keys` is every alias a table is
@ -428,6 +501,9 @@ impl EditorState {
computed_tables: Vec::new(), computed_tables: Vec::new(),
computed_trees: Vec::new(), computed_trees: Vec::new(),
computed_cells: HashMap::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.eval_dirty = false;
self.run_eval(); 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 /// 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. /// is arriving, so tick() eventually fires run_eval.
pub fn has_pending_eval(&self) -> bool { pub fn has_pending_eval(&self) -> bool {
self.eval_dirty self.eval_dirty
|| self.inline_press.as_ref().is_some_and(|s| !s.fired_long_press)
} }
fn reparse(&mut self) { fn reparse(&mut self) {
@ -2918,9 +3009,100 @@ impl EditorState {
self.set_focused_block(idx); 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<String> {
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<String> {
let block = self.registry.get(&block_id)?;
let tb = block.as_any().downcast_ref::<TextBlock>()?;
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> { pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview { let main_content: Element<'_, Message, Theme, iced_wgpu::Renderer> = if self.preview {
let settings = markdown::Settings::with_text_size(self.font_size, md_style()); 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}")) iced_widget::text(format!("{mode_label} Ln {line}, Col {col}"))
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.size(11.0) .size(11.0)
.color(Color::WHITE) .color(oklab::lighten_for_size(Color::WHITE, 11.0))
.into(), .into(),
]) ])
) )
@ -3084,6 +3266,7 @@ impl EditorState {
font_size: self.font_size, font_size: self.font_size,
top_pad: title_bar_h, top_pad: title_bar_h,
item_offsets: self.item_offsets(tb.id), item_offsets: self.item_offsets(tb.id),
indicator: self.line_indicator,
}) })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@ -3103,10 +3286,12 @@ impl EditorState {
global_line_offset: 0, global_line_offset: 0,
font_size: self.font_size, font_size: self.font_size,
scroll_offset: self.scroll_offset, scroll_offset: self.scroll_offset,
cursor_line, cursor_line: if is_focused { Some(cursor_line) } else { None },
top_pad: title_bar_h, top_pad: title_bar_h,
line_decors: decors, line_decors: decors,
item_offsets: self.item_offsets(tb.id), item_offsets: self.item_offsets(tb.id),
indicator: self.line_indicator,
rainbow: self.gutter_rainbow,
}; };
let gw = gutter.gutter_width(); let gw = gutter.gutter_width();
@ -3172,10 +3357,12 @@ impl EditorState {
global_line_offset: global_line, global_line_offset: global_line,
font_size: self.font_size, font_size: self.font_size,
scroll_offset: 0.0, scroll_offset: 0.0,
cursor_line, cursor_line: if is_focused { Some(cursor_line) } else { None },
top_pad, top_pad,
line_decors: decors, line_decors: decors,
item_offsets: self.item_offsets(tb.id), item_offsets: self.item_offsets(tb.id),
indicator: self.line_indicator,
rainbow: self.gutter_rainbow,
}; };
global_line += line_count; global_line += line_count;
let gw = gutter.gutter_width(); let gw = gutter.gutter_width();
@ -3186,6 +3373,7 @@ impl EditorState {
font_size: self.font_size, font_size: self.font_size,
top_pad, top_pad,
item_offsets: self.item_offsets(tb.id), item_offsets: self.item_offsets(tb.id),
indicator: self.line_indicator,
}) })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fixed(editor_h)) .height(Length::Fixed(editor_h))
@ -3377,16 +3565,27 @@ impl EditorState {
match item { match item {
LayerItem::Inline(r) => { LayerItem::Inline(r) => {
let color = if r.is_error { p.red } else { p.green }; let color = if r.is_error { p.red } else { p.green };
let el: Element<'a, Message, Theme, iced_wgpu::Renderer> = let inner = iced_widget::container(
iced_widget::container( iced_widget::text(&r.text)
iced_widget::text(&r.text) .font(syntax::EDITOR_FONT)
.font(syntax::EDITOR_FONT) .size(self.font_size)
.size(self.font_size) .color(oklab::lighten_for_size(color, self.font_size))
.color(color) )
) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 })
.padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 40.0 }) .width(Length::Fill);
.width(Length::Fill) // Errors don't carry a copyable result value, so they
.into(); // 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 { anchored.push(AnchoredItem {
after_line: *after_line, after_line: *after_line,
height: item.element_height(lh, self.font_size), height: item.element_height(lh, self.font_size),
@ -3404,7 +3603,7 @@ impl EditorState {
let mut txt = iced_widget::text(cell) let mut txt = iced_widget::text(cell)
.font(syntax::EDITOR_FONT) .font(syntax::EDITOR_FONT)
.size(self.font_size) .size(self.font_size)
.color(p.text); .color(oklab::lighten_for_size(p.text, self.font_size));
if is_header { if is_header {
txt = txt.font(Font { weight: iced_wgpu::core::font::Weight::Bold, ..syntax::EDITOR_FONT }); 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) iced_widget::text(match_label)
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.size(11.0) .size(11.0)
.color(p.overlay1) .color(oklab::lighten_for_size(p.overlay1, 11.0))
.into(); .into();
let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let btn = |txt: String, msg: Message| -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
@ -3709,6 +3908,8 @@ struct Cursorline {
top_pad: f32, top_pad: f32,
/// (after_line, height) pairs from anchored children — shifts y for lines below. /// (after_line, height) pairs from anchored children — shifts y for lines below.
item_offsets: Vec<(usize, f32)>, item_offsets: Vec<(usize, f32)>,
/// `Off` suppresses the row-highlight band; `On` and `Vim` show it.
indicator: LineIndicator,
} }
impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Cursorline { impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Cursorline {
@ -3730,21 +3931,23 @@ impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Cursorline {
frame.fill_rectangle(Point::ORIGIN, bounds.size(), p.base); frame.fill_rectangle(Point::ORIGIN, bounds.size(), p.base);
if let Some(line) = self.cursor_line { if let Some(line) = self.cursor_line {
let lh = self.font_size * 1.3; if self.indicator != LineIndicator::Off {
let extra: f32 = self.item_offsets.iter() let lh = self.font_size * 1.3;
.filter(|(after, _)| *after < line) let extra: f32 = self.item_offsets.iter()
.map(|(_, h)| h) .filter(|(after, _)| *after < line)
.sum(); .map(|(_, h)| h)
let y = self.top_pad + line as f32 * lh + extra; .sum();
if y < bounds.height && y + lh > 0.0 { let y = self.top_pad + line as f32 * lh + extra;
// ~6% tint of the foreground color. Reads as a faint band in if y < bounds.height && y + lh > 0.0 {
// both light and dark themes without screaming. // ~6% tint of the foreground color. Reads as a faint band in
let band = Color { a: 0.06, ..p.text }; // both light and dark themes without screaming.
frame.fill_rectangle( let band = Color { a: 0.06, ..p.text };
Point::new(0.0, y), frame.fill_rectangle(
iced_wgpu::core::Size::new(bounds.width, lh), Point::new(0.0, y),
band, iced_wgpu::core::Size::new(bounds.width, lh),
); band,
);
}
} }
} }
@ -3757,10 +3960,24 @@ struct Gutter {
global_line_offset: usize, global_line_offset: usize,
font_size: f32, font_size: f32,
scroll_offset: 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<usize>,
top_pad: f32, top_pad: f32,
line_decors: Vec<LineDecor>, line_decors: Vec<LineDecor>,
item_offsets: Vec<(usize, f32)>, 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 { impl Gutter {
@ -3799,9 +4016,17 @@ impl canvas::Program<Message, Theme, iced_wgpu::Renderer> 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; 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(); let gw = self.gutter_width();
@ -3850,21 +4075,53 @@ impl canvas::Program<Message, Theme, iced_wgpu::Renderer> for Gutter {
); );
frame.stroke(&path, canvas::Stroke::default() frame.stroke(&path, canvas::Stroke::default()
.with_width(1.0) .with_width(1.0)
.with_color(p.overlay1)); .with_color(oklab::lighten_for_size(p.overlay1, 1.0)));
} }
LineDecor::None => {} LineDecor::None => {}
} }
let color = if line_idx == self.cursor_line { // `Off` skips the number entirely — gutter strip stays for
p.overlay1 // 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 { } 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 { frame.fill_text(canvas::Text {
content: format!("{}", line_num + 1), content: label,
position: Point::new(gw - 8.0, y), position: Point::new(gw - 8.0, y),
max_width: gw, max_width: gw,
color, color: oklab::lighten_for_size(raw_color, self.font_size),
size: Pixels(self.font_size), size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.3), line_height: LineHeight::Relative(1.3),
font: Font::MONOSPACE, font: Font::MONOSPACE,

View File

@ -7,6 +7,7 @@ use iced_wgpu::core::font::Weight;
use iced_widget::canvas; use iced_widget::canvas;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::oklab;
use crate::palette; use crate::palette;
use crate::selection::{BlockId, InnerPath}; use crate::selection::{BlockId, InnerPath};
@ -87,7 +88,7 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for He
content: self.text.clone(), content: self.text.clone(),
position: Point::new(8.0, 4.0), position: Point::new(8.0, 4.0),
max_width: bounds.width - 16.0, max_width: bounds.width - 16.0,
color, color: oklab::lighten_for_size(color, self.font_size),
size: Pixels(self.font_size), size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.4), line_height: LineHeight::Relative(1.4),
font: Font { weight: self.level.weight(), ..Font::DEFAULT }, font: Font { weight: self.level.weight(), ..Font::DEFAULT },

View File

@ -2,6 +2,7 @@ use iced_wgpu::core::{mouse, Element, Length, Point, Rectangle, Theme};
use iced_widget::canvas; use iced_widget::canvas;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::oklab;
use crate::palette; use crate::palette;
use crate::selection::{BlockId, InnerPath}; use crate::selection::{BlockId, InnerPath};
@ -26,11 +27,12 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for HR
Point::new(margin, y), Point::new(margin, y),
Point::new(bounds.width - margin, y), Point::new(bounds.width - margin, y),
); );
let stroke_w = 1.0;
frame.stroke( frame.stroke(
&path, &path,
canvas::Stroke::default() canvas::Stroke::default()
.with_width(1.0) .with_width(stroke_w)
.with_color(p.overlay0), .with_color(oklab::lighten_for_size(p.overlay0, stroke_w)),
); );
vec![frame.into_geometry()] vec![frame.into_geometry()]
} }

View File

@ -9,6 +9,7 @@ mod handle;
pub mod heading_block; pub mod heading_block;
pub mod hr_block; pub mod hr_block;
pub mod module; pub mod module;
pub mod oklab;
pub mod palette; pub mod palette;
pub mod selection; pub mod selection;
pub mod sidecar; 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)] #[unsafe(no_mangle)]
pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u32) { pub extern "C" fn viewport_send_command(handle: *mut ViewportHandle, command: u32) {
let h = match unsafe { handle.as_mut() } { let h = match unsafe { handle.as_mut() } {

221
viewport/src/oklab.rs Normal file
View File

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

View File

@ -64,20 +64,21 @@ pub static MOCHA: Palette = Palette {
/// contrast. The signature KiCad schematic-editor feel: vivid greens, /// contrast. The signature KiCad schematic-editor feel: vivid greens,
/// bright cyans, punchy reds and yellows on a deep navy base. /// bright cyans, punchy reds and yellows on a deep navy base.
pub static KICAD: Palette = Palette { pub static KICAD: Palette = Palette {
rosewater: Color::from_rgb(0.984, 0.639, 0.757), // From acord-palette-used.svg, 13 user-kept swatches (rounded to f32).
flamingo: Color::from_rgb(0.965, 0.533, 0.404), rosewater: Color::from_rgb(0.976, 0.639, 0.984), // (249,163,251) light pink
pink: Color::from_rgb(0.973, 0.345, 0.718), flamingo: Color::from_rgb(1.000, 0.718, 0.937), // (255,183,239) pink-light
mauve: Color::from_rgb(0.635, 0.282, 0.980), pink: Color::from_rgb(0.988, 0.545, 0.808), // (252,139,206)
red: Color::from_rgb(0.914, 0.376, 0.376), mauve: Color::from_rgb(0.741, 0.494, 0.984), // (189,126,251)
maroon: Color::from_rgb(0.949, 0.416, 0.584), red: Color::from_rgb(0.973, 0.545, 0.545), // (248,139,139)
peach: Color::from_rgb(0.965, 0.533, 0.404), maroon: Color::from_rgb(0.933, 0.506, 0.639), // (238,129,163)
yellow: Color::from_rgb(0.988, 0.831, 0.349), peach: Color::from_rgb(1.000, 0.667, 0.396), // (255,170,101)
green: Color::from_rgb(0.403, 0.972, 0.534), yellow: Color::from_rgb(1.000, 0.886, 0.486), // (255,226,124)
teal: Color::from_rgb(0.310, 1.000, 0.882), green: Color::from_rgb(0.592, 0.925, 0.671), // (151,236,171)
sky: Color::from_rgb(0.403, 0.813, 0.972), teal: Color::from_rgb(0.310, 1.000, 0.882), // (79,255,225)
sapphire: Color::from_rgb(0.384, 0.635, 0.949), sky: Color::from_rgb(0.404, 0.812, 0.973), // (103,207,248)
blue: Color::from_rgb(0.337, 0.475, 0.988), sapphire: Color::from_rgb(0.384, 0.635, 0.949), // unchanged — unused slot
lavender: Color::from_rgb(1.000, 0.718, 0.937), 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), text: Color::from_rgb(0.965, 0.954, 0.969),
subtext1: Color::from_rgb(0.824, 0.813, 0.852), subtext1: Color::from_rgb(0.824, 0.813, 0.852),
subtext0: Color::from_rgb(0.679, 0.668, 0.725), subtext0: Color::from_rgb(0.679, 0.668, 0.725),

View File

@ -39,8 +39,16 @@ const COR_TYPE_ANN: u8 = 64;
// references resolve to the same slot so the name reads the same color // references resolve to the same slot so the name reads the same color
// throughout the document. // throughout the document.
const USER_IDENT_BASE: u8 = 70; const USER_IDENT_BASE: u8 = 70;
const USER_IDENT_PALETTE_SIZE: u8 = 8; pub const USER_IDENT_PALETTE_SIZE: u8 = 8;
const USER_IDENT_HOP: u32 = 3; 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_HEADING_MARKER: u8 = 26;
const MD_H1: u8 = 27; const MD_H1: u8 = 27;

View File

@ -10,6 +10,7 @@ use iced_widget::MouseArea;
use iced_wgpu::core::mouse::Interaction; use iced_wgpu::core::mouse::Interaction;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::oklab;
use crate::palette; use crate::palette;
use crate::selection::{BlockId, InnerPath}; use crate::selection::{BlockId, InnerPath};
use crate::syntax::EDITOR_FONT; use crate::syntax::EDITOR_FONT;
@ -1056,7 +1057,7 @@ where
text(letter) text(letter)
.size(chrome_font) .size(chrome_font)
.font(EDITOR_FONT) .font(EDITOR_FONT)
.color(p.overlay0) .color(oklab::lighten_for_size(p.overlay0, chrome_font))
) )
.width(Length::Fixed(*w)) .width(Length::Fixed(*w))
.height(Length::Fixed(header_h)) .height(Length::Fixed(header_h))
@ -1108,7 +1109,7 @@ where
text(label) text(label)
.size(chrome_font) .size(chrome_font)
.font(EDITOR_FONT) .font(EDITOR_FONT)
.color(p.overlay0) .color(oklab::lighten_for_size(p.overlay0, chrome_font))
) )
.width(Length::Fixed(ROW_NUMBER_WIDTH)) .width(Length::Fixed(ROW_NUMBER_WIDTH))
.padding(Padding { top: 4.0, right: 6.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: 4.0, right: 6.0, bottom: 0.0, left: 0.0 })
@ -1196,7 +1197,7 @@ where
let display = text(display_text) let display = text(display_text)
.size(font_size) .size(font_size)
.font(font) .font(font)
.color(label_color); .color(oklab::lighten_for_size(label_color, font_size));
let container_style = move |_theme: &Theme| { let container_style = move |_theme: &Theme| {
let ws = palette::widget_surface(); let ws = palette::widget_surface();
@ -1212,7 +1213,7 @@ where
container::Style { container::Style {
background, background,
border: cell_border(), border: cell_border(),
text_color: Some(label_color), text_color: Some(oklab::lighten_for_size(label_color, font_size)),
shadow: Shadow::default(), shadow: Shadow::default(),
snap: false, snap: false,
} }

View File

@ -113,12 +113,16 @@ fn total_items_height<M, T>(items: &[AnchoredItem<'_, M, T>]) -> f32 {
} }
/// Build iced Spans from a LayoutRun's glyphs, grouping consecutive glyphs by color. /// 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>( fn build_color_spans<'a>(
text: &'a str, text: &'a str,
glyphs: &[cosmic_text::LayoutGlyph], glyphs: &[cosmic_text::LayoutGlyph],
font_size_px: f32,
) -> Vec<Span<'a>> { ) -> Vec<Span<'a>> {
fn cosmic_to_iced(c: cosmic_text::Color) -> Color { fn cosmic_to_iced(c: cosmic_text::Color, font_size_px: f32) -> Color {
Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0) 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() { if glyphs.is_empty() {
@ -135,7 +139,7 @@ fn build_color_spans<'a>(
if end > seg_start { if end > seg_start {
let mut span = Span::new(&text[seg_start..end]); let mut span = Span::new(&text[seg_start..end]);
if let Some(c) = cur_color { 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); spans.push(span);
} }
@ -147,7 +151,7 @@ fn build_color_spans<'a>(
if seg_start < text.len() { if seg_start < text.len() {
let mut span = Span::new(&text[seg_start..]); let mut span = Span::new(&text[seg_start..]);
if let Some(c) = cur_color { 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); spans.push(span);
} }
@ -1224,7 +1228,7 @@ where
buffer.lines[i].layout_opt() buffer.lines[i].layout_opt()
.map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect()) .map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect())
.unwrap_or_default(); .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 { paras.push(iced_graphics::text::Paragraph::with_spans(Text {
content: spans.as_slice(), content: spans.as_slice(),
bounds: Size::new(text_bounds.width, line_h), bounds: Size::new(text_bounds.width, line_h),

View File

@ -7,6 +7,7 @@ use iced_widget::canvas;
use iced_widget::container; use iced_widget::container;
use crate::block::{Block, BlockCommand, LayeredView, ViewCtx}; use crate::block::{Block, BlockCommand, LayeredView, ViewCtx};
use crate::oklab;
use crate::palette; use crate::palette;
use crate::selection::{BlockId, InnerPath}; use crate::selection::{BlockId, InnerPath};
@ -172,7 +173,7 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for Tr
&connector, &connector,
canvas::Stroke::default() canvas::Stroke::default()
.with_width(1.0) .with_width(1.0)
.with_color(connector_color), .with_color(oklab::lighten_for_size(connector_color, 1.0)),
); );
if !node.is_last { if !node.is_last {
@ -184,7 +185,7 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for Tr
&vert, &vert,
canvas::Stroke::default() canvas::Stroke::default()
.with_width(1.0) .with_width(1.0)
.with_color(connector_color), .with_color(oklab::lighten_for_size(connector_color, 1.0)),
); );
} }
} }
@ -209,7 +210,7 @@ impl<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> for Tr
content: display, content: display,
position: Point::new(indent_x, y + 2.0), position: Point::new(indent_x, y + 2.0),
max_width: bounds.width - indent_x, max_width: bounds.width - indent_x,
color: text_color, color: oklab::lighten_for_size(text_color, self.font_size),
size: Pixels(self.font_size), size: Pixels(self.font_size),
line_height: LineHeight::Relative(1.3), line_height: LineHeight::Relative(1.3),
font: Font::MONOSPACE, font: Font::MONOSPACE,