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

View File

@ -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 `<!-- 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
@ -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<UUID>) {
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)
}
}
}
}

View File

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

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.
func renderMode() -> UInt32 {
guard let h = viewportHandle else { return 0 }

View File

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

View File

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

View File

@ -13,10 +13,18 @@
#include <stdint.h>
#include <stdlib.h>
#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);
/**

View File

@ -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<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
@ -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<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> {
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<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);
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<usize>,
top_pad: f32,
line_decors: Vec<LineDecor>,
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<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;
// 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<Message, Theme, iced_wgpu::Renderer> 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,

View File

@ -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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> 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 },

View File

@ -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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> 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()]
}

View File

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

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,
/// 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),

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
// 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;

View File

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

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.
/// `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<Span<'a>> {
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),

View File

@ -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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> 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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> 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<Message: Clone> canvas::Program<Message, Theme, iced_wgpu::Renderer> 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,