Oh so you'd like a graphics backend, is that right, linux?

Also I made sidecar a bit less aggro.
This commit is contained in:
jess 2026-05-02 20:24:58 -07:00
parent 6cfb229132
commit 6001b41fab
14 changed files with 910 additions and 143 deletions

View File

@ -15,8 +15,10 @@ use acord_viewport::{
viewport_set_line_indicator, viewport_set_gutter_rainbow,
viewport_set_auto_pair_flags,
viewport_send_command, viewport_free_string,
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
ViewportHandle,
};
use acord_viewport::sidecar;
use acord_viewport::browser::{self, BrowserHandle};
use acord_viewport::handle as viewport_handle;
use acord_viewport::editor::ShellAction;
@ -207,9 +209,25 @@ impl App {
fn drain_browser_open(&mut self) {
let Some(handle) = self.browser_handle.as_mut() else { return };
let Some(path) = browser::handle::take_pending_open(handle) else { return };
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(bytes) = std::fs::read(&path) {
self.load_file_bytes(&path, bytes);
}
self.close_browser();
}
/// pushes file bytes into the viewport, splitting the binary archive when the file qualifies.
fn load_file_bytes(&mut self, path: &std::path::Path, bytes: Vec<u8>) {
let (text_bytes, archive) = if self.is_acord_note(path) {
sidecar::extract_from_md(&bytes)
} else {
(bytes, self.read_external_sidecar(path))
};
let text = String::from_utf8_lossy(&text_bytes).into_owned();
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
if let Some(zip) = archive {
viewport_apply_sidecar_bytes(self.handle, zip.as_ptr(), zip.len());
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
@ -218,10 +236,70 @@ impl App {
w.set_title(&format!("{name} - Acord"));
w.focus_window();
}
self.current_file = Some(path);
self.current_file = Some(path.to_path_buf());
self.last_autosaved_hash = None;
}
self.close_browser();
/// true when path is an .md inside the configured notes library (recursive).
fn is_acord_note(&self, path: &std::path::Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !ext.eq_ignore_ascii_case("md") {
return false;
}
let notes_dir = self.config.notes_dir();
let canon_dir = std::fs::canonicalize(&notes_dir).unwrap_or(notes_dir);
let canon_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canon_path.starts_with(&canon_dir)
}
/// path under <notes_dir>/.external/ for storing an external file's archive companion.
fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf {
let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf());
let s = canon.to_string_lossy();
let trimmed = s.trim_start_matches('/').trim_start_matches('\\');
let encoded: String = trimmed
.chars()
.map(|c| match c {
'/' | '\\' | ':' => '.',
_ => c,
})
.collect();
self.config
.notes_dir()
.join(".external")
.join(format!("{encoded}.acord"))
}
fn read_external_sidecar(&self, original: &std::path::Path) -> Option<Vec<u8>> {
let path = self.external_sidecar_path(original);
std::fs::read(path).ok()
}
fn write_external_sidecar(&self, original: &std::path::Path, archive: Option<&[u8]>) {
let path = self.external_sidecar_path(original);
match archive {
Some(bytes) if !bytes.is_empty() => {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, bytes);
}
_ => {
let _ = std::fs::remove_file(&path);
}
}
}
/// drains the current archive zip from the viewport.
fn take_archive_bytes(&self) -> Option<Vec<u8>> {
let mut len: usize = 0;
let ptr = viewport_take_sidecar_bytes(self.handle, &mut len as *mut usize);
if ptr.is_null() || len == 0 {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec();
viewport_free_bytes(ptr, len);
Some(bytes)
}
fn handle_browser_event(&mut self, event: WindowEvent) {
@ -322,18 +400,8 @@ impl App {
.add_filter("Markdown", &["md", "markdown"])
.add_filter("All Files", &["*"]);
if let Some(path) = dialog.pick_file() {
if let Ok(text) = std::fs::read_to_string(&path) {
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
if let Some(w) = &self.window {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord");
w.set_title(&format!("{name} - Acord"));
}
self.current_file = Some(path);
self.last_autosaved_hash = None;
if let Ok(bytes) = std::fs::read(&path) {
self.load_file_bytes(&path, bytes);
}
}
}
@ -371,9 +439,19 @@ impl App {
.to_string_lossy()
.into_owned();
viewport_free_string(text_ptr);
if std::fs::write(path, &text).is_ok() {
let archive = self.take_archive_bytes();
let in_library = self.is_acord_note(path);
let file_bytes: Vec<u8> = match (&archive, in_library) {
(Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc),
_ => text.as_bytes().to_vec(),
};
if std::fs::write(path, &file_bytes).is_ok() {
self.last_autosaved_hash = Some(text_hash(&text));
}
if !in_library {
self.write_external_sidecar(path, archive.as_deref());
}
}
fn derive_default_filename(&self) -> String {
@ -424,15 +502,29 @@ impl App {
let hash = text_hash(&text);
if Some(hash) == self.last_autosaved_hash { return; }
// skip the launch stub so it can't overwrite last session's Untitled.md.
if self.current_file.is_none() && is_effectively_blank(&text) {
return;
}
let path = self.current_file.clone().unwrap_or_else(|| {
self.config.notes_dir().join("Untitled.md")
});
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&path, &text).is_ok() {
let archive = self.take_archive_bytes();
let in_library = self.is_acord_note(&path);
let file_bytes: Vec<u8> = match (&archive, in_library) {
(Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc),
_ => text.as_bytes().to_vec(),
};
if std::fs::write(&path, &file_bytes).is_ok() {
self.last_autosaved_hash = Some(hash);
}
if !in_library {
self.write_external_sidecar(&path, archive.as_deref());
}
}
fn winit_button(button: MouseButton) -> u8 {
@ -624,6 +716,15 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
/// true when the buffer is empty or just leading heading markers.
fn is_effectively_blank(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return true;
}
trimmed.trim_start_matches('#').trim().is_empty()
}
/// Translates winit logical keys into iced keyboard keys for direct iced
/// event push (used by the second browser window, which speaks iced
/// directly rather than through the C bridge).

View File

@ -829,6 +829,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
}
self.lastAutosavedHash = text.hashValue
}
appState.takeArchiveBytesFromViewport = { [weak self] in
self?.viewport?.takeSidecarBytes()
}
appState.applyArchiveBytesToViewport = { [weak self] data in
self?.viewport?.applySidecarBytes(data)
}
}
private func syncTextFromViewport() {

View File

@ -134,6 +134,10 @@ class AppState: ObservableObject {
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave")
/// fires synchronously after a load/new note swap so the host shell can push the new text into the viewport before the autosave timer reads stale viewport state.
var onLoadedTextChanged: ((String) -> Void)?
/// drains the document's archive zip from the viewport for embed-on-save.
var takeArchiveBytesFromViewport: (() -> Data?)?
/// applies an archive zip's metadata back into the viewport.
var applyArchiveBytesToViewport: ((Data) -> Void)?
/// 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
@ -166,9 +170,21 @@ class AppState: ObservableObject {
let text = documentText
let noteID = currentNoteID
let url = resolveAutoSaveURL(noteID: noteID, text: text)
let archive = takeArchiveBytesFromViewport?()
let inLibrary = isAcordNote(url)
let payload: Data = {
let textData = Data(text.utf8)
if inLibrary, let archive = archive {
return AppState.embedArchiveInMd(text: textData, archive: archive)
}
return textData
}()
autoSaveQueue.async { [weak self] in
Self.writeAutoSaveFile(at: url, text: text)
try? payload.write(to: url, options: .atomic)
if !inLibrary {
self?.writeExternalSidecar(for: url, archive: archive)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
self.autoSaveCoolingDown = false
@ -287,6 +303,82 @@ class AppState: ObservableObject {
try? text.write(to: url, atomically: true, encoding: .utf8)
}
/// magic separating the markdown body from the appended raw zip; surrounding
/// NULs trip text editors into binary mode so the archive shows as garbage.
static let binarySentinel: Data = {
var d = Data()
d.append(0x0A) // \n
d.append(0x00)
d.append(contentsOf: "ACORD-ARCHIVE".utf8)
d.append(0x00)
d.append(0x0A) // \n
return d
}()
/// splits raw file bytes on the binary sentinel into (text, optional archive zip).
static func splitFileBytes(_ data: Data) -> (Data, Data?) {
let sentinel = binarySentinel
guard let range = data.range(of: sentinel, options: .backwards) else {
return (data, nil)
}
let text = data.subdata(in: 0..<range.lowerBound)
let archive = data.subdata(in: range.upperBound..<data.count)
return (text, archive.isEmpty ? nil : archive)
}
/// concatenates text + sentinel + archive, ensuring a newline before the sentinel.
static func embedArchiveInMd(text: Data, archive: Data) -> Data {
var out = Data(capacity: text.count + binarySentinel.count + archive.count)
out.append(text)
if !text.isEmpty && text.last != 0x0A {
out.append(0x0A)
}
out.append(binarySentinel)
out.append(archive)
return out
}
/// true when url is an .md inside the configured notes library (recursive).
func isAcordNote(_ url: URL) -> Bool {
let format = FileFormat.from(filename: url.lastPathComponent)
guard format.isMarkdown else { return false }
let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
.standardizedFileURL.resolvingSymlinksInPath()
let standardized = url.standardizedFileURL.resolvingSymlinksInPath()
return standardized.path.hasPrefix(dir.path + "/")
|| standardized.path == dir.path
}
/// path under <notes_dir>/.external/ for an external file's archive companion.
func externalSidecarPath(for original: URL) -> URL {
let trimmed = original.standardizedFileURL.path
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let encoded = trimmed.map { ch -> Character in
(ch == "/" || ch == "\\" || ch == ":") ? "." : ch
}
let name = String(encoded) + ".acord"
return URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
.appendingPathComponent(".external", isDirectory: true)
.appendingPathComponent(name)
}
/// reads the companion archive zip for an external file, if it exists.
func readExternalSidecar(for original: URL) -> Data? {
try? Data(contentsOf: externalSidecarPath(for: original))
}
/// writes (or clears) the companion archive for an external file.
func writeExternalSidecar(for original: URL, archive: Data?) {
let path = externalSidecarPath(for: original)
if let archive = archive, !archive.isEmpty {
let parent = path.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
try? archive.write(to: path, options: .atomic)
} else {
try? FileManager.default.removeItem(at: path)
}
}
/// 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
@ -361,7 +453,7 @@ class AppState: ObservableObject {
bridge.setText(currentNoteID, text: documentText)
if let url = currentFileURL {
let textToSave = textForExternalSave(format: currentFileFormat)
try? textToSave.write(to: url, atomically: true, encoding: .utf8)
writeNoteFile(text: textToSave, to: url)
}
let _ = bridge.cacheSave(currentNoteID)
modified = false
@ -371,7 +463,7 @@ class AppState: ObservableObject {
func saveNoteToFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent)
let textToSave = textForExternalSave(format: format)
try? textToSave.write(to: url, atomically: true, encoding: .utf8)
writeNoteFile(text: textToSave, to: url)
currentFileURL = url
currentFileFormat = format
// An explicit save-to-disk locks the autosave path to the same file
@ -383,6 +475,24 @@ class AppState: ObservableObject {
modified = false
}
/// embeds the binary archive into in-library .md files; for everything else
/// writes plain text and stashes the archive at <notes_dir>/.external/.
private func writeNoteFile(text: String, to url: URL) {
let archive = takeArchiveBytesFromViewport?()
let inLibrary = isAcordNote(url)
let textData = Data(text.utf8)
let payload: Data
if inLibrary, let archive = archive {
payload = AppState.embedArchiveInMd(text: textData, archive: archive)
} else {
payload = textData
}
try? payload.write(to: url, options: .atomic)
if !inLibrary {
writeExternalSidecar(for: url, archive: archive)
}
}
/// 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
@ -395,14 +505,18 @@ class AppState: ObservableObject {
func loadNoteFromFile(_ url: URL) {
let format = FileFormat.from(filename: url.lastPathComponent)
if let (id, text) = bridge.loadNote(path: url.path) {
// pin the autosave path before touching documentText so the didSet
// autosave path resolution lands on the actual file rather than a
// title-derived sibling.
let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
.standardizedFileURL
let parent = url.deletingLastPathComponent().standardizedFileURL
if format.isMarkdown && parent == dir {
guard let bytes = try? Data(contentsOf: url) else { return }
let inLibrary = isAcordNote(url)
let (textBytes, embeddedArchive): (Data, Data?) = inLibrary
? AppState.splitFileBytes(bytes)
: (bytes, nil)
let archive = embeddedArchive ?? (inLibrary ? nil : readExternalSidecar(for: url))
let text = String(data: textBytes, encoding: .utf8) ?? ""
guard let id = bridge.installDocument(text: text) else { return }
if format.isMarkdown {
autoSavePaths[id] = url
}
currentNoteID = id
@ -418,6 +532,8 @@ class AppState: ObservableObject {
evaluate()
refreshNoteList()
onLoadedTextChanged?(documentText)
if let archive = archive {
applyArchiveBytesToViewport?(archive)
}
}
@ -573,8 +689,20 @@ class AppState: ObservableObject {
func writeAutosavedCopy(text: String) {
let noteID = currentNoteID
let url = resolveAutoSaveURL(noteID: noteID, text: text)
autoSaveQueue.async {
Self.writeAutoSaveFile(at: url, text: text)
let archive = takeArchiveBytesFromViewport?()
let inLibrary = isAcordNote(url)
let payload: Data = {
let textData = Data(text.utf8)
if inLibrary, let archive = archive {
return AppState.embedArchiveInMd(text: textData, archive: archive)
}
return textData
}()
autoSaveQueue.async { [weak self] in
try? payload.write(to: url, options: .atomic)
if !inLibrary {
self?.writeExternalSidecar(for: url, archive: archive)
}
}
}

View File

@ -241,6 +241,28 @@ class IcedViewportView: NSView {
return result
}
/// drains the document's archive zip bytes for embed-on-save.
func takeSidecarBytes() -> Data? {
if isTornDown { return nil }
guard let h = viewportHandle else { return nil }
var len: UInt = 0
guard let ptr = viewport_take_sidecar_bytes(h, &len), len > 0 else { return nil }
let data = Data(bytes: ptr, count: Int(len))
viewport_free_bytes(ptr, len)
return data
}
/// applies an archive zip's metadata back into the document.
func applySidecarBytes(_ data: Data) {
if isTornDown { return }
guard let h = viewportHandle, !data.isEmpty else { return }
data.withUnsafeBytes { raw in
if let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) {
viewport_apply_sidecar_bytes(h, base, UInt(data.count))
}
}
}
func sendCommand(_ command: UInt32) {
guard let h = viewportHandle else { return }
viewport_send_command(h, command)

View File

@ -90,6 +90,23 @@ class RustBridge {
return (id, text)
}
/// installs a doc from already-decoded text used when the shell read raw bytes
/// itself (so it could split off the binary archive trailer) and just needs a UUID.
func installDocument(text: String) -> UUID? {
guard let ptr = acord_doc_new() else { return nil }
text.withCString { cstr in
acord_doc_set_text(ptr, cstr)
}
let uuidStr = cacheSaveRaw(ptr)
guard let id = UUID(uuidString: uuidStr) else {
acord_doc_free(ptr)
return nil
}
if let old = docs[id] { acord_doc_free(old) }
docs[id] = ptr
return id
}
func cacheSave(_ id: UUID) -> Bool {
guard let ptr = docs[id] else { return false }
guard let cstr = acord_cache_save(ptr) else { return false }

View File

@ -84,6 +84,23 @@ char *viewport_get_text(struct ViewportHandle *handle);
void viewport_free_string(char *s);
/**
* returns the archive zip bytes (or null when empty); writes the length to len_out.
*/
uint8_t *viewport_take_sidecar_bytes(struct ViewportHandle *handle, uintptr_t *len_out);
/**
* applies archive zip bytes back into the document.
*/
void viewport_apply_sidecar_bytes(struct ViewportHandle *handle,
const uint8_t *bytes,
uintptr_t len);
/**
* frees byte buffers returned by viewport_take_sidecar_bytes.
*/
void viewport_free_bytes(uint8_t *ptr, uintptr_t len);
void viewport_set_theme(struct ViewportHandle *handle, const char *name);
void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode);

View File

@ -61,8 +61,9 @@ pub fn create(
let backends = wgpu::Backends::METAL;
#[cfg(target_os = "windows")]
let backends = wgpu::Backends::DX12;
// accept GL alongside Vulkan so distros without a Vulkan ICD still get a browser surface.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let backends = wgpu::Backends::VULKAN;
let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends,
@ -74,23 +75,46 @@ pub fn create(
raw_window_handle: raw_window,
};
let surface = unsafe { instance.create_surface_unsafe(target).ok()? };
let surface = match unsafe { instance.create_surface_unsafe(target) } {
Ok(s) => s,
Err(e) => {
eprintln!("acord: browser surface creation failed: {e}");
return None;
}
};
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.ok()?;
})) {
Ok(a) => a,
Err(e) => {
eprintln!("acord: browser adapter request failed: {e}");
return None;
}
};
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
Ok(dq) => dq,
Err(e) => {
eprintln!("acord: browser device request failed: {e}");
return None;
}
};
let phys_w = (width * scale) as u32;
let phys_h = (height * scale) as u32;
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.first().copied()?;
let format = match caps.formats.first().copied() {
Some(f) => f,
None => {
eprintln!("acord: browser surface has no compatible formats");
return None;
}
};
surface.configure(
&device,

View File

@ -81,7 +81,10 @@ pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
}
pub fn file_preview(path: &Path) -> String {
let Ok(text) = std::fs::read_to_string(path) else { return String::new() };
// bytes-first so the binary archive trailer doesn't trip the utf-8 decode.
let Ok(bytes) = std::fs::read(path) else { return String::new() };
let (text_bytes, _) = crate::sidecar::extract_from_md(&bytes);
let text = String::from_utf8_lossy(&text_bytes);
let body = strip_sidecar_archive(&text);
if body_looks_blank(body) {
return "(empty note)".to_string();

View File

@ -1102,13 +1102,24 @@ impl EditorState {
}
}
/// saves the document to file bytes, embedding the sidecar archive
/// returns the clean markdown body; the archive lives in a separate channel.
pub fn save_doc(&mut self) -> String {
let body = self.get_clean_text();
self.get_clean_text()
}
/// returns the archive zip bytes the shell should embed for in-library .md files.
pub fn save_sidecar_bytes(&mut self) -> Option<Vec<u8>> {
self.rebuild_modules();
let sidecar = self.build_sidecar();
let block_files = self.build_block_files();
sidecar::embed_archive(&body, &sidecar, &block_files)
sidecar::build_archive_bytes(&sidecar, &block_files)
}
/// applies an archive zip's metadata back into the document.
pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) {
if let Some(sc) = sidecar::extract_archive_bytes(bytes) {
self.apply_sidecar(&sc);
}
}
/// builds the per-block `.cord` source files for the sidecar archive

View File

@ -242,8 +242,6 @@ pub extern "C" fn viewport_get_text(handle: *mut ViewportHandle) -> *mut c_char
Some(h) => h,
None => return std::ptr::null_mut(),
};
// Goes through `save_doc` so any tables with persistent metadata get
// their data round-tripped via the embedded sidecar archive comment.
let text = h.state.save_doc();
CString::new(text).unwrap_or_default().into_raw()
}
@ -254,6 +252,57 @@ pub extern "C" fn viewport_free_string(s: *mut c_char) {
unsafe { drop(CString::from_raw(s)); }
}
/// returns the archive zip bytes (or null when empty); writes the length to len_out.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_take_sidecar_bytes(
handle: *mut ViewportHandle,
len_out: *mut usize,
) -> *mut u8 {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => {
if !len_out.is_null() { unsafe { *len_out = 0; } }
return std::ptr::null_mut();
}
};
let bytes = match h.state.save_sidecar_bytes() {
Some(b) => b,
None => {
if !len_out.is_null() { unsafe { *len_out = 0; } }
return std::ptr::null_mut();
}
};
let mut boxed = bytes.into_boxed_slice();
if !len_out.is_null() { unsafe { *len_out = boxed.len(); } }
let ptr = boxed.as_mut_ptr();
std::mem::forget(boxed);
ptr
}
/// applies archive zip bytes back into the document.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_apply_sidecar_bytes(
handle: *mut ViewportHandle,
bytes: *const u8,
len: usize,
) {
let h = match unsafe { handle.as_mut() } {
Some(h) => h,
None => return,
};
if bytes.is_null() || len == 0 { return; }
let slice = unsafe { std::slice::from_raw_parts(bytes, len) };
h.state.apply_sidecar_bytes(slice);
h.needs_redraw = true;
}
/// frees byte buffers returned by viewport_take_sidecar_bytes.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_free_bytes(ptr: *mut u8, len: usize) {
if ptr.is_null() || len == 0 { return; }
unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); }
}
#[unsafe(no_mangle)]
pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) {
let s = if name.is_null() {

View File

@ -218,27 +218,63 @@ pub struct BlockFile {
pub content: String,
}
/// Append an archive comment to the markdown body. If there's nothing to store
/// (no block files AND no sidecar entries), returns the body unchanged.
pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String {
/// builds archive zip bytes from sidecar metadata and block files, None when both empty.
pub fn build_archive_bytes(sidecar: &Sidecar, block_files: &[BlockFile]) -> Option<Vec<u8>> {
if sidecar.tables.is_empty() && block_files.is_empty() {
return markdown.to_string();
return None;
}
let toml_text = toml::to_string_pretty(sidecar).ok()?;
write_zip(&toml_text, block_files).ok()
}
let toml_text = match toml::to_string_pretty(sidecar) {
Ok(t) => t,
Err(_) => return markdown.to_string(),
};
/// parses zip bytes back into a Sidecar.
pub fn extract_archive_bytes(bytes: &[u8]) -> Option<Sidecar> {
let toml_text = read_zip(bytes)?;
toml::from_str::<Sidecar>(&toml_text).ok()
}
let zip_bytes = match write_zip(&toml_text, block_files) {
Ok(b) => b,
Err(_) => return markdown.to_string(),
};
/// magic separating the markdown body from the appended raw zip; the surrounding NULs
/// trip text editors into "binary mode" so the archive shows up as garbage, not as
/// readable base64.
pub const BINARY_SENTINEL: &[u8] = b"\n\x00ACORD-ARCHIVE\x00\n";
/// appends raw zip bytes after the markdown body, separated by BINARY_SENTINEL.
pub fn embed_in_md(markdown: &[u8], archive: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(markdown.len() + BINARY_SENTINEL.len() + archive.len());
out.extend_from_slice(markdown);
if !markdown.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(BINARY_SENTINEL);
out.extend_from_slice(archive);
out
}
/// splits raw file bytes on BINARY_SENTINEL, returning (text_bytes, optional zip bytes).
pub fn extract_from_md(bytes: &[u8]) -> (Vec<u8>, Option<Vec<u8>>) {
if let Some(idx) = rfind_subslice(bytes, BINARY_SENTINEL) {
let text = bytes[..idx].to_vec();
let archive_start = idx + BINARY_SENTINEL.len();
let archive = bytes[archive_start..].to_vec();
return (text, Some(archive));
}
(bytes.to_vec(), None)
}
fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).rposition(|w| w == needle)
}
/// legacy embed format: base64-encoded zip inside an HTML comment. Kept for the
/// round-trip tests and the load-side back-compat path; new saves go through embed_in_md.
pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String {
let Some(zip_bytes) = build_archive_bytes(sidecar, block_files) else {
return markdown.to_string();
};
let encoded = B64.encode(&zip_bytes);
// Wrap base64 to ~76 cols so the comment doesn't blow out git diffs and
// terminal viewers. The decoder ignores whitespace.
let wrapped = wrap_base64(&encoded, 76);
let mut out = markdown.trim_end_matches('\n').to_string();

View File

@ -111,6 +111,8 @@ pub struct SyntaxHighlighter {
current_line: usize,
line_decors: Vec<LineDecor>,
user_idents: HashMap<String, u8>,
/// per-line tree-sitter spans for fenced code body lines, by absolute line index.
code_block_spans: HashMap<usize, Vec<(Range<usize>, SyntaxHighlight)>>,
}
impl SyntaxHighlighter {
@ -153,6 +155,82 @@ impl SyntaxHighlighter {
self.current_line = 0;
self.scan_user_idents(source);
self.scan_fenced_code_blocks(source);
}
/// runs each language-tagged fenced block through tree-sitter and stashes per-line spans.
fn scan_fenced_code_blocks(&mut self, source: &str) {
self.code_block_spans.clear();
let lines: Vec<&str> = source.split('\n').collect();
let mut i = 0;
while i < lines.len() {
let is_md = i < self.line_kinds.len()
&& self.line_kinds[i] == LineKind::Markdown;
if !is_md {
i += 1;
continue;
}
let trimmed = lines[i].trim_start();
if !trimmed.starts_with("```") {
i += 1;
continue;
}
let lang_label = trimmed.strip_prefix("```").unwrap_or("").trim();
let mut j = i + 1;
while j < lines.len() {
let jis_md = j < self.line_kinds.len()
&& self.line_kinds[j] == LineKind::Markdown;
if jis_md && lines[j].trim_start().starts_with("```") {
break;
}
j += 1;
}
let body_start_line = i + 1;
let body_end_line = j; // exclusive
if body_end_line > body_start_line {
if let Some(canonical) = canonical_code_lang(lang_label) {
let body_start_byte = self.line_offsets[body_start_line];
let body_end_byte = if body_end_line < self.line_offsets.len() {
self.line_offsets[body_end_line].saturating_sub(1)
} else {
source.len()
};
let body_end_byte = body_end_byte.min(source.len());
if body_end_byte > body_start_byte {
let body = &source[body_start_byte..body_end_byte];
let spans = highlight_source(body, &canonical);
for span in spans {
let abs_start = body_start_byte + span.start;
let abs_end = body_start_byte + span.end;
for line_idx in body_start_line..body_end_line {
let line_byte_start = self.line_offsets[line_idx];
let line_byte_end = if line_idx + 1 < self.line_offsets.len() {
self.line_offsets[line_idx + 1].saturating_sub(1)
} else {
source.len()
};
if abs_start >= line_byte_end { continue; }
if abs_end <= line_byte_start { break; }
let local_start = abs_start.saturating_sub(line_byte_start);
let local_end = abs_end.min(line_byte_end) - line_byte_start;
if local_end > local_start {
self.code_block_spans
.entry(line_idx)
.or_default()
.push((
local_start..local_end,
SyntaxHighlight { kind: span.kind },
));
}
}
}
}
}
}
i = j + 1; // skip past closing fence (or break out if unclosed)
}
}
/// Walk the source, find every identifier introduction site (let, fn,
@ -234,6 +312,29 @@ impl SyntaxHighlighter {
}
}
/// byte ranges of inline format markers on a non-fenced markdown line, empty otherwise.
pub fn line_marker_ranges(&self, line_idx: usize, line_text: &str) -> Vec<Range<usize>> {
if line_idx >= self.line_kinds.len() {
return Vec::new();
}
if self.line_kinds[line_idx] != LineKind::Markdown {
return Vec::new();
}
if line_idx < self.line_decors.len() {
match self.line_decors[line_idx] {
LineDecor::CodeBlock
| LineDecor::FenceMarker
| LineDecor::HorizontalRule => return Vec::new(),
_ => {}
}
}
parse_inline(line_text, 0)
.into_iter()
.filter(|(_, h)| h.kind == MD_FORMAT_MARKER)
.map(|(r, _)| r)
.collect()
}
fn highlight_markdown(&self, line: &str) -> Vec<(Range<usize>, SyntaxHighlight)> {
let trimmed = line.trim_start();
let leading = line.len() - trimmed.len();
@ -824,6 +925,7 @@ impl highlighter::Highlighter for SyntaxHighlighter {
current_line: 0,
line_decors: Vec::new(),
user_idents: HashMap::new(),
code_block_spans: HashMap::new(),
};
h.rebuild(&settings.source);
h
@ -863,6 +965,9 @@ impl highlighter::Highlighter for SyntaxHighlighter {
}
if self.in_fenced_code {
if let Some(spans) = self.code_block_spans.get(&ln) {
return spans.clone().into_iter();
}
return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter();
}
@ -1016,6 +1121,49 @@ pub fn highlight_font(kind: u8) -> Option<Font> {
}
}
/// maps a fenced-code label to a tree-sitter language id, recursing on the trailing extension for dotted labels.
fn canonical_code_lang(label: &str) -> Option<String> {
let label = label.trim().to_ascii_lowercase();
if label.is_empty() {
return None;
}
let direct = match label.as_str() {
"rust" | "rs" => Some("rust"),
"py" | "python" => Some("python"),
"js" | "javascript" | "mjs" | "cjs" => Some("javascript"),
"ts" | "typescript" => Some("typescript"),
"tsx" => Some("tsx"),
"jsx" => Some("javascript"),
"c" | "h" => Some("c"),
"cpp" | "c++" | "cc" | "cxx" | "hpp" => Some("cpp"),
"go" => Some("go"),
"rb" | "ruby" => Some("ruby"),
"sh" | "bash" | "shell" | "zsh" => Some("bash"),
"java" => Some("java"),
"html" | "htm" => Some("html"),
"css" => Some("css"),
"scss" => Some("scss"),
"json" => Some("json"),
"lua" => Some("lua"),
"php" => Some("php"),
"toml" => Some("toml"),
"yaml" | "yml" => Some("yaml"),
"swift" => Some("swift"),
"zig" => Some("zig"),
"sql" => Some("sql"),
"make" | "makefile" => Some("make"),
"md" | "markdown" => Some("markdown"),
_ => None,
};
if let Some(d) = direct {
return Some(d.to_string());
}
if let Some(idx) = label.rfind('.') {
return canonical_code_lang(&label[idx + 1..]);
}
None
}
pub fn compute_line_decors(source: &str) -> Vec<LineDecor> {
let classified = classify_document(source);
let line_kinds: Vec<LineKind> = classified.into_iter().map(|cl| cl.kind).collect();

View File

@ -134,48 +134,137 @@ fn gutter_fade_t(distance: usize) -> f32 {
(distance as f32 / max_d).min(1.0)
}
/// 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`.
/// builds iced Spans from cosmic glyphs, grouping by (color, weight, style) and skipping marker ranges.
fn build_color_spans<'a>(
text: &'a str,
glyphs: &[cosmic_text::LayoutGlyph],
attrs_list: &cosmic_text::AttrsList,
base_font: Font,
font_size_px: f32,
marker_ranges: &[std::ops::Range<usize>],
) -> Vec<Span<'a>> {
use iced_wgpu::core::font::{Style as IcedStyle, Weight as IcedWeight};
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)
}
fn cosmic_to_iced_weight(w: cosmic_text::Weight) -> IcedWeight {
match w.0 {
0..=149 => IcedWeight::Thin,
150..=249 => IcedWeight::ExtraLight,
250..=349 => IcedWeight::Light,
350..=449 => IcedWeight::Normal,
450..=549 => IcedWeight::Medium,
550..=649 => IcedWeight::Semibold,
650..=749 => IcedWeight::Bold,
750..=849 => IcedWeight::ExtraBold,
_ => IcedWeight::Black,
}
}
fn cosmic_to_iced_style(s: cosmic_text::Style) -> IcedStyle {
match s {
cosmic_text::Style::Normal => IcedStyle::Normal,
cosmic_text::Style::Italic => IcedStyle::Italic,
cosmic_text::Style::Oblique => IcedStyle::Oblique,
}
}
/// span grouping key — color repr plus iced weight/style.
type StyleKey = (Option<u32>, IcedWeight, IcedStyle);
let style_at = |byte_idx: usize| -> (IcedWeight, IcedStyle) {
let attrs = attrs_list.get_span(byte_idx);
(cosmic_to_iced_weight(attrs.weight), cosmic_to_iced_style(attrs.style))
};
let push_span = |spans: &mut Vec<Span<'a>>,
range: std::ops::Range<usize>,
key: StyleKey| {
if range.start >= range.end || range.end > text.len() {
return;
}
let mut span = Span::new(&text[range]);
if let Some(raw) = key.0 {
let c = cosmic_text::Color(raw);
span = span.color(cosmic_to_iced(c, font_size_px));
}
if key.1 != IcedWeight::Normal || key.2 != IcedStyle::Normal {
span = span.font(Font {
weight: key.1,
style: key.2,
..base_font
});
}
spans.push(span);
};
// subtract every marker range from the full line range.
let visible_segments: Vec<std::ops::Range<usize>> = if marker_ranges.is_empty() {
vec![0..text.len()]
} else {
let mut segs = vec![0..text.len()];
for marker in marker_ranges {
let mut next = Vec::with_capacity(segs.len() + 1);
for seg in segs.into_iter() {
if marker.end <= seg.start || marker.start >= seg.end {
next.push(seg);
} else {
if seg.start < marker.start {
next.push(seg.start..marker.start);
}
if marker.end < seg.end {
next.push(marker.end..seg.end);
}
}
}
segs = next;
}
segs
};
if glyphs.is_empty() {
return vec![Span::new(text)];
}
// empty line — fall back to line-level attrs.
let (w, s) = style_at(0);
let key: StyleKey = (None, w, s);
let mut spans = Vec::new();
let mut seg_start = 0usize;
let mut cur_color: Option<cosmic_text::Color> = glyphs.first().and_then(|g| g.color_opt);
for seg in &visible_segments {
push_span(&mut spans, seg.clone(), key);
}
if spans.is_empty() {
spans.push(Span::new(text));
}
return spans;
}
let mut spans: Vec<Span<'a>> = Vec::new();
for seg in &visible_segments {
let mut seg_start = seg.start;
let mut cur_key: Option<StyleKey> = None;
for glyph in glyphs {
if glyph.color_opt != cur_color {
let end = glyph.start.min(text.len());
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, font_size_px));
if glyph.start < seg.start || glyph.start >= seg.end {
continue;
}
spans.push(span);
let (w, s) = style_at(glyph.start);
let key: StyleKey = (glyph.color_opt.map(|c| c.0), w, s);
match cur_key {
None => {
seg_start = glyph.start;
cur_key = Some(key);
}
seg_start = end;
cur_color = glyph.color_opt;
Some(prev) if prev != key => {
push_span(&mut spans, seg_start..glyph.start, prev);
seg_start = glyph.start;
cur_key = Some(key);
}
_ => {}
}
}
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, font_size_px));
if let Some(key) = cur_key {
push_span(&mut spans, seg_start..seg.end, key);
}
spans.push(span);
}
if spans.is_empty() {
@ -1505,20 +1594,41 @@ where
let mut child_idx = 0;
let children_layouts: Vec<_> = layout.children().collect();
// Build paragraphs and retain in widget State. fill_paragraph
// stores Weak refs — the Paragraphs must survive until the
// renderer's prepare() phase. State lives in the widget tree.
// cursor line draws raw cosmic glyphs, other lines strip marker bytes for WYS.
let active_cursor_line = if self.is_focused_block {
self.cursor_line
} else {
None
};
let highlighter_borrow = state.highlighter.borrow();
let highlighter_any: &dyn std::any::Any = &*highlighter_borrow;
let syntax_highlighter = highlighter_any.downcast_ref::<crate::syntax::SyntaxHighlighter>();
{
let mut paras = state.retained_paragraphs.borrow_mut();
paras.clear();
let metrics = state.line_metrics.borrow();
for i in 0..line_count {
let line_text = buffer.lines[i].text();
let attrs_list = buffer.lines[i].attrs_list();
let glyphs: Vec<cosmic_text::LayoutGlyph> =
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, f32::from(text_size));
let marker_ranges = if active_cursor_line == Some(i) {
Vec::new()
} else {
syntax_highlighter
.map(|h| h.line_marker_ranges(i, line_text))
.unwrap_or_default()
};
let spans = build_color_spans(
line_text,
&glyphs,
attrs_list,
font,
f32::from(text_size),
&marker_ranges,
);
let visual_rows = metrics.get(i).map(|m| m.visual_rows).unwrap_or(1).max(1);
paras.push(iced_graphics::text::Paragraph::with_spans(Text {
content: spans.as_slice(),

View File

@ -17,8 +17,10 @@ use acord_viewport::{
viewport_set_line_indicator, viewport_set_gutter_rainbow,
viewport_set_auto_pair_flags,
viewport_send_command, viewport_free_string,
viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes,
ViewportHandle,
};
use acord_viewport::sidecar;
use acord_viewport::browser::{self, BrowserHandle};
use crate::config::Config;
@ -204,19 +206,8 @@ impl App {
fn drain_browser_open(&mut self) {
let Some(handle) = self.browser_handle.as_mut() else { return };
let Some(path) = browser::handle::take_pending_open(handle) else { return };
if let Ok(text) = std::fs::read_to_string(&path) {
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
if let Some(w) = &self.window {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord");
w.set_title(&format!("{name} - Acord"));
w.focus_window();
}
self.current_file = Some(path);
self.last_autosaved_hash = None;
if let Ok(bytes) = std::fs::read(&path) {
self.load_file_bytes(&path, bytes);
}
self.close_browser();
}
@ -226,22 +217,93 @@ impl App {
.add_filter("Markdown", &["md", "markdown"])
.add_filter("All Files", &["*"]);
if let Some(path) = dialog.pick_file() {
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(bytes) = std::fs::read(&path) {
self.load_file_bytes(&path, bytes);
}
}
}
fn load_file_bytes(&mut self, path: &std::path::Path, bytes: Vec<u8>) {
let (text_bytes, archive) = if self.is_acord_note(path) {
sidecar::extract_from_md(&bytes)
} else {
(bytes, self.read_external_sidecar(path))
};
let text = String::from_utf8_lossy(&text_bytes).into_owned();
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("md");
if let Some(zip) = archive {
viewport_apply_sidecar_bytes(self.handle, zip.as_ptr(), zip.len());
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
if let Some(w) = &self.window {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord");
w.set_title(&format!("{name} - Acord"));
w.focus_window();
}
self.current_file = Some(path);
self.current_file = Some(path.to_path_buf());
self.last_autosaved_hash = None;
}
fn is_acord_note(&self, path: &std::path::Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !ext.eq_ignore_ascii_case("md") {
return false;
}
let notes_dir = self.config.notes_dir();
let canon_dir = std::fs::canonicalize(&notes_dir).unwrap_or(notes_dir);
let canon_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canon_path.starts_with(&canon_dir)
}
fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf {
let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf());
let s = canon.to_string_lossy();
let trimmed = s.trim_start_matches('/').trim_start_matches('\\');
let encoded: String = trimmed
.chars()
.map(|c| match c {
'/' | '\\' | ':' => '.',
_ => c,
})
.collect();
self.config
.notes_dir()
.join(".external")
.join(format!("{encoded}.acord"))
}
fn read_external_sidecar(&self, original: &std::path::Path) -> Option<Vec<u8>> {
let path = self.external_sidecar_path(original);
std::fs::read(path).ok()
}
fn write_external_sidecar(&self, original: &std::path::Path, archive: Option<&[u8]>) {
let path = self.external_sidecar_path(original);
match archive {
Some(bytes) if !bytes.is_empty() => {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, bytes);
}
_ => {
let _ = std::fs::remove_file(&path);
}
}
}
fn take_archive_bytes(&self) -> Option<Vec<u8>> {
let mut len: usize = 0;
let ptr = viewport_take_sidecar_bytes(self.handle, &mut len as *mut usize);
if ptr.is_null() || len == 0 {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec();
viewport_free_bytes(ptr, len);
Some(bytes)
}
fn save_file(&mut self) {
@ -277,9 +339,19 @@ impl App {
.to_string_lossy()
.into_owned();
viewport_free_string(text_ptr);
if std::fs::write(path, &text).is_ok() {
let archive = self.take_archive_bytes();
let in_library = self.is_acord_note(path);
let file_bytes: Vec<u8> = match (&archive, in_library) {
(Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc),
_ => text.as_bytes().to_vec(),
};
if std::fs::write(path, &file_bytes).is_ok() {
self.last_autosaved_hash = Some(text_hash(&text));
}
if !in_library {
self.write_external_sidecar(path, archive.as_deref());
}
}
fn derive_default_filename(&self) -> String {
@ -334,15 +406,29 @@ impl App {
let hash = text_hash(&text);
if Some(hash) == self.last_autosaved_hash { return; }
// skip the launch stub so it can't overwrite last session's Untitled.md.
if self.current_file.is_none() && is_effectively_blank(&text) {
return;
}
let path = self.current_file.clone().unwrap_or_else(|| {
self.config.notes_dir().join("Untitled.md")
});
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&path, &text).is_ok() {
let archive = self.take_archive_bytes();
let in_library = self.is_acord_note(&path);
let file_bytes: Vec<u8> = match (&archive, in_library) {
(Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc),
_ => text.as_bytes().to_vec(),
};
if std::fs::write(&path, &file_bytes).is_ok() {
self.last_autosaved_hash = Some(hash);
}
if !in_library {
self.write_external_sidecar(&path, archive.as_deref());
}
}
fn winit_button(button: MouseButton) -> u8 {
@ -634,6 +720,15 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
/// true when the buffer is empty or just leading heading markers.
fn is_effectively_blank(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return true;
}
trimmed.trim_start_matches('#').trim().is_empty()
}
/// Map winit logical keys to iced keyboard keys for direct iced event push.
fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};