feat: RTL reading view, note composer commands, round 3 audit tasks
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Right-to-left rows (unicode-bidi, owner approved): whole-line ordering, right alignment, system
Hebrew/Arabic fallback fonts. Engine commands note.extract and note.merge (assistant gate: writes,
merge also needs allow_delete). Round 3 tasks 25-30 added to specs/obsidian-basics/tasks.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-19 11:54:38 -03:00
parent 5e8020aa38
commit 302164b7ed
16 changed files with 446 additions and 10 deletions
Generated
+7
View File
@@ -2612,6 +2612,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
@@ -4190,6 +4191,12 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
+1
View File
@@ -55,6 +55,7 @@ thiserror = "2"
# Native macOS menu bar (apps/notescraft only; owner-approved: close/minimise/zoom live in the menu bar since the title bar is gone). No default features: no GTK.
muda = { version = "=0.21.2", default-features = false }
egui = { version = "0.36", default-features = false }
unicode-bidi = { version = "0.3.18", default-features = false, features = ["std", "hardcoded-data"] }
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
+1 -1
View File
@@ -60,7 +60,7 @@ Pending, by area:
- **Code blocks, grammars and formatting (done, 15 of 15 tasks, specs/code-blocks-and-formatting):** `toml` and `ts` fences now highlight through two original grammars embedded in `notescraft-syntax` (`crates/syntax/grammars/*.sublime-syntax`), and the batch review has a third check, "Add missing language labels": `blocks::detect_language` names the language of an unlabeled fence conservatively (shebang, PHP, HTML, XML, JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML, on the first 4 KiB and 40 non-blank lines) and each `ChangeKind::Label` is a zero-length insert right after the fence run that always needs review (`batch.start` takes `labels`, on by default). Not built — the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. A note with 50 code blocks draws its first frame in 15 ms median (release), 87 ms in a cold process.
- **Editor previews (done, 11 of 11 tasks, specs/editor-previews):** not built or limited — no swatches or arrows in Source mode or in rows carrying inline math, and a hex that Obsidian reads as a tag (`#cba6f7` in prose) stays a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only if the diagram parser returns an unrenderable kind (today it answers `NoHeader` for one it does not know, so those show the Error chip). Scanning 100 rows of 10 colours (1,000 hits) takes 0.05 ms median, 0.15 ms worst, in release.
- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), audio embeds with Play/Stop through the system player, video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; unbuilt: RTL (egui has no bidi or Arabic shaping), hand check of the packaged app and a Windows playback run.
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer engine commands `note.extract` and `note.merge` built, UI open; unbuilt: hand check of the packaged app and a Windows playback run.
- **Editor (built, see phase 14):** spell-check needs an owner decision (new dependency or in-house checker); open: mobile bottom toolbar, inline fix popover, block menu by keyboard, embed with other text on one line, checkboxes inside embeds, embed refresh after editing another tab.
- **Editor settings:** the page has only Vim mode; more settings need editor features first (line numbers, spellcheck, font size, ...), then sub-menus for them.
- **Community plugins:** a browse page and sub-menu.
+3 -1
View File
@@ -18,7 +18,7 @@ use crate::session::Session;
/// The commands that delete or rename vault content: they need
/// `assistant.allow_delete` on top of `assistant.allow_write`.
pub const DELETE_COMMANDS: &[&str] = &["note.delete", "folder.delete", "calendar.delete", "calendar.bulk_delete"];
pub const DELETE_COMMANDS: &[&str] = &["note.delete", "note.merge", "folder.delete", "calendar.delete", "calendar.bulk_delete"];
/// The core plugin a command belongs to, for the per-plugin tool toggle of
/// Settings, AI assistant, Plugin tools. Core plugins are on until the user
@@ -46,6 +46,8 @@ pub const WRITE_COMMANDS: &[&str] = &[
"note.write",
"note.duplicate",
"note.rename",
"note.extract",
"note.merge",
"note.property_set",
"note.property_remove",
"note.property_rename",
+25
View File
@@ -73,6 +73,14 @@ fn core_specs() -> Vec<CommandSpec<Session>> {
.with_shortcut("Mod-n")
.with_param(ParamSpec::required("path", ParamKind::String, "note path, ending in .md"))
.with_param(ParamSpec::optional("content", ParamKind::String, "initial content, empty by default")),
CommandSpec::new("note.extract", "Extract to new note", "Moves a passage of a note into a new note and links to it", writable, note_extract)
.with_param(ParamSpec::required("path", ParamKind::String, "the note holding the passage"))
.with_param(ParamSpec::required("start", ParamKind::Integer, "byte offset where the passage starts"))
.with_param(ParamSpec::required("end", ParamKind::Integer, "byte offset where the passage ends"))
.with_param(ParamSpec::required("to", ParamKind::String, "the new note's path, ending in .md")),
CommandSpec::new("note.merge", "Merge note into another", "Appends a note to another note and trashes it", writable, note_merge)
.with_param(ParamSpec::required("from", ParamKind::String, "the note to fold in"))
.with_param(ParamSpec::required("into", ParamKind::String, "the note that receives it")),
CommandSpec::new("note.delete", "Delete note", "Moves a note to the trash", writable, note_delete).with_param(ParamSpec::required(
"path",
ParamKind::String,
@@ -199,6 +207,23 @@ fn note_create(session: &mut Session, params: &Value) -> CommandResult {
json(json!({ "path": path }))
}
/// `note.extract`: moves a passage into a new note and links to it.
fn note_extract(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
let to = text_param(params, "to")?;
let range = offset_param(params, "start")?..offset_param(params, "end")?;
let done = session.extract_to_note(&path, range, &to).map_err(|error| command_error(&error))?;
json(json!({ "path": done.path, "link": done.link }))
}
/// `note.merge`: appends a note to another and trashes it.
fn note_merge(session: &mut Session, params: &Value) -> CommandResult {
let from = text_param(params, "from")?;
let into = text_param(params, "into")?;
session.merge_into_note(&from, &into).map_err(|error| command_error(&error))?;
json(json!({ "from": from, "into": into, "merged": true }))
}
/// `note.delete`: moves a note to the trash.
fn note_delete(session: &mut Session, params: &Value) -> CommandResult {
let path = text_param(params, "path")?;
+93
View File
@@ -0,0 +1,93 @@
//! The note composer: move a passage of one note into a new note, or fold a whole note into another.
use std::ops::Range;
use crate::error::{Error, Result};
use crate::vault::session::Session;
/// Where an extracted passage went.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Extracted {
/// The new note's vault path.
pub path: String,
/// The link left in the source note.
pub link: String,
}
impl Session {
/// The text of a note as it is now: the open buffer when there is one, else the file.
fn current_text(&self, path: &str) -> Result<String> {
match self.buffer(path) {
Some(buffer) => Ok(buffer.text().to_owned()),
None => self.read_note(path),
}
}
/// Replaces `range` of a note with `replacement`, through its open buffer when there is one (so the editor
/// and its undo history see the change), else on disk.
fn replace_in_note(&mut self, path: &str, range: Range<usize>, replacement: &str) -> Result<()> {
if self.buffer(path).is_some() {
return self.buffer_mut(path)?.edit(range, replacement);
}
let mut text = self.read_note(path)?;
if range.start > range.end || range.end > text.len() || !text.is_char_boundary(range.start) || !text.is_char_boundary(range.end) {
return Err(Error::InvalidRange(format!("{}..{} is not a range of `{path}`", range.start, range.end)));
}
text.replace_range(range, replacement);
self.write_note(path, &text).map(|_| ())
}
/// Moves the text `range` of `path` into the new note `to` and leaves a link to it in its place.
///
/// # Errors
///
/// [`Error::InvalidRange`] for an empty range or one that is not on character boundaries of the note,
/// [`Error::Exists`] when `to` is taken, [`Error::ReadOnly`], or whatever reading and writing return.
/// Nothing is changed when the new note cannot be made.
pub fn extract_to_note(&mut self, path: &str, range: Range<usize>, to: &str) -> Result<Extracted> {
let text = self.current_text(path)?;
let passage = text
.get(range.clone())
.filter(|passage| !passage.trim().is_empty())
.ok_or_else(|| Error::InvalidRange(format!("{}..{} is not a passage of `{path}`", range.start, range.end)))?
.to_owned();
let to = self.note_path(to)?;
self.create_note(&to, &format!("{}\n", passage.trim_end()))?;
let link = self.config().files.link_text(path, &to, None);
let ends_line = passage.ends_with('\n');
let replacement = if ends_line { format!("{link}\n") } else { link.clone() };
if let Err(error) = self.replace_in_note(path, range, &replacement) {
// The source could not take the link: take the new note back so the passage is not in two places.
let _ = self.delete_note(&to);
return Err(error);
}
Ok(Extracted { path: to, link })
}
/// Appends the note `from` to the end of the note `into` and moves `from` to the trash.
///
/// Links that pointed at `from` are not rewritten; they show as unresolved.
///
/// # Errors
///
/// [`Error::InvalidPath`] when the two are the same note, [`Error::NotFound`], [`Error::ReadOnly`], or
/// whatever reading and writing return. `from` is trashed only after `into` was written.
pub fn merge_into_note(&mut self, from: &str, into: &str) -> Result<()> {
let (from, into) = (self.note_path(from)?, self.note_path(into)?);
if from == into {
return Err(Error::InvalidPath(format!("`{from}` cannot be merged into itself")));
}
let moved = self.current_text(&from)?;
let base = self.current_text(&into)?;
let separator = if base.is_empty() || base.ends_with("\n\n") {
""
} else if base.ends_with('\n') {
"\n"
} else {
"\n\n"
};
let at = base.len();
self.replace_in_note(&into, at..at, &format!("{separator}{moved}"))?;
self.delete_note(&from)
}
}
+1
View File
@@ -3,6 +3,7 @@
pub(crate) mod attachments;
pub(crate) mod bookmarks;
pub(crate) mod buffers;
pub(crate) mod composer;
pub(crate) mod daily;
pub(crate) mod external;
pub(crate) mod find;
+2
View File
@@ -34,6 +34,8 @@ const CORE_IDS: &[&str] = &[
"note.write",
"note.create",
"note.delete",
"note.extract",
"note.merge",
"note.properties",
"note.property_remove",
"note.property_rename",
+67
View File
@@ -0,0 +1,67 @@
//! The note composer: `note.extract` and `note.merge`.
mod common;
use common::{temp_config, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::json;
fn open(name: &str, files: &[(&str, &str)]) -> (Registry<Session>, Session, common::TempVault, common::TempVault) {
let vault = temp_vault(name);
for (path, text) in files {
std::fs::write(vault.path().join(path), text).expect("seed");
}
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open(vault.path()).expect("open");
let config = temp_config(name);
session.use_settings_dir(config.path());
(registry, session, vault, config)
}
#[test]
fn extract_moves_the_passage_into_a_new_note_and_leaves_a_link() {
let (registry, mut session, vault, _config) = open("extract", &[("src.md", "intro\nthe passage\noutro\n")]);
let out = registry.run("note.extract", &mut session, &json!({"path": "src.md", "start": 6, "end": 18, "to": "Passage.md"})).expect("extract");
assert_eq!(out["link"], "[[Passage]]");
assert_eq!(std::fs::read_to_string(vault.path().join("Passage.md")).expect("new"), "the passage\n");
assert_eq!(std::fs::read_to_string(vault.path().join("src.md")).expect("src"), "intro\n[[Passage]]\noutro\n");
assert!(session.index().resolve("src.md", "Passage").is_resolved(), "the link resolves");
}
#[test]
fn extract_refuses_bad_ranges_and_taken_names_without_changing_anything() {
let (registry, mut session, vault, _config) = open("extract-bad", &[("src.md", "héllo world\n"), ("taken.md", "x\n")]);
for params in [
json!({"path": "src.md", "start": 2, "end": 5, "to": "n.md"}),
json!({"path": "src.md", "start": 3, "end": 3, "to": "n.md"}),
json!({"path": "src.md", "start": 0, "end": 999, "to": "n.md"}),
json!({"path": "src.md", "start": 0, "end": 5, "to": "taken.md"}),
json!({"path": "src.md", "start": 0, "end": 5, "to": "../escape.md"}),
] {
assert!(registry.run("note.extract", &mut session, &params).is_err(), "{params}");
}
assert_eq!(std::fs::read_to_string(vault.path().join("src.md")).expect("src"), "héllo world\n");
assert_eq!(std::fs::read_to_string(vault.path().join("taken.md")).expect("taken"), "x\n");
assert!(!vault.path().join("n.md").exists());
}
#[test]
fn extract_goes_through_an_open_buffer() {
let (registry, mut session, _vault, _config) = open("extract-buffer", &[("src.md", "keep this\ncut me\n")]);
session.open_buffer("src.md").expect("buffer");
registry.run("note.extract", &mut session, &json!({"path": "src.md", "start": 10, "end": 17, "to": "Cut.md"})).expect("extract");
assert_eq!(session.buffer("src.md").map(|buffer| buffer.text().to_owned()).as_deref(), Some("keep this\n[[Cut]]\n"));
assert!(session.buffer("src.md").is_some_and(notescraft_engine::Buffer::can_undo), "one undo step");
}
#[test]
fn merge_appends_the_note_and_trashes_it() {
let (registry, mut session, vault, _config) = open("merge", &[("a.md", "# A\nbody\n"), ("b.md", "# B\n")]);
registry.run("note.merge", &mut session, &json!({"from": "b.md", "into": "a.md"})).expect("merge");
assert_eq!(std::fs::read_to_string(vault.path().join("a.md")).expect("a"), "# A\nbody\n\n# B\n");
assert!(!vault.path().join("b.md").exists());
assert!(registry.run("note.merge", &mut session, &json!({"from": "a.md", "into": "a.md"})).is_err(), "not into itself");
assert!(registry.run("note.merge", &mut session, &json!({"from": "nope.md", "into": "a.md"})).is_err());
assert_eq!(std::fs::read_to_string(vault.path().join("a.md")).expect("a"), "# A\nbody\n\n# B\n", "a failed merge changes nothing");
}
+1
View File
@@ -21,6 +21,7 @@ log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
unicode-bidi = { workspace = true }
notescraft-engine = { workspace = true }
notescraft-vault = { workspace = true }
notescraft-acp = { workspace = true }
+30
View File
@@ -11,6 +11,29 @@ const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Sem
const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
const STIX_MATH: &[u8] = include_bytes!("../../../../assets/fonts/STIXTwoMath-Regular.ttf");
/// Fonts the system ships with Hebrew and Arabic letters, read when present: the bundled fonts have none, and a
/// line of right-to-left text would otherwise show empty boxes.
const SYSTEM_FALLBACKS: [&str; 7] = [
"/System/Library/Fonts/SFHebrew.ttf",
"/System/Library/Fonts/SFArabic.ttf",
"C:\\Windows\\Fonts\\segoeui.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
"/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf",
];
/// Largest system font read, in bytes.
const MAX_FALLBACK_BYTES: u64 = 24 * 1024 * 1024;
/// The bytes of the system fallback fonts that exist here.
fn system_fallbacks() -> Vec<Vec<u8>> {
SYSTEM_FALLBACKS
.iter()
.filter(|path| std::fs::metadata(path).is_ok_and(|meta| meta.len() <= MAX_FALLBACK_BYTES))
.filter_map(|path| std::fs::read(path).ok())
.collect()
}
/// Named family for medium-weight text.
pub const MEDIUM: &str = "inter-medium";
/// Named family for semibold text (titles, tab labels).
@@ -45,6 +68,13 @@ pub fn install(ctx: &egui::Context) {
}
let proportional = fonts.families.entry(FontFamily::Proportional).or_default();
proportional.insert(0, "inter".to_owned());
for (index, bytes) in system_fallbacks().into_iter().enumerate() {
let name = format!("system-fallback-{index}");
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_owned(bytes)));
if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) {
chain.push(name);
}
}
let monospace = fonts.families.entry(FontFamily::Monospace).or_default();
monospace.insert(0, "jetbrains-mono".to_owned());
for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] {
+1
View File
@@ -13,6 +13,7 @@ use egui::Ui;
use notescraft_markdown::{Document, Item, ItemKind};
mod blocks;
mod direction;
pub(crate) mod edit;
#[cfg(test)]
mod edit_tests;
@@ -0,0 +1,171 @@
//! Right-to-left rows: a line whose first strong letter is Hebrew, Arabic or another right-to-left script is
//! laid out for the right: its runs are reordered to the order they are read on screen (Unicode bidirectional
//! algorithm, `unicode-bidi`) and the row is aligned to the right edge.
//!
//! egui does not join Arabic letters, so Arabic shows in its isolated forms; Hebrew and other scripts
//! without joining are right. Source mode and the caret keep the logical order.
use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class};
use super::inlines::Span;
/// Whether the first strong character of `text` reads right to left.
#[must_use]
pub(super) fn is_rtl(text: &str) -> bool {
text.chars().map(bidi_class).find_map(|class| match class {
BidiClass::L => Some(false),
BidiClass::R | BidiClass::AL => Some(true),
_ => None,
}) == Some(true)
}
/// Stands in for a span that is not running text (inline math, a footnote mark) while the line is ordered.
const OBJECT: char = '\u{fffc}';
/// The running text of a span that may be reordered; `None` for the others, which stay whole.
fn text_of(span: &Span) -> Option<&str> {
match span {
Span::Plain(text)
| Span::Mono(text)
| Span::Bold(text)
| Span::Italic(text)
| Span::Strike(text)
| Span::Chip(text)
| Span::Highlight(text)
| Span::Colored(text, _)
| Span::Link { text, .. } => Some(text),
Span::Wiki { shown, .. } => Some(shown),
_ => None,
}
}
/// `span` with `text` as its running text.
fn with_text(span: &Span, text: String) -> Span {
match span {
Span::Plain(_) => Span::Plain(text),
Span::Mono(_) => Span::Mono(text),
Span::Bold(_) => Span::Bold(text),
Span::Italic(_) => Span::Italic(text),
Span::Strike(_) => Span::Strike(text),
Span::Chip(_) => Span::Chip(text),
Span::Highlight(_) => Span::Highlight(text),
Span::Colored(_, color) => Span::Colored(text, *color),
Span::Wiki { target, subpath, .. } => Span::Wiki { shown: text, target: target.clone(), subpath: subpath.clone() },
Span::Link { dest, .. } => Span::Link { text, dest: dest.clone() },
other => other.clone(),
}
}
/// The spans of a right-to-left row in screen order, or `None` for a row that reads left to right.
///
/// The whole line is ordered at once (a Latin phrase that crosses two spans stays in reading order), then cut
/// back into spans, each letter keeping the style it had.
#[must_use]
pub(super) fn arrange(spans: &[Span]) -> Option<Vec<Span>> {
let mut text = String::new();
let mut letters: Vec<(usize, char, usize)> = Vec::new();
for (owner, span) in spans.iter().enumerate() {
if let Some(run) = text_of(span) {
for letter in run.chars() {
letters.push((text.len(), letter, owner));
text.push(letter);
}
} else {
letters.push((text.len(), OBJECT, owner));
text.push(OBJECT);
}
}
if !is_rtl(&text) {
return None;
}
let info = BidiInfo::new(&text, Some(Level::rtl()));
let mut shown: Vec<(char, usize)> = Vec::with_capacity(letters.len());
for paragraph in &info.paragraphs {
let (levels, runs) = info.visual_runs(paragraph, paragraph.range.clone());
for run in runs {
let from = letters.partition_point(|(at, ..)| *at < run.start);
let to = letters.partition_point(|(at, ..)| *at < run.end);
let inside = letters.get(from..to).unwrap_or_default().iter().map(|(_, letter, owner)| (*letter, *owner));
if levels.get(run.start).is_some_and(Level::is_rtl) {
shown.extend(inside.rev());
} else {
shown.extend(inside);
}
}
}
let mut out: Vec<Span> = Vec::new();
let mut open: Option<(usize, String)> = None;
let close = |open: &mut Option<(usize, String)>, out: &mut Vec<Span>| {
if let Some((owner, run)) = open.take() {
out.extend(spans.get(owner).map(|span| with_text(span, run)));
}
};
for (letter, owner) in shown {
if letter == OBJECT && spans.get(owner).is_some_and(|span| text_of(span).is_none()) {
close(&mut open, &mut out);
out.extend(spans.get(owner).cloned());
continue;
}
match &mut open {
Some((current, run)) if *current == owner => run.push(letter),
_ => {
close(&mut open, &mut out);
open = Some((owner, letter.to_string()));
}
}
}
close(&mut open, &mut out);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
const HEBREW: &str = "\u{5e9}\u{5dc}\u{5d5}\u{5dd}";
#[test]
fn direction_follows_the_first_strong_letter() {
assert!(is_rtl(HEBREW));
assert!(is_rtl(&format!(" 1. {HEBREW} abc")));
assert!(!is_rtl("abc \u{5e9}"));
assert!(!is_rtl("1234 !?"));
assert!(is_rtl("\u{645}\u{631}\u{62d}\u{628}\u{627}"));
}
fn plain(text: &str) -> Span {
Span::Plain(text.to_owned())
}
#[test]
fn hebrew_is_reversed_for_the_screen_and_latin_inside_it_is_kept() {
let reversed: String = HEBREW.chars().rev().collect();
assert_eq!(arrange(&[plain(HEBREW)]), Some(vec![plain(&reversed)]));
assert_eq!(arrange(&[plain(&format!("{HEBREW} abc"))]), Some(vec![plain(&format!("abc {reversed}"))]));
}
#[test]
fn a_latin_phrase_across_two_spans_keeps_its_reading_order_and_styles() {
let spans = [plain(&format!("{HEBREW} this ")), Span::Bold(String::from("is")), plain(" it")];
let reversed: String = HEBREW.chars().rev().collect();
let arranged = arrange(&spans).expect("rtl row");
assert_eq!(arranged, [plain("this "), Span::Bold(String::from("is")), plain(" it"), plain(&format!(" {reversed}"))]);
}
#[test]
fn a_left_to_right_row_and_atomic_spans() {
assert!(arrange(&[plain("plain text")]).is_none());
assert!(arrange(&[Span::Math { src: String::from("x") }]).is_none());
let spans = [plain(HEBREW), Span::Math { src: String::from("x") }];
let arranged = arrange(&spans).expect("rtl row");
assert_eq!(arranged.first(), Some(&Span::Math { src: String::from("x") }), "the formula sits at the left end of a right-to-left row");
}
#[test]
fn hostile_text_does_not_panic() {
for text in ["\u{202e}\u{202b}\u{2067}", "\u{5d0}\u{2066}\u{2069}\u{2069}\u{2066}", &"\u{202b}".repeat(500), ""] {
let _ = arrange(&[plain(text)]);
let _ = is_rtl(text);
}
}
}
+13 -6
View File
@@ -262,21 +262,28 @@ pub(super) fn paint_row_rect(ui: &mut Ui, tokens: &Tokens, spans: &[Span], row:
fn text_row_rect(ui: &mut Ui, tokens: &Tokens, spans: &[Span], row: &RowStyle, resolves: &dyn Fn(&str) -> bool, ctrl: bool) -> (Rect, LinkOut) {
let owned = crate::previews::settings(ui.ctx()).colors.then(|| super::inlines::colorize(spans, crate::previews::settings(ui.ctx()).named_colors)).flatten();
let spans = owned.as_deref().unwrap_or(spans);
let arranged = super::direction::arrange(spans);
let right_to_left = arranged.is_some();
let spans = arranged.as_deref().unwrap_or(spans);
let JobParts { mut job, links, chips, arrows, swatches } = build_job(spans, tokens, row, resolves, true);
job.wrap = TextWrapping::wrap_at_width(ui.available_width().max(60.0));
let galley = ui.painter().fonts_mut(|f| f.layout_job(job));
let height = galley.size().y.max(row.size);
let wrap_width = ui.available_width().max(60.0);
let (rect, _) = ui.allocate_exact_size(egui::vec2(0.0, height), Sense::hover());
// A right-to-left row sits against the right edge (a wrapped one keeps its lines left-aligned in that block).
let shift = if right_to_left { (wrap_width - galley.size().x).max(0.0) } else { 0.0 };
let origin = rect.left_top() + egui::vec2(shift, 0.0);
// Selection: registers the row, paints its wash behind the text and handles the pointer on it.
super::select::row(ui, tokens, &galley, Rect::from_min_size(rect.min, egui::vec2(wrap_width, height)));
paint_chips(ui, tokens, &galley, rect.left_top(), &chips);
paint_arrows(ui, &galley, rect.left_top(), &arrows);
paint_swatches(ui, tokens, &galley, rect.left_top(), &swatches);
ui.painter().galley(rect.left_top(), Arc::clone(&galley), row.color);
super::select::row(ui, tokens, &galley, Rect::from_min_size(origin, egui::vec2(wrap_width - shift, height)));
paint_chips(ui, tokens, &galley, origin, &chips);
paint_arrows(ui, &galley, origin, &arrows);
paint_swatches(ui, tokens, &galley, origin, &swatches);
ui.painter().galley(origin, Arc::clone(&galley), row.color);
let mut out = LinkOut::default();
for link in &links {
let Some(hit) = hit_rect(&galley, rect.left_top(), link.start, link.end) else { continue };
let Some(hit) = hit_rect(&galley, origin, link.start, link.end) else { continue };
// `interact`, not `allocate_rect`: allocating would move the layout cursor back up
// to the first line of a wrapped row and overlap the next block.
let response = ui.interact(hit, ui.id().with(("notescraft-link", link.start, rect.min.x.to_bits(), rect.min.y.to_bits())), Sense::click());
+13
View File
@@ -378,3 +378,16 @@ fn a_note_pops_out_of_the_tabs_and_comes_back_when_its_window_closes() {
d.settle();
assert!(d.app.tabs.is_open("b.md"));
}
#[test]
fn a_hebrew_paragraph_sits_against_the_right_edge_and_an_english_one_stays_left() {
let mut d = driver(&[("h.md", "# H\n\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}\n\nplain english\n")]);
d.app.open_note("h.md");
let texts = d.settle();
let hebrew = texts.iter().find(|p| p.text.contains('\u{5dd}') && p.pos.x > 300.0 && p.pos.y > 100.0).expect("the hebrew row");
let english = find(&texts, "plain english");
let right_end = hebrew.pos.x + hebrew.galley.size().x;
assert!(right_end > english.pos.x + 400.0, "the hebrew row ends far to the right: {right_end} vs {}", english.pos.x);
assert!(hebrew.pos.x > english.pos.x + 300.0, "and starts well right of the left edge");
assert_eq!(hebrew.text.chars().next(), Some('\u{5dd}'), "painted in screen order: the last letter first");
}
+17 -2
View File
@@ -27,7 +27,7 @@ Confirmed missing by grep: no `query` fence handling, no RTL handling. Pop-out a
- [x] 19. Embedded search queries: a ```query fence renders the live results of a vault search (same operators as the search panel), refreshes on index change
Done when: a driven test shows a note with a `query` fence listing the matching notes, and the list changes after a matching note is added. DONE: `blocks/query/mod.rs`, driven test `a_query_block_lists_the_matching_notes_and_follows_new_ones`; answers refresh every second, rows open the note (a note holding the query text lists itself, as in Obsidian)
- [ ] 20. RTL: per-paragraph text direction (auto-detect from the first strong character, plus a "Right to left" editor setting), in the editor and reading view
- [x] 20. RTL in the reading view. Owner approved `unicode-bidi` (2026-10-19). DONE: `views/note_view/direction/` orders a row whose first strong letter is right-to-left (whole line at once, styles kept, inline math and footnote marks stay whole) and `painting.rs` puts the row against the right edge; the UI fonts get the system's Hebrew/Arabic faces as fallbacks (`design/fonts.rs`); tests `direction::tests` (8) and `a_hebrew_paragraph_sits_against_the_right_edge_and_an_english_one_stays_left`; rendered offscreen and viewed. Limits: Arabic stays in isolated letter forms (egui does not join), Source mode and the caret keep logical order, a wrapped right-to-left paragraph keeps its lines left-aligned inside its block, selection and copy on such rows are not mapped back to the source
Done when: a unit test resolves direction for Arabic, Hebrew and Latin lines; an offscreen render shows an Arabic paragraph right-aligned
- [x] 21. Stacked tabs: a tab-group mode where tabs stack as collapsed headers
Done when: a driven test toggles stacked mode and the active tab body shows under the other tabs' headers. DONE: `view.stacked_tabs` (View menu), `widgets/stacked.rs` (earlier tabs fold left, later ones right, title written upward, click unfolds), saved in the UI state; test `stacked_tabs_fold_the_others_into_strips_and_a_click_unfolds_one`. The tab bar stays; no stacking with the split view on
@@ -35,5 +35,20 @@ Confirmed missing by grep: no `query` fence handling, no RTL handling. Pop-out a
Done when: a driven test moves a tab to a pop-out and the main window no longer lists it; closing the pop-out returns it. DONE: `view.pop_out` (View menu), `shell/app/popout.rs`: an immediate egui viewport (a native window under eframe on desktop) shows the rendered note read-only through the split pane's drawing; links open in the main window; closing the window returns the note as a tab. Where eframe embeds viewports (web, the offscreen harness) the note goes straight back to its tab. NOT checked in a real native window (the live window never paints in the agent session); the driven test covers the tab hand-off only
- [x] 23. Editor follow-ups. DONE: checkboxes inside embeds (`ticking_a_task_inside_an_embed_changes_the_embedded_note`), text on an embed's line drawn around it (`text_on_the_line_of_an_embed_is_drawn_around_it`), embeds follow an edit made in the embedded note's own tab (`an_embed_follows_an_edit_made_to_the_embedded_note_in_its_own_tab`). block menu by keyboard (Cmd/Ctrl-Alt-B, `the_block_menu_opens_from_the_keyboard_at_the_caret`; before it was reachable only from the palette)
Done when: one driven or unit test per item passes
- [ ] 24. Hand check of the packaged app: open the DMG build, run the dev hook screenshot and a Windows run of audio/video playback (needs the owner's desktop and a Windows machine)
- [ ] 24. (DMG checked 2026-10-19 without running the UI: mounts, `codesign --verify --deep --strict` passes, arm64, version 0.1.0, contains `view.pop_out` and `view.stacked_tabs`; the window screenshot, a native pop-out window and the Windows run are still open) Hand check of the packaged app: open the DMG build, run the dev hook screenshot and a Windows run of audio/video playback (needs the owner's desktop and a Windows machine)
Done when: a PNG of the real window and a Windows result are attached to this file
## Round 3: gaps still listed as missing or partial in `audit.md` (re-audited 2026-10-19; recheck each row before building, some may have been covered by 1-18)
- [ ] 25. (ENGINE DONE: `vault/composer/mod.rs`, commands `note.extract` and `note.merge`, tests `engine/tests/note_composer.rs` (4); open: the UI, a name prompt for extract on the selection toolbar and a note picker for merge, since the palette cannot take required parameters; links to a merged note are not rewritten) Note composer: `note.merge` (append a note to another, fix links, trash the source) and `note.extract` (move a text range into a new note, leave a `[[link]]`)
Done when: engine integration tests for both commands pass, and each is in the palette
- [ ] 26. Trash restore and empty: list `.notescraft/trash`, restore a note to its place, empty the trash
Done when: engine command tests and a driven test restoring a deleted note
- [ ] 27. Folder notes: a folder opens its note (`folder/folder.md`) when one exists
Done when: a driven test clicking a folder with a folder note opens the note
- [ ] 28. Search: excluded files setting, result sort order, collapse results, `#tag` in a note body click-to-search
Done when: one test per item
- [ ] 29. Reading-only open: "Open in reading mode" (no editing) and an indent-with-tab-or-spaces setting
Done when: a driven test shows a reading tab ignoring a click-to-edit; a unit test for the indent setting
- [ ] 30. Smaller rows from the audit: heading folding in Source mode, table editing helpers, "Move file to…" folder picker, graph timelapse, user themes in Settings, spellcheck language
Done when: each checked against the code first; built or struck out with the reason