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:
parent
ccffb9149d
commit
03284a694e
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() } {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue