feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-21 02:31:52 -03:00
parent bc0b258e04
commit 5e9b5d3cfb
99 changed files with 5716 additions and 323 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ Done (crossed off):
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 (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), an audio tab and audio embeds that play in the app (ffplay sound, a waveform that seeks, a live spectrum from an own FFT), 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; 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 (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers, user themes, the graph timelapse and Source-mode heading folding (30), workspaces (32), heading/block links (33) and note versions (31, local snapshots plus the git history) and Cmd/Ctrl+Enter on tasks in Source and Visual (36, 37) built; unbuilt: hand check of the packaged app and a Windows playback run.
- **Linux and Windows feedback (2026-10-19):** built: the Explorer calendar folds (chevron, remembered, folded on a first run in a window under 720 pt), the Explorer toolbar is laid out by hand (filter box plus two pill buttons, fixed gaps, flush right), the empty Bookmarks section is gone, the menu bar words are flat text with a soft hover/open background, the Related tab scrolls, a new Outline pane (count, filter, folding, active heading, scroll), a performance overlay (`view.performance`: fps, frame ms, memory, CPU; `/proc`, `ps` or PowerShell, no new dependency), and a first-run UI scale taken from the desktop on Linux when the window was not told its scale (`QT_SCALE_FACTOR`, `GDK_SCALE`, `Xft.dpi`, GNOME text scaling). Not confirmed on those systems: the title bar blink (the bar is now drawn whole on every frame and sits above the cards) and Explorer icons drawn out of bounds on a fast scroll (not reproduced offscreen; rows are now clipped to the viewport). Windows paths (PowerShell sampler, menu bar) were not 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.
@@ -102,6 +102,30 @@ fn a_published_folder_serves_only_its_markdown_notes() {
assert!(!index.contains("Hidden note"), "{index}");
}
#[test]
fn a_comment_sidecar_is_never_published() {
let server = Server::start_temp("publish-sidecar");
let (vault, token, _) = server.owned_vault("sidecar");
note(&server, &vault, &token, "docs/a.md", "# Note A\n");
note(&server, &vault, &token, "docs/a.md.comments.json", "{\"version\":1,\"note\":\"docs/a.md\",\"comments\":[]}\n");
let (status, reply) =
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "docs", "kind": "folder"})));
assert_eq!(status, 200, "{reply}");
let slug = reply["slug"].as_str().expect("slug").to_owned();
// The index of the folder lists the note, never the sidecar.
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs"), None, &[], None);
assert_eq!(status, 200);
let index = text(&body);
assert!(index.contains("Note A"), "{index}");
assert!(!index.contains("comments.json"), "{index}");
// And the sidecar itself answers 404 with an empty body.
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs/a.md.comments.json"), None, &[], None);
assert_eq!(status, 404);
assert!(body.is_empty(), "the sidecar must answer with an empty body");
}
#[test]
fn unpublishing_answers_404_at_once() {
let server = Server::start_temp("publish-unpublish");
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

+46 -2
View File
@@ -1576,8 +1576,7 @@ redo = "Redo"
clear = "Clear"
clear-again = "Click again to clear"
colour = "Colour"
label = "Label"
add-label = "Add"
text-size = "Text size"
saved = "Saved to {{name}}"
saved-plain = "Saved · the original picture is unchanged"
saving = "Saving…"
@@ -1655,6 +1654,7 @@ show-backlinks = "Show backlinks"
show-assistant = "Show the assistant"
show-lint = "Show lint"
show-comments = "Show comments"
show-activity = "Show the activity"
region-sidebar = "Sidebar"
region-editor = "Editor"
region-right = "Right panel"
@@ -1689,3 +1689,47 @@ now = "just now"
minutes = "{n}m ago"
hours = "{n}h ago"
days = "{n}d ago"
[ui.activity-panel]
title = "Activity"
loading = "Loading the activity…"
empty = "Nothing happened yet"
unbound = "This vault has no server: there is no activity to show."
unread = "{n} unread"
mark-read = "Mark all as read"
now = "just now"
minutes = "{n}m ago"
hours = "{n}h ago"
days = "{n}d ago"
mention = "mentioned you"
comment = "commented on"
suggestion = "proposed a change to"
note-changed = "changed"
invite-accepted = "accepted your invite"
member-joined = "joined the vault"
role-changed = "changed a role"
thread-resolved = "resolved a thread"
other = "was active"
[ui.audio-view]
spectrum = "Spectrum"
spectrum-tip = "Show or hide the frequency bars"
spectrum-label = "Frequency spectrum"
waveform = "Waveform"
waveform-failed = "The waveform of this file cannot be shown"
no-player = "ffplay is not installed, so this file cannot play here."
volume = "Volume {{percent}}%"
[ui.source-file]
too-large = "This file is over 2 MiB: shown read-only."
plain = "Plain text"
chip = "Language: {{language}}"
banner = "No syntax highlighting found for {{type}}"
install = "Install a grammar"
other = "Use another language"
ignore = "Ignore"
search = "Search languages"
no-match = "No language matches"
avif-reason = "AVIF pictures need a native decoder this app does not ship."
copy-hint = "Click to copy"
kind-image = "Image"
+2 -2
View File
@@ -18,8 +18,8 @@ craft-ui = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true, features = ["image"] }
arboard = { workspace = true }
# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp; owner asked for jpeg and the other formats on 2026-10-21).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp, ico; owner asked for jpeg and the other formats on 2026-10-21).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "ico"] }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+5 -1
View File
@@ -78,12 +78,14 @@ pub fn install(ctx: &egui::Context) {
}
let proportional = fonts.families.entry(FontFamily::Proportional).or_default();
proportional.insert(0, "inter".to_owned());
let mut fallback_names = Vec::new();
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);
chain.push(name.clone());
}
fallback_names.push(name);
}
// STIX Two Math is the last resort of every text family, so symbols, arrows and math operators never show boxes.
if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) {
@@ -91,6 +93,8 @@ pub fn install(ctx: &egui::Context) {
}
let monospace = fonts.families.entry(FontFamily::Monospace).or_default();
monospace.insert(0, "jetbrains-mono".to_owned());
// Hebrew and Arabic inside code (strings, comments) must not show boxes either.
monospace.extend(fallback_names);
monospace.push(MATH.to_owned());
for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] {
let mut chain = vec![face.to_owned(), "inter".to_owned()];
+1
View File
@@ -36,6 +36,7 @@ static ICONS: &[(&str, &[u8])] = &[
icon!("agents/custom"),
icon!("agents/plug"),
icon!("arrow-up"),
icon!("bell"),
("flip-horizontal", br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 5 5-5 5V7"/><path d="m21 7-5 5 5 5V7"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/></svg>"#),
("flip-vertical", br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 3-5 5-5-5h10"/><path d="m17 21-5-5-5 5h10"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/></svg>"#),
("file-down", br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>"#),
+1
View File
@@ -18,6 +18,7 @@ pub use design::motion;
pub use design::sounds;
pub use design::style;
pub use design::theme;
pub use panels::activity_panel;
pub use panels::assistant_panel;
pub use panels::backlinks_panel;
pub use panels::bookmarks_view;
+1 -1
View File
@@ -4,7 +4,7 @@ pub mod assistant_panel;
pub mod bookmarks_view;
pub mod buffers_panel;
pub mod right;
pub use right::{backlinks_panel, comment_panel, outline};
pub use right::{activity_panel, backlinks_panel, comment_panel, outline};
pub mod lint_panel;
pub mod related_panel;
pub mod right_view;
@@ -0,0 +1,350 @@
//! The Activity tab of the right card: the feed of the vault.
//!
//! Pure widget: the shell reads the feed with the `activity.list` command and
//! hands it over as [`ActivityData`]; what the user pressed comes back as
//! [`ActivityResponse`]. No command runs while the panel paints.
use egui::{Layout, RichText, Sense, Ui};
use serde_json::Value;
use crate::text::review::activity_panel as strings;
use crate::theme::{Tokens, color};
/// Text size of the small parts, in points.
const SMALL: f32 = 12.0;
/// Text size of the body of an event, in points.
const BODY: f32 = 13.0;
/// Corner radius of the cards, in points.
const RADIUS: f32 = 12.0;
/// Inner margin of a card, in points.
const CARD_MARGIN: i8 = 12;
/// Most characters of a note path shown in a row.
const PATH_CHARS: usize = 80;
/// Side of the dot that marks an unread event, in points.
const DOT: f32 = 8.0;
/// Height of the row that holds the buttons, in points.
const ROW_HEIGHT: f32 = 28.0;
/// One event of the feed, as the server lists it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ActivityEvent {
/// The id of the event; the cursor of the feed.
pub id: u64,
/// When the server stored the event, in milliseconds.
pub ts_ms: u64,
/// The kind of the event.
pub kind: String,
/// The user id of the actor.
pub actor: String,
/// The display name of the actor.
pub actor_name: String,
/// The note the event is about.
pub note: Option<String>,
/// The comment thread the event is about.
pub thread_id: Option<String>,
/// The line of the note the event is about, one-based.
pub line: Option<u64>,
/// Whether the member has read the event.
pub read: bool,
}
/// The feed of the answer of `activity.list`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ActivityFeed {
/// The events, newest first.
pub events: Vec<ActivityEvent>,
/// How many of them the member has not read.
pub unread: u64,
}
/// What the tab shows.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ActivityData {
/// The events of the feed, newest first.
pub events: Vec<ActivityEvent>,
/// How many events the member has not read.
pub unread: u64,
/// The feed has been read at least once.
pub loaded: bool,
/// The vault has no server: there is no feed to show.
pub unbound: bool,
}
/// The note an event points at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivityTarget {
/// The path of the note, vault-relative.
pub path: String,
/// The line of the note, one-based, when the event carries one.
pub line: Option<u64>,
}
/// What the user did in the tab.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ActivityResponse {
/// An event was clicked: open its note, at its line when it has one.
pub open: Option<ActivityTarget>,
/// The events to mark read; an empty list means every event of the member.
pub mark_read: Option<Vec<u64>>,
}
/// The feed of the answer of `activity.list`: the events newest first and the
/// unread count.
///
/// The server lists the events oldest first; the feed shows them the other way
/// round, so the newest event is the first row.
#[must_use]
pub fn parse_feed(result: &Value) -> ActivityFeed {
let mut events: Vec<ActivityEvent> =
result.get("events").and_then(Value::as_array).map_or_else(Vec::new, |items| items.iter().filter_map(parse_event).collect());
events.sort_by_key(|event| std::cmp::Reverse(event.id));
let unread = result.get("unread").and_then(Value::as_u64).unwrap_or(0);
ActivityFeed { events, unread }
}
/// One event of the answer of `activity.list`.
fn parse_event(value: &Value) -> Option<ActivityEvent> {
let id = value.get("id").and_then(Value::as_u64)?;
Some(ActivityEvent {
id,
ts_ms: value.get("ts_ms").and_then(Value::as_u64).unwrap_or(0),
kind: value.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned(),
actor: value.get("actor").and_then(Value::as_str).unwrap_or_default().to_owned(),
actor_name: value.get("actor_name").and_then(Value::as_str).unwrap_or_default().to_owned(),
note: value.get("note").and_then(Value::as_str).map(str::to_owned),
thread_id: value.get("thread_id").and_then(Value::as_str).map(str::to_owned),
line: value.get("line").and_then(Value::as_u64),
read: value.get("read").and_then(Value::as_bool).unwrap_or(false),
})
}
/// The copy of a kind of event; an unknown kind stays plain.
#[must_use]
pub fn kind_action(kind: &str) -> &'static str {
match kind {
"mention" => strings::mention(),
"comment" => strings::comment(),
"suggestion" => strings::suggestion(),
"note_changed" => strings::note_changed(),
"invite_accepted" => strings::invite_accepted(),
"member_joined" => strings::member_joined(),
"role_changed" => strings::role_changed(),
"thread_resolved" => strings::thread_resolved(),
_ => strings::other(),
}
}
/// The name of the actor, or its id when the server knows no name.
#[must_use]
pub fn actor_name(event: &ActivityEvent) -> &str {
if event.actor_name.is_empty() { &event.actor } else { &event.actor_name }
}
/// The note of an event, with its line when it has one.
#[must_use]
pub fn note_label(path: &str, line: Option<u64>) -> String {
let mut out = excerpt(path, PATH_CHARS);
if let Some(line) = line {
out.push(':');
out.push_str(&line.to_string());
}
out
}
/// The first `max` characters of a text, with an ellipsis when it was cut.
#[must_use]
pub fn excerpt(text: &str, max: usize) -> String {
let mut out: String = text.chars().take(max).collect();
if text.chars().nth(max).is_some() {
out.push('…');
}
out
}
/// The time of an event, as a short relative text.
#[must_use]
pub fn relative_time(ts_ms: u64, now_ms: u64) -> String {
let minutes = now_ms.saturating_sub(ts_ms) / 60_000;
if minutes == 0 {
return strings::now().to_owned();
}
if minutes < 60 {
return strings::minutes().replace("{n}", &minutes.to_string());
}
let hours = minutes / 60;
if hours < 24 {
return strings::hours().replace("{n}", &hours.to_string());
}
strings::days().replace("{n}", &(hours / 24).to_string())
}
/// The clock now, in milliseconds since the epoch.
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(u64::MAX))
}
/// Shows the feed of the vault, newest first. What the user pressed comes back
/// in the answer.
pub fn show(ui: &mut Ui, tokens: &Tokens, data: &ActivityData) -> ActivityResponse {
let mut response = ActivityResponse::default();
if !data.loaded {
muted_line(ui, tokens, strings::loading());
return response;
}
if data.unbound {
muted_line(ui, tokens, strings::unbound());
return response;
}
summary(ui, tokens, data, &mut response);
if data.events.is_empty() {
muted_line(ui, tokens, strings::empty());
return response;
}
egui::ScrollArea::vertical().id_salt("notescraft-activity-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
for event in &data.events {
row(ui, tokens, event, &mut response);
}
});
response
}
/// The row above the feed: the unread count and the button that marks it read.
fn summary(ui: &mut Ui, tokens: &Tokens, data: &ActivityData, response: &mut ActivityResponse) {
let colors = &tokens.theme.colors;
ui.horizontal(|ui| {
if data.unread > 0 {
ui.label(
RichText::new(strings::unread().replace("{n}", &data.unread.to_string()))
.size(SMALL)
.family(crate::fonts::semibold())
.color(color(colors.accent)),
);
}
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if data.unread > 0 {
let height = crate::style::compact::target(ui.ctx(), ROW_HEIGHT);
let button = ui.add(egui::Button::new(RichText::new(strings::mark_read()).size(SMALL)).min_size(egui::vec2(0.0, height)));
if button.clicked() {
response.mark_read = Some(data.events.iter().filter(|event| !event.read).map(|event| event.id).collect());
}
}
});
});
}
/// One event: the actor, the time, the action and the note it is about.
fn row(ui: &mut Ui, tokens: &Tokens, event: &ActivityEvent, response: &mut ActivityResponse) {
let colors = &tokens.theme.colors;
egui::Frame::new().fill(color(colors.surface_2)).corner_radius(RADIUS).inner_margin(egui::Margin::symmetric(CARD_MARGIN, 10)).show(ui, |ui| {
ui.set_min_width(ui.available_width());
row_header(ui, tokens, event);
ui.add(egui::Label::new(RichText::new(kind_action(&event.kind)).size(BODY).color(color(colors.text))).wrap());
if let Some(path) = &event.note {
let label = note_label(path, event.line);
let height = crate::style::compact::target(ui.ctx(), ROW_HEIGHT);
let button = ui.add(egui::Button::new(RichText::new(label).size(SMALL).color(color(colors.link))).frame(false).min_size(egui::vec2(0.0, height)));
if button.clicked() {
response.open = Some(ActivityTarget { path: path.clone(), line: event.line });
}
}
});
}
/// The actor, the time and the dot of an unread event.
fn row_header(ui: &mut Ui, tokens: &Tokens, event: &ActivityEvent) {
let colors = &tokens.theme.colors;
ui.horizontal(|ui| {
ui.label(RichText::new(actor_name(event)).size(SMALL).family(crate::fonts::semibold()).color(color(colors.text)));
ui.label(RichText::new(relative_time(event.ts_ms, now_ms())).size(SMALL).color(color(colors.text_muted)));
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if !event.read {
let (rect, _) = ui.allocate_exact_size(egui::vec2(DOT, DOT), Sense::hover());
ui.painter().circle_filled(rect.center(), DOT / 2.0, color(colors.accent));
}
});
});
}
/// One muted line of text.
fn muted_line(ui: &mut Ui, tokens: &Tokens, text: &str) {
ui.label(RichText::new(text).size(SMALL).color(color(tokens.theme.colors.text_muted)));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The answer of `activity.list` with two events.
fn answer() -> Value {
json!({
"events": [
{ "id": 1, "ts_ms": 10, "kind": "mention", "actor": "u-ana", "actor_name": "Ana",
"note": "a.md", "thread_id": "c-1", "line": 3, "read": false },
{ "id": 2, "ts_ms": 20, "kind": "comment", "actor": "u-bob", "actor_name": "Bob",
"note": "b.md", "read": true }
],
"head": 2,
"more": false,
"unread": 1
})
}
#[test]
fn the_feed_comes_newest_first() {
let feed = parse_feed(&answer());
assert_eq!(feed.events.iter().map(|event| event.id).collect::<Vec<_>>(), [2, 1]);
assert_eq!(feed.unread, 1);
assert_eq!(feed.events[1].line, Some(3));
assert_eq!(feed.events[1].thread_id.as_deref(), Some("c-1"));
assert!(!feed.events[1].read);
assert!(feed.events[0].read);
}
#[test]
fn an_answer_without_events_is_empty() {
assert_eq!(parse_feed(&json!({})).events, Vec::new());
assert_eq!(parse_feed(&json!({ "events": "no" })).events, Vec::new());
assert_eq!(parse_feed(&json!({ "events": [1] })).events, Vec::new(), "an event without an id is skipped");
}
#[test]
fn every_kind_has_its_own_copy() {
assert_eq!(kind_action("mention"), "mentioned you");
assert_eq!(kind_action("comment"), "commented on");
assert_eq!(kind_action("suggestion"), "proposed a change to");
assert_eq!(kind_action("note_changed"), "changed");
assert_eq!(kind_action("invite_accepted"), "accepted your invite");
assert_eq!(kind_action("member_joined"), "joined the vault");
assert_eq!(kind_action("role_changed"), "changed a role");
assert_eq!(kind_action("thread_resolved"), "resolved a thread");
assert_eq!(kind_action("whatever"), "was active");
}
#[test]
fn an_actor_without_a_name_shows_its_id() {
let event = ActivityEvent { actor: "u-ana".to_owned(), ..ActivityEvent::default() };
assert_eq!(actor_name(&event), "u-ana");
let named = ActivityEvent { actor: "u-ana".to_owned(), actor_name: "Ana".to_owned(), ..ActivityEvent::default() };
assert_eq!(actor_name(&named), "Ana");
}
#[test]
fn a_note_shows_its_line() {
assert_eq!(note_label("notes/a.md", None), "notes/a.md");
assert_eq!(note_label("notes/a.md", Some(12)), "notes/a.md:12");
let long = "a".repeat(100);
assert_eq!(note_label(&long, Some(1)), format!("{}…:1", "a".repeat(80)));
}
#[test]
fn times_are_relative_and_never_negative() {
assert_eq!(relative_time(0, 0), "just now");
assert_eq!(relative_time(0, 30_000), "just now");
assert_eq!(relative_time(0, 5 * 60_000), "5m ago");
assert_eq!(relative_time(0, 3 * 3_600_000), "3h ago");
assert_eq!(relative_time(0, 2 * 86_400_000), "2d ago");
assert_eq!(relative_time(10_000, 0), "just now", "a clock that went back does not go negative");
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Panels of the right card that only list data: backlinks, the outline and the comments.
pub mod activity_panel;
pub mod backlinks_panel;
pub mod comment_panel;
pub mod outline;
+62 -8
View File
@@ -20,10 +20,20 @@ const SOURCE_CACHE_CAP: usize = 512;
const COLLAPSE_SIZE: f32 = 28.0;
const ICON: f32 = 16.0;
const TAB_PAD: f32 = 10.0;
/// The least padding either side of a tab icon when the tabs get tight, in points.
const TAB_PAD_MIN: f32 = 4.0;
const TAB_GAP: f32 = 8.0;
const TAB_SPACING: f32 = 4.0;
const TAB_HEIGHT: f32 = 36.0;
const TAB_RADIUS: u8 = 12;
/// Height of the unread badge on the Activity tab, in points.
const BADGE_HEIGHT: f32 = 16.0;
/// Width of the unread badge on the Activity tab, in points.
const BADGE_WIDTH: f32 = 18.0;
/// Gap between the icon of the Activity tab and its badge, in points.
const BADGE_GAP: f32 = 4.0;
/// The largest count the badge shows before it shortens.
const BADGE_MAX: u64 = 99;
/// The prepared data of the right card for the active note.
#[derive(Debug, Default)]
@@ -38,6 +48,8 @@ pub struct RightData {
pub lint: crate::lint_panel::LintData,
/// The comment threads of the active note, filled by the shell.
pub comments: crate::comment_panel::CommentData,
/// The feed of the vault, filled by the shell.
pub activity: crate::activity_panel::ActivityData,
}
/// Reads note bodies through the session for the backlinks scan, caching per scan.
@@ -96,6 +108,7 @@ pub fn data_for(session: &Session, active_path: Option<&str>, docs: &BTreeMap<St
related: crate::related_panel::RelatedData::default(),
lint: crate::lint_panel::LintData::default(),
comments: crate::comment_panel::CommentData::default(),
activity: crate::activity_panel::ActivityData::default(),
}
}
@@ -115,6 +128,8 @@ pub enum RightTab {
Outline,
/// The comment threads of the active note.
Comments,
/// The feed of the vault: what happened while the member was away.
Activity,
/// Conversation with the agent configured in Settings.
Assistant,
}
@@ -133,6 +148,8 @@ pub struct RightInputs<'a> {
pub lint: &'a crate::lint_panel::LintData,
/// The comment threads of the active note.
pub comments: &'a crate::comment_panel::CommentData,
/// The feed of the vault.
pub activity: &'a crate::activity_panel::ActivityData,
}
/// What the user did in the right card.
@@ -160,6 +177,8 @@ pub struct RightResponse {
pub lint: crate::lint_panel::LintResponse,
/// What was pressed in the Comments tab.
pub comments: crate::comment_panel::CommentResponse,
/// What was pressed in the Activity tab.
pub activity: crate::activity_panel::ActivityResponse,
}
/// Draws the tab strip and the tab's content, reporting the user's actions.
@@ -178,7 +197,7 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu
if collapse {
ui.ctx().request_repaint();
}
tab_strip(ui, tokens, tab);
tab_strip(ui, tokens, tab, inputs.activity.unread);
let mut response = RightResponse { collapse, ..RightResponse::default() };
ui.add_space(4.0);
match tab {
@@ -204,33 +223,41 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu
}
}
RightTab::Comments => response.comments = crate::comment_panel::show(ui, tokens, inputs.comments),
RightTab::Activity => response.activity = crate::activity_panel::show(ui, tokens, inputs.activity),
RightTab::Assistant => {}
}
response
}
/// The tab pills: the active tab shows icon and label, the others only the icon.
fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) {
/// The Activity tab carries the unread count of the feed.
fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, unread: u64) {
let c = &tokens.theme.colors;
let font = egui::FontId::new(14.0, crate::fonts::semibold());
let mut x = ui.cursor().left();
let top = ui.cursor().top();
// The active label gives way before the tabs reach the collapse button.
let icons_only = 6.0 * (TAB_PAD * 2.0 + ICON) + 5.0 * TAB_SPACING;
let label_room = (ui.available_width() - COLLAPSE_SIZE - crate::theme::PANEL_GAP - icons_only - TAB_GAP).max(0.0);
// The tabs never reach the collapse button: the pills shrink first, then the active label gives way.
let unread_extra = if unread > 0 { BADGE_WIDTH + BADGE_GAP } else { 0.0 };
let room = ui.available_width() - COLLAPSE_SIZE - crate::theme::PANEL_GAP - unread_extra;
let pad = (((room - 6.0 * TAB_SPACING) / 7.0 - ICON) / 2.0).clamp(TAB_PAD_MIN, TAB_PAD);
let icons_only = 7.0 * (pad * 2.0 + ICON) + 6.0 * TAB_SPACING;
let label_room = (room - icons_only - TAB_GAP).max(0.0);
for (candidate, icon, label, hotkey) in [
(RightTab::Backlinks, "link", strings::backlinks(), "ui.show_backlinks"),
(RightTab::Related, "sparkles", crate::text::knowledge_related::related(), "ui.show_related"),
(RightTab::Lint, "list-checks", crate::text::lint_panel::lint(), "ui.show_lint"),
(RightTab::Outline, "list", strings::outline(), "ui.show_outline"),
(RightTab::Comments, "message-square", crate::text::review::comment_panel::title(), "ui.show_comments"),
(RightTab::Activity, "bell", crate::text::review::activity_panel::title(), "ui.show_activity"),
(RightTab::Assistant, "bot", strings::assistant(), "ui.show_assistant"),
] {
let active = *tab == candidate;
let tint = if active { color(c.text) } else { color(c.text_muted) };
let text = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), tint);
let label_width = text.size().x.min(label_room);
let width = TAB_PAD * 2.0 + ICON + if active { TAB_GAP + label_width } else { 0.0 };
let unread_badge = (candidate == RightTab::Activity).then_some(unread).filter(|count| *count > 0);
let badge_width = if unread_badge.is_some() { BADGE_WIDTH + BADGE_GAP } else { 0.0 };
let width = pad * 2.0 + ICON + badge_width + if active { TAB_GAP + label_width } else { 0.0 };
let rect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(width, TAB_HEIGHT));
let hit = crate::hotkeys::on_hover_hotkey(ui.interact(rect, egui::Id::new(("notescraft-right-tab", label)), egui::Sense::click()), label, hotkey);
let radius = egui::CornerRadius::same(TAB_RADIUS);
@@ -245,10 +272,21 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) {
let glow = crate::motion::hover(ui.ctx(), ("right-tab-hover", label), &hit);
ui.painter().rect_filled(rect, radius, color(c.selection).gamma_multiply(glow));
}
let icon_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + TAB_PAD, rect.center().y - ICON / 2.0), egui::Vec2::splat(ICON));
let icon_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + pad, rect.center().y - ICON / 2.0), egui::Vec2::splat(ICON));
crate::icons::paint(ui, icon, icon_rect, tint);
let after_badge = match unread_badge {
Some(count) => {
let badge_rect = egui::Rect::from_min_size(
egui::pos2(icon_rect.right() + BADGE_GAP, rect.center().y - BADGE_HEIGHT / 2.0),
egui::vec2(BADGE_WIDTH, BADGE_HEIGHT),
);
draw_unread_badge(ui, tokens, count, badge_rect);
badge_rect.right()
}
None => icon_rect.right(),
};
if active {
let clip = egui::Rect::from_min_max(egui::pos2(icon_rect.right() + TAB_GAP, rect.top()), rect.right_bottom());
let clip = egui::Rect::from_min_max(egui::pos2(after_badge + TAB_GAP, rect.top()), rect.right_bottom());
ui.painter().with_clip_rect(clip).galley(egui::pos2(clip.left(), rect.center().y - text.size().y / 2.0), text, tint);
}
if hit.clicked() {
@@ -259,6 +297,22 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) {
ui.allocate_space(egui::vec2(ui.available_width(), TAB_HEIGHT));
}
/// The unread count on the Activity tab: a pill with the number of events.
fn draw_unread_badge(ui: &mut Ui, tokens: &Tokens, count: u64, rect: egui::Rect) {
let c = &tokens.theme.colors;
let text = badge_label(count);
let mut layout = egui::Layout::top_down(egui::Align::Center);
layout.main_align = egui::Align::Center;
let mut badge = ui.new_child(egui::UiBuilder::new().id_salt("notescraft-activity-badge").max_rect(rect).layout(layout));
badge.painter().rect_filled(rect, egui::CornerRadius::same(6), color(c.accent));
badge.label(egui::RichText::new(text).size(10.0).family(crate::fonts::semibold()).color(color(c.on_accent)));
}
/// The number of the badge; past a hundred it shortens.
fn badge_label(count: u64) -> String {
if count > BADGE_MAX { format!("{BADGE_MAX}+") } else { count.to_string() }
}
#[cfg(test)]
mod partial_tests {
use super::*;
+7 -1
View File
@@ -5,6 +5,9 @@
//! indentation per level, a rotating chevron per folder, hover and selected states; collapse
//! state is a `BTreeSet<String>` of folder paths owned by the caller (see [`collapse_all`]
//! for the "folders start collapsed" default).
//!
//! The sidecar that holds the comments of a note ([`is_comment_sidecar`]) belongs to its
//! note, not to the user: it is never a row of the tree.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -25,6 +28,7 @@ pub use crumbs::crumbs;
pub use filter::{cached_filtered, filtered};
pub use hscroll::{MAX_CONTENT_WIDTH, content_width, row_width, wheel_to_horizontal};
pub use name_tip::{DELAY_SECS as TIP_DELAY_SECS, name_tip};
pub use notescraft_index::is_comment_sidecar;
pub use rows::{FlatRow, OVERSCAN_ROWS, cached_rows, first_visible_row, flatten, row_top, row_window};
/// Row height, in points.
@@ -677,7 +681,9 @@ fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTre
);
// The trash button sits at the right edge of the row and appears while the row is hovered.
// It takes the note count's place: the two would overlap.
let show_trash = response.hovered() && writable;
// `contains_pointer`, not `hovered`: pressing the trash makes it the pressed widget and the row stops being hovered,
// and a trash that vanished on press could never be released on.
let show_trash = response.contains_pointer() && writable;
if node.folder && node.notes > 0 && !show_trash {
let badge = egui::FontId::proportional(crate::style::TEXT_TINY);
ui.painter().text(Pos2::new(slot_rect.right() - ROW_PADDING, rect.center().y), Align2::RIGHT_CENTER, node.notes.to_string(), badge, muted);
+1 -1
View File
@@ -105,7 +105,7 @@ mod tests {
let flat = row_width(0, 100.0, 0.0);
assert!(row_width(2, 100.0, 0.0) > flat + 2.0 * INDENT - 0.01, "each level adds an indent");
assert!(row_width(0, 100.0, 40.0) > flat, "a wide count adds room");
assert!(row_width(0, 100.0, 5.0) - flat < f32::EPSILON, "a narrow count still keeps the trash room");
assert!((row_width(0, 100.0, 5.0) - flat).abs() < f32::EPSILON, "a narrow count still keeps the trash room");
assert!(flat > 100.0 + 2.0 * (ICON_SIZE + ICON_GAP), "the label is never overlapped by the icons");
}
@@ -8,7 +8,7 @@ use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, Vec2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use super::{TIP_DELAY_SECS, TreeModel, show};
use super::{TreeModel, show};
use crate::testsupport;
use crate::theme::Tokens;
@@ -69,7 +69,8 @@ fn hovering_a_row_shows_its_full_name_and_path_after_the_delay() {
harness.hover_at(pos2(60.0, 24.0));
harness.step();
assert!(harness.query_by_label(LONG_NAME).is_none(), "not before the delay");
let steps = (TIP_DELAY_SECS * 4.0).ceil() as usize + 3;
// Frames of 0.25 s: enough to pass the tooltip delay with room to spare.
let steps: usize = 8;
harness.run_steps(steps);
assert!(harness.query_by_label(LONG_NAME).is_some(), "the full name shows");
assert!(harness.query_by_label(&format!("{LONG_NAME}.md")).is_some(), "the path shows below it");
+2 -2
View File
@@ -189,8 +189,8 @@ fn the_hover_trash_button_asks_for_the_row_it_sits_on() {
frame(Vec::new());
// The trash sits at the right edge of the hovered row: sweep the edge until it is hit.
let y = ROW_HEIGHT / 2.0;
for step in 0..48 {
let pos = egui::pos2(width - 2.0 - step as f32, y);
for step in 0..48_u16 {
let pos = egui::pos2(width - 2.0 - f32::from(step), y);
frame(vec![egui::Event::PointerMoved(pos)]);
frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]);
let response = frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }]);
+3
View File
@@ -157,6 +157,8 @@ pub struct NotesApp {
pub(crate) dev_hook: Option<dev_hook::DevHook>,
/// The video embed that plays.
pub(crate) video: video::VideoPlayer,
/// The audio file that plays (tab or embed).
pub(crate) audio: crate::media_view::audio::player::AudioPlayer,
pub(crate) switch_jump: Option<(String, String, String)>,
/// The note the next quick-switcher pick is merged into (set by "Merge into…").
pub(crate) merge_from: Option<String>,
@@ -296,6 +298,7 @@ impl NotesApp {
player: player::Player::default(),
dev_hook: dev_hook::DevHook::from_env(),
video: video::VideoPlayer::default(),
audio: crate::media_view::audio::player::AudioPlayer::default(),
link_hover: None,
batch_review: None,
grammar_dialog: None,
@@ -158,6 +158,9 @@ fn typed_actions_dispatch_every_type_and_rename_remove() {
app.handle_properties("note.md", vec![PropertiesAction::Rename { from: "text".into(), to: "title".into() }], 1.1, &ctx);
assert!(app.active_text().is_some_and(|text| text.contains("title: value")));
app.handle_properties("note.md", vec![PropertiesAction::Remove { key: "title".into() }], 1.2, &ctx);
assert!(app.active_text().is_some_and(|text| text.contains("title:")), "removing asks first");
assert!(app.menus.confirm.is_some());
app.remove_property("note.md", "title", 1.2, &ctx);
assert!(!app.active_text().is_some_and(|text| text.contains("title:")));
assert!(app.queued_cues().contains(&crate::sounds::Cue::Close));
}
@@ -170,6 +170,9 @@ impl NotesApp {
self.split_tick();
self.player_tick(ctx);
self.video_tick(ctx);
if let Some(notice) = self.audio.tick(ctx, self.session.vault().root()) {
self.toast = Some(notice.to_owned());
}
if self.tree.sort != self.state.explorer_sort {
self.tree.resort(self.state.explorer_sort);
}
+25 -2
View File
@@ -17,8 +17,9 @@ const STAMP_BUCKETS: usize = 20;
/// The explorer tree for `session` from its file listing, which exists before any note is parsed.
/// Notes, canvases, whiteboards and diagrams always show; every other file shows while
/// `files.show_all_types` is on, minus the extensions in `files.hidden_extensions` and the
/// annotation sidecars of images that exist (unless `files.show_annotation_files`). Empty folders are kept.
/// `files.show_all_types` is on, minus the extensions in `files.hidden_extensions`, the
/// annotation sidecars of images that exist (unless `files.show_annotation_files`) and the
/// sidecar that holds the comments of a note. Empty folders are kept.
///
/// # Errors
///
@@ -36,6 +37,10 @@ pub(crate) fn tree_from_listing(session: &notescraft_engine::Session, sort: crat
/// Whether the Explorer lists the file `path` under the given settings; `present` holds every listed path.
fn shows_in_explorer(path: &str, files: &notescraft_engine::FilesConfig, present: &std::collections::HashSet<&str>) -> bool {
use crate::tabs::TabKind;
if crate::tree::is_comment_sidecar(path) {
// The threads of a note belong to the note: the file that holds them never shows.
return false;
}
if matches!(TabKind::of(path), TabKind::Note | TabKind::Canvas | TabKind::Whiteboard | TabKind::Diagram) {
return true;
}
@@ -137,6 +142,14 @@ mod tests {
assert_eq!(names, ["sub", "a", "board.canvas", "pic.png"], "every file type shows by default");
}
#[test]
fn the_tree_holds_no_comment_sidecar() {
let Some((app, _root)) = app_over("lazy-sidecar", &[("a.md", "x"), ("a.md.comments.json", "{}")]) else { return };
let tree = tree_from_listing(&app.session, crate::tree::Sort::default()).expect("the tree of the vault");
let names: Vec<&str> = tree.roots.iter().map(|node| node.name.as_str()).collect();
assert_eq!(names, ["a"], "the sidecar of a note is not a row of the tree");
}
#[test]
fn each_frame_steps_and_repaints_only_while_work_remains() {
let Some((mut app, _root)) = app_over_many("lazy-frames", 40) else {
@@ -219,4 +232,14 @@ mod explorer_filter_tests {
assert!(!shows_in_explorer("p.png", &config, &present));
assert!(shows_in_explorer("note.md", &config, &present));
}
#[test]
fn the_sidecar_of_a_note_never_shows() {
let present: HashSet<&str> = ["note.md", "note.md.comments.json"].into_iter().collect();
let mut config = files();
assert!(shows_in_explorer("note.md", &config, &present));
assert!(!shows_in_explorer("note.md.comments.json", &config, &present), "the threads of a note belong to the note");
config.show_all_types = true;
assert!(!shows_in_explorer("note.md.comments.json", &config, &present), "no setting reveals it");
}
}
@@ -110,7 +110,8 @@ impl NotesApp {
};
let Some(text) = loaded else { return };
crate::fonts::note_text(&text);
let doc = notescraft_markdown::parse(&text);
// Source files are not Markdown: parsing a multi-megabyte script as a note would only freeze the open.
let doc = notescraft_markdown::parse(if matches!(TabKind::of(&path), TabKind::Code | TabKind::Text) { "" } else { &text });
self.texts.clear();
self.docs.clear();
let _ = self.texts.insert(path.clone(), text.clone());
@@ -168,6 +168,7 @@ impl NotesApp {
if dismissed != self.state.dismissed_json {
self.state.dismissed_json = dismissed;
}
self.tick_languages(ctx);
let current = self.state.to_json();
if current != self.preferences.observed {
self.preferences.observed = current;
@@ -181,6 +182,18 @@ impl NotesApp {
}
}
}
/// Hands the source-file tabs the saved language choices and saves what the user chose or ignored there.
fn tick_languages(&mut self, ctx: &egui::Context) {
use crate::code_view::language::{self, Prefs};
let mut prefs = Prefs { chosen: self.state.file_languages.clone(), ignored: self.state.ignored_syntax.clone() };
if let Some(action) = language::take_action(ctx) {
language::apply(&mut prefs, action);
self.state.file_languages.clone_from(&prefs.chosen);
self.state.ignored_syntax.clone_from(&prefs.ignored);
}
language::set_prefs(ctx, prefs);
}
}
#[cfg(test)]
@@ -0,0 +1,119 @@
//! The Activity tab: the feed of the vault, read with `activity.list` and
//! marked read with `activity.read`.
//!
//! The panel itself is a pure widget: the shell reads the feed here and hands
//! it over, and every command runs after the frame, never while the panel
//! paints.
use serde_json::json;
use crate::activity_panel::{ActivityData, ActivityResponse, parse_feed};
use crate::right_view::RightTab;
use crate::shell::app::NotesApp;
/// The command that lists the feed.
const LIST: &str = "activity.list";
/// The command that marks the events read.
const READ: &str = "activity.read";
impl NotesApp {
/// Reads the feed while the Activity tab is on screen.
///
/// The feed is read once per visit: the server answers a poll, and the
/// panel asks for a new one when the user marks the events read.
pub(super) fn tick_activity(&mut self, _ctx: &egui::Context) {
let phone = self.state.compact_page == crate::shell::compact::CompactPage::Activity;
let tab = self.state.right_visible && self.right_tab == RightTab::Activity;
if !(phone || tab) {
return;
}
if self.right.activity.loaded {
return;
}
self.read_activity();
}
/// Reads the feed with `activity.list`.
///
/// A vault with no server has no feed: the panel says so instead of
/// asking a server that is not there.
fn read_activity(&mut self) {
if !self.bound_to_a_server() {
self.right.activity = ActivityData { loaded: true, unbound: true, ..ActivityData::default() };
return;
}
let feed = self.engine_command(LIST, &json!({})).as_ref().map(parse_feed).unwrap_or_default();
self.right.activity = ActivityData { events: feed.events, unread: feed.unread, loaded: true, unbound: false };
}
/// Whether the open vault is bound to a server.
fn bound_to_a_server(&self) -> bool {
notescraft_engine::load_binding(self.session.vault()).ok().flatten().is_some()
}
/// Carries out what the user pressed in the Activity tab: open the note of
/// an event and mark the events read.
pub(super) fn handle_activity(&mut self, response: &ActivityResponse) {
if let Some(target) = &response.open {
self.open_note(&target.path);
self.jump_to_line(target.line);
}
if let Some(ids) = &response.mark_read {
let params = if ids.is_empty() { json!({}) } else { json!({ "ids": ids }) };
if self.engine_command(READ, &params).is_some() {
self.read_activity();
}
}
}
/// Scrolls the open note to a line: the editor jumps to its first byte.
fn jump_to_line(&mut self, line: Option<u64>) {
let Some(line) = line else { return };
let Some(path) = self.tabs.active_path().map(str::to_owned) else { return };
if self.editing.view_of(Some(&path)) != crate::center::ViewMode::Markdown {
return;
}
let Some(tab) = self.editing.editor_of(&path) else { return };
let Some(byte) = line_start(&tab.editor.text, line) else { return };
if let Some(tab) = self.editing.editor_of(&path) {
tab.editor.jump = Some(byte);
}
}
}
/// The byte offset of the start of a one-based line.
///
/// `None` when the note has no such line.
#[must_use]
pub fn line_start(text: &str, line: u64) -> Option<usize> {
if line == 0 {
return None;
}
let mut rest = text;
for _ in 1..line {
let at = rest.find('\n')?;
rest = rest.get(at + 1..)?;
}
Some(text.len().saturating_sub(rest.len()))
}
#[cfg(test)]
mod tests {
use super::line_start;
#[test]
fn a_line_starts_where_it_begins() {
let text = "First line.\nSecond line.\nThird line.";
assert_eq!(line_start(text, 1), Some(0));
assert_eq!(line_start(text, 2), Some(12));
assert_eq!(line_start(text, 3), Some(25));
assert_eq!(line_start(text, 4), None, "the note has no fourth line");
assert_eq!(line_start(text, 0), None, "the lines are one-based");
}
#[test]
fn a_line_of_an_empty_note_is_nothing() {
assert_eq!(line_start("", 1), Some(0));
assert_eq!(line_start("", 2), None);
}
}
@@ -43,6 +43,8 @@ impl NotesApp {
let compact_title =
compact.then(|| self.tabs.active_path()).flatten().map_or_else(String::new, |path| crate::tabs::TabKind::of(path).title(path).to_owned());
let compact_back = std::cell::Cell::new(false);
let compact_review_back = std::cell::Cell::new(false);
let compact_review_page: std::cell::Cell<Option<crate::shell::compact::CompactPage>> = std::cell::Cell::new(None);
let compact_tap = std::cell::Cell::new(None::<crate::shell::compact::CompactPage>);
let vim_enabled = self.vim_enabled();
let vim_nav = &self.vim_nav;
@@ -214,7 +216,31 @@ impl NotesApp {
});
panels.right = Box::new(|ui| {
crate::hotkeys::region(ui, crate::vim_nav::NavFocus::Right, crate::text::hotkeys::region_right());
if compact && compact_page.is_review() {
// On a phone the feed and the comments are pages of their own.
let mut response = RightResponse::default();
if compact_page == crate::shell::compact::CompactPage::Activity {
let (activity, action) = crate::shell::compact::review::activity(ui, tokens, &right.activity);
response.activity = activity;
compact_review_back.set(action.back);
} else {
let (comments, action) = crate::shell::compact::review::comments(ui, tokens, &right.comments);
response.comments = comments;
compact_review_back.set(action.back);
}
*right_response.borrow_mut() = response;
return;
}
let before = *right_tab;
let mut response = draw_right(ui, tokens, right_tab, right, &mut right_expanded, &frame, caret);
if compact && *right_tab != before {
// A tab the user picked on a phone opens the page of that panel.
match *right_tab {
crate::right_view::RightTab::Activity => compact_review_page.set(Some(crate::shell::compact::CompactPage::Activity)),
crate::right_view::RightTab::Comments => compact_review_page.set(Some(crate::shell::compact::CompactPage::Comments)),
_ => {}
}
}
if frame.right_content && *right_tab == crate::right_view::RightTab::Assistant {
let active_note = active_path.as_deref().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note);
response.assistant = assistant.show(ui, tokens, session_ref.assistant_settings(), active_note).actions;
@@ -233,6 +259,8 @@ impl NotesApp {
if compact {
if compact_back.get() {
self.state.compact_page = crate::shell::compact::CompactPage::Notes;
} else if let Some(page) = compact_review_page.get() {
self.state.compact_page = page;
} else if let Some(tapped) = compact_tap.get() {
match tapped {
crate::shell::compact::CompactPage::Graph => self.run_command("ui.open_graph", &serde_json::Value::Null),
@@ -243,6 +271,9 @@ impl NotesApp {
self.search.focus = true;
}
}
if compact_review_back.get() {
self.state.compact_page = crate::shell::compact::CompactPage::More;
}
}
if let Some(pane) = split_response.into_inner() {
self.handle_split(&pane);
@@ -288,6 +319,7 @@ fn draw_right(
related: &data.related,
lint: &data.lint,
comments: &data.comments,
activity: &data.activity,
};
let response = crate::right_view::draw(ui, tokens, tab, &inputs, *expanded);
if response.collapse {
@@ -18,7 +18,9 @@ impl NotesApp {
/// Reads the threads of the active note while the Comments tab is on screen
/// and keeps the selection of the editor up to date.
pub(super) fn tick_comments(&mut self, ctx: &egui::Context) {
if !(self.state.right_visible && self.right_tab == RightTab::Comments) {
let phone = self.state.compact_page == crate::shell::compact::CompactPage::Comments;
let tab = self.state.right_visible && self.right_tab == RightTab::Comments;
if !(phone || tab) {
return;
}
let path = self.tabs.active_path().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note).map(str::to_owned);
+9 -2
View File
@@ -213,18 +213,24 @@ impl NotesApp {
self.sidebar_stamp = stamp;
}
if !self.right_valid {
let (related, lint, comments) =
(std::mem::take(&mut self.right.related), std::mem::take(&mut self.right.lint), std::mem::take(&mut self.right.comments));
let (related, lint, comments, activity) = (
std::mem::take(&mut self.right.related),
std::mem::take(&mut self.right.lint),
std::mem::take(&mut self.right.comments),
std::mem::take(&mut self.right.activity),
);
self.right = crate::right_view::data_for(&self.session, self.tabs.active_path(), &self.docs, self.state.right_visible);
self.right.related = related;
self.right.lint = lint;
self.right.comments = comments;
self.right.activity = activity;
self.mark_related_stale(ctx);
self.right_valid = true;
}
self.tick_related(ctx);
self.tick_lint(ctx);
self.tick_comments(ctx);
self.tick_activity(ctx);
let rows = self.palette_rows();
self.preferences.settings.ui = serde_json::to_value(&self.state).unwrap_or_default();
let inset = self.draw_titlebar(ctx);
@@ -740,6 +746,7 @@ impl NotesApp {
fn handle_right(&mut self, response: RightResponse, now: f64, ctx: &egui::Context) {
self.handle_assistant(response.assistant);
self.handle_comments(&response.comments);
self.handle_activity(&response.activity);
if let Some(path) = response.open_note {
self.open_note(&path);
}
@@ -1,5 +1,6 @@
//! App window content: cards, chrome, the frame loop, menus, views and commands.
pub(super) mod activity;
pub(super) mod assistant;
pub(super) mod cards;
pub(super) mod chrome;
@@ -9,10 +10,12 @@ pub(super) mod confirm;
pub(super) mod frame;
pub(super) mod lint;
#[cfg(test)]
#[path = "tests/menu_tests.rs"]
pub(super) mod menu_tests;
pub(super) mod menubar;
pub(super) mod menus;
#[cfg(test)]
#[path = "tests/origin_tests.rs"]
pub(super) mod origin_tests;
pub(super) mod related;
pub(super) mod views;
+33 -1
View File
@@ -224,8 +224,12 @@ impl Views {
TabKind::Trash => self.trash(ui, inputs, response),
TabKind::Versions => self.versions(ui, inputs, response),
kind if kind.is_binary() => match kind {
TabKind::Image if crate::file_card::is_undecodable_picture(path) => {
response.open_file = crate::file_card::show_info(ui, inputs.tokens, inputs.session, path);
}
TabKind::Image => crate::image_view::show(ui, inputs.tokens, inputs.session, path, &mut self.image),
TabKind::Video => response.open_file = crate::media_view::video::show(ui, inputs.tokens, inputs.session, path),
TabKind::Audio => response.open_file = crate::media_view::audio::show(ui, inputs.tokens, inputs.session, path),
TabKind::Pdf => response.open_file = crate::media_view::pdf::show(ui, inputs.tokens, inputs.session, path),
_ => response.open_file = crate::file_card::show_info(ui, inputs.tokens, inputs.session, path),
},
@@ -303,11 +307,15 @@ impl Views {
let Some(path) = inputs.path else { return };
let Some(edit) = inputs.editor.as_mut() else { return };
let kind = TabKind::of(path);
if matches!(kind, TabKind::Code | TabKind::Text) {
response.edited = source_file(ui, inputs.tokens, path, &mut edit.editor.text, inputs.session.is_read_only());
return;
}
let entry = self.files.entry(path.to_owned()).or_insert_with(|| FileView::new(kind, &edit.editor.text));
if entry.source != edit.editor.text {
*entry = FileView::new(kind, &edit.editor.text);
}
if edit.view == ViewMode::Markdown && kind != TabKind::Diagram || kind == TabKind::Text {
if edit.view == ViewMode::Markdown && kind != TabKind::Diagram {
response.edited = source_editor(ui, path, &mut edit.editor.text);
return;
}
@@ -393,6 +401,30 @@ impl FileView {
}
}
/// The tab of a code or text file: a highlighted editor, or the read-only viewer under a notice when the
/// file is too big to edit (or the vault is read-only). `true` when the text changed.
fn source_file(ui: &mut egui::Ui, tokens: &crate::theme::Tokens, path: &str, text: &mut String, read_only: bool) -> bool {
use crate::code_view::language;
let ext = path.rsplit_once('.').map_or_else(String::new, |(_, ext)| ext.to_ascii_lowercase());
let prefs = language::prefs(ui.ctx());
let resolved = language::resolve(path, text.lines().next().unwrap_or_default(), &prefs.chosen);
language::set_current(ui.ctx(), path, resolved.as_deref());
let too_large = text.len() > crate::code_view::MAX_EDIT_BYTES;
if language::needs_hint(path, resolved.as_deref(), &prefs) {
crate::code_view::banner(ui, tokens, path);
}
if !too_large && !read_only {
return crate::code_view::edit(ui, tokens, path, resolved.as_deref(), text);
}
if too_large {
ui.label(
egui::RichText::new(crate::text::code_view::too_large()).size(crate::style::TEXT_TINY).color(crate::theme::color(tokens.theme.colors.text_muted)),
);
}
crate::code_view::show(ui, tokens, text, &ext);
false
}
fn source_editor(ui: &mut egui::Ui, path: &str, source: &mut String) -> bool {
egui::ScrollArea::both()
.id_salt((path, "source"))
@@ -231,7 +231,7 @@ impl Images {
total = total.saturating_add(raster.pixels.len().saturating_mul(std::mem::size_of::<egui::Color32>()));
Source::Raster(Arc::new(Raster { image: raster, texture: egui::mutex::Mutex::default() }))
} else {
Source::Bytes(bytes.into())
Source::Bytes(crate::image_view::picture_bytes(&bytes).into())
};
if total > DEFAULT_MAX_BYTES {
return Err(TOO_LARGE.replace("{{limit}}", &DEFAULT_MAX_BYTES.to_string()));
@@ -25,9 +25,10 @@ const MAX_ENCODE_BYTES: usize = 96 * 1024 * 1024;
/// Longest local clipboard path accepted before parsing or allocating.
const MAX_PATH_BYTES: usize = 16 * 1024;
/// Number of image file extensions at the beginning of [`EXTENSIONS`].
const IMAGE_EXTENSIONS: usize = 7;
const IMAGE_EXTENSIONS: usize = 8;
/// All file types accepted by the engine's attachment commands.
const EXTENSIONS: [&str; 15] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "pdf", "mp3", "wav", "ogg", "m4a", "mp4", "webm", "mov"];
const EXTENSIONS: [&str; 21] =
["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "pdf", "avif", "mp3", "wav", "ogg", "m4a", "flac", "3gp", "mp4", "webm", "mov", "mkv", "ogv"];
/// One thing to do with a pasted or dropped file.
#[derive(Debug, Clone, PartialEq)]
+14 -1
View File
@@ -7,6 +7,7 @@
pub mod banner;
pub mod page;
pub mod review;
pub mod tab_bar;
use serde::{Deserialize, Serialize};
@@ -40,6 +41,10 @@ pub enum CompactPage {
Calendar,
/// The right panel's content: Outline, Backlinks, Related.
More,
/// The activity feed of the vault, on its own page.
Activity,
/// The comment threads of the open note, on their own page.
Comments,
}
/// Which floating card a page renders into.
@@ -59,9 +64,15 @@ impl CompactPage {
match self {
Self::Notes | Self::Search => Card::Sidebar,
Self::Note | Self::Graph | Self::Calendar => Card::Editor,
Self::More => Card::Right,
Self::More | Self::Activity | Self::Comments => Card::Right,
}
}
/// Whether the page is one of the review pages: the feed and the comments.
#[must_use]
pub fn is_review(self) -> bool {
matches!(self, Self::Activity | Self::Comments)
}
}
/// The compact card geometry: only the current page's card, from `[PANEL_GAP]` below the
@@ -135,6 +146,8 @@ mod tests {
(CompactPage::Graph, false, true, false),
(CompactPage::Calendar, false, true, false),
(CompactPage::More, false, false, true),
(CompactPage::Activity, false, false, true),
(CompactPage::Comments, false, false, true),
] {
let rects = layout_of(screen, page, 0.0);
assert_eq!(rects.sidebar.is_some(), sidebar);
@@ -0,0 +1,66 @@
//! The phone pages of the review panels: the activity feed and the comments.
//!
//! On a phone the right card shows one page at a time. The feed and the
//! comments get a page of their own, with a header that carries the title and
//! a back chevron; the rows keep the 44 pt touch target of the compact layout.
use egui::{Rect, Sense, Ui, vec2};
use crate::theme::{Tokens, color};
/// Height of the header of a page, in points.
pub const HEADER: f32 = 48.0;
/// Side of the back chevron, in points.
const ICON: f32 = 24.0;
/// Size of the page title, in points.
const TITLE: f32 = 17.0;
/// Side of the chevron's hit area, in points (the compact touch target).
const TARGET: f32 = crate::design::style::compact::MIN_TARGET_COMPACT;
/// What the page asks the shell to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PageAction {
/// The back chevron was tapped: the shell returns to the More page.
pub back: bool,
}
/// The header of a review page: a back chevron and the title of the page.
pub fn header(ui: &mut Ui, tokens: &Tokens, title: &str) -> PageAction {
let colors = &tokens.theme.colors;
let mut back = false;
ui.horizontal(|ui| {
let (rect, response) = ui.allocate_exact_size(vec2(TARGET, TARGET), Sense::click());
back = response.on_hover_text(crate::text::compact::back()).clicked();
crate::icons::paint(ui, "chevron-left", Rect::from_center_size(rect.center(), vec2(ICON, ICON)), color(colors.text));
ui.add(egui::Label::new(egui::RichText::new(title).size(TITLE).family(crate::fonts::semibold()).color(color(colors.text))));
});
ui.add_space(4.0);
PageAction { back }
}
/// The phone page of the activity feed.
#[must_use]
pub fn activity(ui: &mut Ui, tokens: &Tokens, data: &crate::activity_panel::ActivityData) -> (crate::activity_panel::ActivityResponse, PageAction) {
let action = header(ui, tokens, crate::text::review::activity_panel::title());
let response = crate::activity_panel::show(ui, tokens, data);
(response, action)
}
/// The phone page of the comments of the open note.
#[must_use]
pub fn comments(ui: &mut Ui, tokens: &Tokens, data: &crate::comment_panel::CommentData) -> (crate::comment_panel::CommentResponse, PageAction) {
let action = header(ui, tokens, crate::text::review::comment_panel::title());
let response = crate::comment_panel::show(ui, tokens, data);
(response, action)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_header_keeps_the_touch_target_of_the_phone() {
assert_eq!(TARGET, 44.0, "the chevron is a 44 pt target");
const { assert!(HEADER >= TARGET) };
}
}
+5 -2
View File
@@ -17,13 +17,14 @@ use crate::vim_nav::NavFocus;
const MENU: &str = "View";
/// Opens the right panel on `tab`: `(command id, tab)`.
const SHOW: [(&str, RightTab); 6] = [
const SHOW: [(&str, RightTab); 7] = [
("ui.show_outline", RightTab::Outline),
("ui.show_related", RightTab::Related),
("ui.show_backlinks", RightTab::Backlinks),
("ui.show_assistant", RightTab::Assistant),
("ui.show_lint", RightTab::Lint),
("ui.show_comments", RightTab::Comments),
("ui.show_activity", RightTab::Activity),
];
/// The palette label of a hotkey command.
@@ -43,12 +44,13 @@ fn label(id: &str) -> Option<&'static str> {
"ui.show_assistant" => t::show_assistant(),
"ui.show_lint" => t::show_lint(),
"ui.show_comments" => t::show_comments(),
"ui.show_activity" => t::show_activity(),
_ => return None,
})
}
/// Every command this module runs.
const IDS: [&str; 14] = [
const IDS: [&str; 15] = [
"ui.next_tab",
"ui.prev_tab",
"ui.goto_tab",
@@ -63,6 +65,7 @@ const IDS: [&str; 14] = [
"ui.show_assistant",
"ui.show_lint",
"ui.show_comments",
"ui.show_activity",
];
#[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")]
@@ -57,6 +57,7 @@ pub const DEFAULTS: &[Hotkey] = &[
dispatch("ui.show_assistant", Binding::key(Key::A).cmd().alt(), Scope::Global), // ⌘⌥A
dispatch("ui.show_lint", Binding::key(Key::L).cmd().alt(), Scope::Global), // ⌘⌥L
dispatch("ui.show_comments", Binding::key(Key::M).cmd().alt(), Scope::Global), // ⌘⌥M
dispatch("ui.show_activity", Binding::key(Key::N).cmd().alt(), Scope::Global), // ⌘⌥N
dispatch("ui.toggle_right_panel", Binding::key(Key::Backslash).cmd().alt(), Scope::Global), // ⌘⌥\
// Older bindings, handled where they always were.
held_by("ui.toggle_sidebar", Binding::key(Key::B).cmd(), Scope::Global, Owner::Legacy), // ⌘B (panel_resize::workspace::shortcut)
@@ -111,6 +111,7 @@ fn every_command_the_shell_runs_has_a_default() {
"ui.show_assistant",
"ui.show_lint",
"ui.show_comments",
"ui.show_activity",
"ui.toggle_right_panel",
"ui.toggle_sidebar",
] {
@@ -72,13 +72,14 @@ pub(crate) fn sidebar_rail(ui: &mut Ui, tokens: &Tokens, state: &mut crate::stat
pub(crate) fn right_rail(ui: &mut Ui, tokens: &Tokens, tab: &mut crate::right_view::RightTab, expanded: &mut bool) {
use crate::right_view::RightTab;
const ENTRIES: [(Option<RightTab>, &str, &str, &str); 7] = [
const ENTRIES: [(Option<RightTab>, &str, &str, &str); 8] = [
(None, "panel-right", EXPAND, "ui.toggle_right_panel"),
(Some(RightTab::Backlinks), "link", "Backlinks", "ui.show_backlinks"),
(Some(RightTab::Related), "sparkles", "Related", "ui.show_related"),
(Some(RightTab::Lint), "list-checks", "Lint", "ui.show_lint"),
(Some(RightTab::Outline), "list", "Outline", "ui.show_outline"),
(Some(RightTab::Comments), "message-square", "Comments", "ui.show_comments"),
(Some(RightTab::Activity), "bell", "Activity", "ui.show_activity"),
(Some(RightTab::Assistant), "bot", "Assistant", "ui.show_assistant"),
];
for (choice, icon, label, hotkey) in ENTRIES {
+6
View File
@@ -99,6 +99,10 @@ pub struct UiState {
pub pinned_folders: Vec<String>,
/// Bodies (as 16-digit hex hashes) of "this block looks like JSON" offers the user declined.
pub dismissed_json: Vec<String>,
/// The grammar the user chose per source-file type (extension or name, lowercase), bounded to 200.
pub file_languages: std::collections::BTreeMap<String, String>,
/// File types whose "no syntax highlighting" hint the user dismissed, bounded to 200.
pub ignored_syntax: Vec<String>,
/// Set by the `find.open` command: open the find bar next frame; not saved.
#[serde(skip)]
pub find_requested: bool,
@@ -197,6 +201,8 @@ impl UiState {
pinned_folders: Vec::new(),
pinned_tabs: Vec::new(),
dismissed_json: Vec::new(),
file_languages: std::collections::BTreeMap::new(),
ignored_syntax: Vec::new(),
find_requested: false,
nav_request: None,
explorer_sort: crate::tree::Sort::default(),
@@ -15,6 +15,7 @@ table! {
show_assistant = "ui.hotkeys.show-assistant";
show_lint = "ui.hotkeys.show-lint";
show_comments = "ui.hotkeys.show-comments";
show_activity = "ui.hotkeys.show-activity";
region_sidebar = "ui.hotkeys.region-sidebar";
region_editor = "ui.hotkeys.region-editor";
region_right = "ui.hotkeys.region-right";
@@ -0,0 +1,23 @@
//! Copy for the activity tab of the right card.
table! {
title = "ui.activity-panel.title";
loading = "ui.activity-panel.loading";
empty = "ui.activity-panel.empty";
unbound = "ui.activity-panel.unbound";
unread = "ui.activity-panel.unread";
mark_read = "ui.activity-panel.mark-read";
now = "ui.activity-panel.now";
minutes = "ui.activity-panel.minutes";
hours = "ui.activity-panel.hours";
days = "ui.activity-panel.days";
mention = "ui.activity-panel.mention";
comment = "ui.activity-panel.comment";
suggestion = "ui.activity-panel.suggestion";
note_changed = "ui.activity-panel.note-changed";
invite_accepted = "ui.activity-panel.invite-accepted";
member_joined = "ui.activity-panel.member-joined";
role_changed = "ui.activity-panel.role-changed";
thread_resolved = "ui.activity-panel.thread-resolved";
other = "ui.activity-panel.other";
}
+2 -1
View File
@@ -1,3 +1,4 @@
//! Copy for the review panels: comments and suggestions.
//! Copy for the review panels: comments, suggestions and the activity feed.
pub mod activity_panel;
pub mod comment_panel;
@@ -3,3 +3,15 @@
table! {
more = "ui.code-view.more";
}
table! {
too_large = "ui.source-file.too-large";
plain = "ui.source-file.plain";
chip = "ui.source-file.chip";
banner = "ui.source-file.banner";
install = "ui.source-file.install";
other = "ui.source-file.other";
ignore = "ui.source-file.ignore";
search = "ui.source-file.search";
no_match = "ui.source-file.no-match";
}
@@ -13,3 +13,9 @@ table! {
kind_pdf = "ui.file-card.kind-pdf";
kind_other = "ui.file-card.kind-other";
}
table! {
avif_reason = "ui.source-file.avif-reason";
copy_hint = "ui.source-file.copy-hint";
kind_image = "ui.source-file.kind-image";
}
@@ -17,8 +17,7 @@ table! {
clear = "ui.image-annotate.clear";
clear_again = "ui.image-annotate.clear-again";
colour = "ui.image-annotate.colour";
label = "ui.image-annotate.label";
add_label = "ui.image-annotate.add-label";
text_size = "ui.image-annotate.text-size";
saved = "ui.image-annotate.saved";
saved_plain = "ui.image-annotate.saved-plain";
saving = "ui.image-annotate.saving";
@@ -0,0 +1,11 @@
//! Copy for the audio tab and the audio embed, served from the `ui.audio-view` table.
table! {
spectrum = "ui.audio-view.spectrum";
spectrum_tip = "ui.audio-view.spectrum-tip";
spectrum_label = "ui.audio-view.spectrum-label";
waveform = "ui.audio-view.waveform";
waveform_failed = "ui.audio-view.waveform-failed";
no_player = "ui.audio-view.no-player";
volume = "ui.audio-view.volume";
}
@@ -1,5 +1,7 @@
//! Copy for the media tabs (video and PDF), served from the `ui.media-view` table.
pub mod audio;
table! {
play = "ui.media-view.play";
stop = "ui.media-view.stop";
@@ -62,6 +62,8 @@ pub(super) fn show(
let text = color(colors.text);
let muted = color(colors.text_muted);
let style = RowStyle { font: FontId::new(11.0, crate::fonts::semibold()), text, muted };
// Source files are always the editor: the Visual/Source choice is meaningless for them.
let toggle = path.is_none_or(|path| !matches!(crate::tabs::TabKind::of(path), crate::tabs::TabKind::Code | crate::tabs::TabKind::Text));
let Some(path) = path else {
draw_toggle(ui, row, None, tokens, view, response, &style);
return;
@@ -140,7 +142,11 @@ pub(super) fn show(
response.live_toggle = true;
}
}
draw_toggle(ui, row, Some(geometry.pill), tokens, view, response, &style);
if toggle {
draw_toggle(ui, row, Some(geometry.pill), tokens, view, response, &style);
} else {
crate::code_view::chip(ui, tokens, geometry.pill, path);
}
}
fn draw_toggle(ui: &mut Ui, row: Rect, fixed_pill: Option<Rect>, tokens: &Tokens, view: ViewMode, response: &mut CenterResponse, style: &RowStyle) {
@@ -0,0 +1,195 @@
//! The editable source view of code and text files: the same token colours as the read-only viewer,
//! a line-number gutter and the theme's monospace font.
//!
//! The text the user edits is never altered for display: spans the grammar does not cover are laid out
//! plain, so the galley text always equals the buffer. Small files are highlighted on the spot, big ones by
//! the syntax worker (plain until it answers).
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use egui::text::{LayoutJob, TextFormat};
use egui::{Align2, FontId, Ui};
use notescraft_syntax::{Highlighted, Highlighter, TokenKind};
use super::CODE_SIZE;
use crate::theme::{Tokens, color};
/// Largest file opened for editing, in bytes; bigger files open read-only.
pub const MAX_EDIT_BYTES: usize = 2 * 1024 * 1024;
/// Space between the line numbers and the text, in points.
const NUMBER_GAP: f32 = 12.0;
/// Space left of the line numbers, in points.
const GUTTER_PAD: f32 = 8.0;
/// Largest text highlighted on the UI thread, in bytes; bigger texts go to the worker.
const SYNC_BYTES: usize = 64 * 1024;
/// Opacity of the line numbers against the muted text colour.
const NUMBER_OPACITY: f32 = 0.7;
/// The highlighted layout of one text, kept while the text and the palette stay the same.
#[derive(Clone)]
struct Cached {
/// Hash of the text, the extension and the colours it was built for.
key: u64,
/// The layout, wrapped nowhere.
job: Arc<LayoutJob>,
}
/// Draws the editor for `text`, highlighted with the grammar `language` (none: plain); `true` when it changed.
pub fn edit(ui: &mut Ui, tokens: &Tokens, path: &str, language: Option<&str>, text: &mut String) -> bool {
let mono = FontId::monospace(CODE_SIZE);
let digits = text.bytes().filter(|byte| *byte == b'\n').count().saturating_add(1).to_string().len();
let digit_width = ui.fonts_mut(|fonts| fonts.glyph_width(&mono, '0'));
#[expect(clippy::cast_precision_loss, reason = "a handful of digits")]
let gutter = GUTTER_PAD + digit_width * digits as f32 + NUMBER_GAP;
let cache_id = egui::Id::new((path, "code-layout"));
let muted = color(tokens.theme.colors.text_muted).gamma_multiply(NUMBER_OPACITY);
egui::ScrollArea::both()
.id_salt((path, "source"))
.auto_shrink([false, false])
.show(ui, |ui| {
let mut layouter = |ui: &Ui, buffer: &dyn egui::TextBuffer, _wrap: f32| {
let job = job_for(ui.ctx(), cache_id, tokens, language, buffer.as_str(), &mono);
ui.ctx().fonts_mut(|fonts| fonts.layout_job((*job).clone()))
};
let output = ui
.horizontal_top(|ui| {
ui.add_space(gutter);
egui::TextEdit::multiline(text)
.id_salt((path, "raw"))
.font(mono.clone())
.frame(egui::Frame::NONE)
.layouter(&mut layouter)
.desired_width(f32::INFINITY)
.min_size(ui.available_size())
.show(ui)
})
.inner;
for (number, y) in line_tops(&output.galley) {
let at = egui::pos2(output.galley_pos.x - NUMBER_GAP, output.galley_pos.y + y);
ui.painter().text(at, Align2::RIGHT_CENTER, number.to_string(), mono.clone(), muted);
}
output.response.changed()
})
.inner
}
/// The number and vertical middle (galley space) of every row that starts a source line.
fn line_tops(galley: &egui::Galley) -> Vec<(usize, f32)> {
let mut out = Vec::new();
let mut number = 1_usize;
let mut starts_line = true;
for row in &galley.rows {
if starts_line {
out.push((number, row.rect().center().y));
}
starts_line = row.ends_with_newline;
if starts_line {
number = number.saturating_add(1);
}
}
out
}
/// The layout of `text`, rebuilt only when the text, the grammar, the grammars installed or the palette changed.
fn job_for(ctx: &egui::Context, id: egui::Id, tokens: &Tokens, language: Option<&str>, text: &str, mono: &FontId) -> Arc<LayoutJob> {
let highlighted = language.filter(|language| *language != super::language::PLAIN).and_then(|language| spans_of(language, text));
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(text, language, tokens.theme.name.as_str(), highlighted.is_some(), notescraft_syntax::load::current().generation).hash(&mut hasher);
let key = hasher.finish();
if let Some(cached) = ctx.data(|data| data.get_temp::<Cached>(id)).filter(|cached| cached.key == key) {
return cached.job;
}
let job = Arc::new(build_job(tokens, highlighted.as_deref(), text, mono));
ctx.data_mut(|data| data.insert_temp(id, Cached { key, job: job.clone() }));
job
}
/// The token spans of `text` for `language`: now when the text is small, from the worker (or `None` while
/// it is busy) when it is not, and `None` for a text over the highlighter's caps.
fn spans_of(language: &str, text: &str) -> Option<Arc<Highlighted>> {
let lines = text.bytes().filter(|byte| *byte == b'\n').count().saturating_add(1);
if text.len() > SYNC_BYTES {
return notescraft_syntax::global().get(language, text, 0..lines).filter(|found| !found.lines.is_empty());
}
Highlighter::new(language, text).map(|mut highlighter| Arc::new(highlighter.lines(0..lines)))
}
/// The colour of a token kind in the theme's syntax palette.
fn kind_color(kind: TokenKind, tokens: &Tokens) -> egui::Color32 {
tokens.theme.syntax.all().get(kind.index()).copied().map_or_else(|| color(tokens.theme.colors.text), color)
}
/// One section per token run; the sections together are exactly `text`.
fn build_job(tokens: &Tokens, highlighted: Option<&Highlighted>, text: &str, mono: &FontId) -> LayoutJob {
let plain = TextFormat::simple(mono.clone(), color(tokens.theme.colors.text));
let mut job = LayoutJob::default();
job.wrap.max_width = f32::INFINITY;
for (index, line) in text.split('\n').enumerate() {
if index > 0 {
job.append("\n", 0.0, plain.clone());
}
let spans = highlighted.and_then(|found| index.checked_sub(found.first_line).and_then(|at| found.lines.get(at)));
let mut used = 0_usize;
for span in spans.map_or(&[][..], Vec::as_slice) {
// A span the grammar reports out of order or off a character boundary is left plain.
let Some(shown) = line.get(span.start..span.end).filter(|_| span.start >= used) else { continue };
if let Some(gap) = line.get(used..span.start).filter(|gap| !gap.is_empty()) {
job.append(gap, 0.0, plain.clone());
}
job.append(shown, 0.0, TextFormat::simple(mono.clone(), kind_color(span.kind, tokens)));
used = span.end;
}
if let Some(rest) = line.get(used..).filter(|rest| !rest.is_empty()) {
job.append(rest, 0.0, plain.clone());
}
}
job
}
#[cfg(test)]
mod tests {
use super::{build_job, edit};
use crate::theme::Tokens;
#[test]
fn the_editor_draws_numbers_and_leaves_the_text_alone() {
let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return };
let tokens = Tokens::new(theme);
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() };
let mut text = "const a = 1;\n// two\nlet b = 'x';\n".to_owned();
let before = text.clone();
let mut shown = Vec::new();
for _ in 0..2 {
let mut changed = false;
let mut out = ctx.run_ui(input.clone(), |ui| changed = edit(ui, &tokens, "a.js", Some("js"), &mut text));
out.textures_delta.clear();
assert!(!changed);
shown = out
.shapes
.iter()
.filter_map(|row| if let egui::Shape::Text(shaped) = &row.shape { Some(shaped.galley.text().to_owned()) } else { None })
.collect();
}
assert_eq!(text, before);
for number in ["1", "2", "3", "4"] {
assert!(shown.iter().any(|text| text == number), "line number {number} in {shown:?}");
}
}
#[test]
fn the_sections_rebuild_the_buffer_exactly() {
let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return };
let tokens = Tokens::new(theme);
let long = format!("let s = \"{}\"; // end\r\n", "ب".repeat(3000));
let text = format!("fn main() {{\r\n/* open\n still */ 12\n{long}\n\n");
let found = super::spans_of("rust", &text);
assert!(found.is_some());
let job = build_job(&tokens, found.as_deref(), &text, &egui::FontId::monospace(13.0));
assert_eq!(job.text, text);
assert!(job.sections.len() > 5, "the grammar coloured it");
}
}
@@ -0,0 +1,250 @@
//! Which grammar highlights a source file: the user's choice for its type, then the file type itself
//! (extension, well-known name), then the shebang line. The answer is a grammar label the syntax crate
//! finds, or nothing, in which case the tab offers to install a grammar or pick another language.
//!
//! The choices live in the saved UI state; the view reads them from the egui context ([`set_prefs`])
//! and posts what the user did as an [`Action`] the app takes ([`take_action`]) and saves.
use std::collections::BTreeMap;
use egui::Context;
use notescraft_syntax::load::is_known;
/// Most extension choices (and ignored extensions) the saved state keeps.
pub const MAX_ENTRIES: usize = 200;
/// The grammar label of "no highlighting".
pub const PLAIN: &str = "text";
/// Extensions of files that are plain on purpose: they never raise the hint.
const PLAIN_TYPES: &[&str] = &["txt", "text", "log", "csv", "tsv"];
/// Well-known file names (lowercase) and the grammar label they mean.
const NAMES: &[(&str, &str)] = &[
("makefile", "makefile"),
("gnumakefile", "makefile"),
("dockerfile", "dockerfile"),
(".bashrc", "bash"),
(".zshrc", "bash"),
(".profile", "bash"),
(".editorconfig", "ini"),
(".gitconfig", "ini"),
];
/// Interpreters named by a shebang line and the grammar label they mean.
const INTERPRETERS: &[(&str, &str)] =
&[("python", "python"), ("node", "javascript"), ("bash", "bash"), ("sh", "bash"), ("zsh", "bash"), ("ruby", "ruby"), ("perl", "perl"), ("php", "php")];
/// What the user chose, as the saved state holds it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Prefs {
/// File type key -> grammar label.
pub chosen: BTreeMap<String, String>,
/// File type keys whose "no syntax highlighting" hint was dismissed.
pub ignored: Vec<String>,
}
/// Something the user did in the tab, for the app to save.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Highlight files of this type with this grammar from now on.
Choose {
/// The file type key.
key: String,
/// The grammar label.
language: String,
},
/// Stop hinting for this file type.
Ignore {
/// The file type key.
key: String,
},
}
/// The key under which a file's type is remembered: its lowercase extension, or its lowercase name when
/// it has none (`makefile`, `.gitignore`), and how the banner names it (`.css`, `Makefile`).
#[must_use]
pub fn type_of(path: &str) -> (String, String) {
let name = path.rsplit('/').next().unwrap_or(path);
match name.rsplit_once('.').filter(|(stem, ext)| !stem.is_empty() && !ext.is_empty()) {
Some((_, ext)) => (ext.to_ascii_lowercase(), format!(".{ext}")),
None => (name.to_ascii_lowercase(), name.to_owned()),
}
}
/// The grammar label for `path`, given its first line and the user's choices; `None` when nothing
/// bundled or installed fits.
#[must_use]
pub fn resolve(path: &str, first_line: &str, chosen: &BTreeMap<String, String>) -> Option<String> {
let (key, _) = type_of(path);
if let Some(language) = chosen.get(&key).filter(|language| is_known(language)) {
return Some(language.clone());
}
if PLAIN_TYPES.contains(&key.as_str()) {
return Some(PLAIN.to_owned());
}
if is_known(&key) {
return Some(key);
}
if let Some((_, label)) = NAMES.iter().find(|(name, _)| *name == key).filter(|(_, label)| is_known(label)) {
return Some((*label).to_owned());
}
shebang(first_line)
}
/// The grammar of a `#!` line: `#!/usr/bin/env python3` and `#!/bin/bash -e` both work.
fn shebang(first_line: &str) -> Option<String> {
let rest = first_line.strip_prefix("#!")?;
let mut words = rest.split_whitespace();
let mut program = words.next()?.rsplit('/').next()?;
if program == "env" {
program = words.find(|word| !word.starts_with('-'))?;
}
let program = program.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
INTERPRETERS.iter().find(|(name, _)| *name == program).map(|(_, label)| (*label).to_owned()).filter(|label| is_known(label))
}
/// Whether the hint for a file of this type should show: nothing fits it and the user did not dismiss it.
#[must_use]
pub fn needs_hint(path: &str, resolved: Option<&str>, prefs: &Prefs) -> bool {
resolved.is_none() && !prefs.ignored.contains(&type_of(path).0)
}
/// Every language the picker offers, sorted, with the plain one first: the bundled and installed grammar names.
#[must_use]
pub fn languages() -> Vec<String> {
let mut names: Vec<String> =
notescraft_syntax::load::current().set.syntaxes().iter().filter(|syntax| !syntax.hidden).map(|syntax| syntax.name.clone()).collect();
names.sort_by_key(|name| name.to_lowercase());
names.dedup();
names.retain(|name| name != "Plain Text");
names.insert(0, "Plain Text".to_owned());
names
}
/// The label shown for a resolved grammar: its own name, or the plain one.
#[must_use]
pub fn name_of(language: Option<&str>) -> String {
let Some(language) = language else { return crate::text::code_view::plain().to_owned() };
if language == PLAIN {
return crate::text::code_view::plain().to_owned();
}
notescraft_syntax::load::current().find(language).map_or_else(|| language.to_owned(), |syntax| syntax.name.clone())
}
fn prefs_id() -> egui::Id {
egui::Id::new("notescraft-source-language-prefs")
}
fn action_id() -> egui::Id {
egui::Id::new("notescraft-source-language-action")
}
fn current_id() -> egui::Id {
egui::Id::new("notescraft-source-language-current")
}
/// Hands the view the saved choices (the app calls this every frame).
pub fn set_prefs(ctx: &Context, prefs: Prefs) {
ctx.data_mut(|data| data.insert_temp(prefs_id(), prefs));
}
/// The saved choices the app handed over.
#[must_use]
pub fn prefs(ctx: &Context) -> Prefs {
ctx.data(|data| data.get_temp::<Prefs>(prefs_id())).unwrap_or_default()
}
/// Posts what the user did.
pub fn post(ctx: &Context, action: Action) {
ctx.data_mut(|data| data.insert_temp(action_id(), action));
ctx.request_repaint();
}
/// Takes the action the user posted this frame.
#[must_use]
pub fn take_action(ctx: &Context) -> Option<Action> {
ctx.data_mut(|data| {
let action = data.get_temp::<Action>(action_id());
data.remove::<Action>(action_id());
action
})
}
/// Remembers how the open source file is highlighted, for the chip of the tab header.
pub fn set_current(ctx: &Context, path: &str, language: Option<&str>) {
ctx.data_mut(|data| data.insert_temp(current_id(), (path.to_owned(), language.map(str::to_owned))));
}
/// The grammar of the open source file `path` as the view last resolved it.
#[must_use]
pub fn current(ctx: &Context, path: &str) -> Option<String> {
ctx.data(|data| data.get_temp::<(String, Option<String>)>(current_id())).filter(|(open, _)| open == path).and_then(|(_, language)| language)
}
/// Applies an action to the saved choices, within the caps.
pub fn apply(prefs: &mut Prefs, action: Action) {
match action {
Action::Choose { key, language } => {
prefs.ignored.retain(|ignored| *ignored != key);
if prefs.chosen.len() < MAX_ENTRIES || prefs.chosen.contains_key(&key) {
prefs.chosen.insert(key, language);
}
}
Action::Ignore { key } => {
if prefs.ignored.len() < MAX_ENTRIES && !prefs.ignored.contains(&key) {
prefs.ignored.push(key);
}
}
}
}
#[cfg(test)]
mod tests {
use super::{Action, Prefs, apply, languages, needs_hint, resolve, type_of};
fn chosen() -> std::collections::BTreeMap<String, String> {
std::collections::BTreeMap::new()
}
#[test]
fn the_file_type_decides() {
assert_eq!(type_of("a/b/Publish.CSS"), ("css".to_owned(), ".CSS".to_owned()));
assert_eq!(type_of("x/Makefile"), ("makefile".to_owned(), "Makefile".to_owned()));
assert_eq!(type_of(".gitignore"), (".gitignore".to_owned(), ".gitignore".to_owned()));
for path in ["a.css", "a.js", "a.json", "a.py", "a.rs", "a.ts", "a.toml", "a.yaml", "a.sh", "Makefile"] {
assert!(resolve(path, "", &chosen()).is_some(), "{path}");
}
assert_eq!(resolve("notes.txt", "", &chosen()).as_deref(), Some("text"));
assert_eq!(resolve("data.xyz", "", &chosen()), None);
}
#[test]
fn the_shebang_is_the_fallback_and_the_choice_wins() {
assert!(resolve("run", "#!/usr/bin/env python3", &chosen()).is_some());
assert!(resolve("run", "#!/bin/bash -e", &chosen()).is_some());
assert_eq!(resolve("run", "echo hi", &chosen()), None);
let mut picked = chosen();
picked.insert("xyz".to_owned(), "rust".to_owned());
assert_eq!(resolve("a.xyz", "", &picked).as_deref(), Some("rust"));
picked.insert("css".to_owned(), "rust".to_owned());
assert_eq!(resolve("a.css", "", &picked).as_deref(), Some("rust"), "the choice beats the type");
}
#[test]
fn the_hint_shows_for_unknown_types_until_ignored_or_chosen() {
let mut prefs = Prefs::default();
assert!(needs_hint("a.xyz", resolve("a.xyz", "", &prefs.chosen).as_deref(), &prefs));
assert!(!needs_hint("a.css", resolve("a.css", "", &prefs.chosen).as_deref(), &prefs));
apply(&mut prefs, Action::Ignore { key: "xyz".to_owned() });
assert!(!needs_hint("a.xyz", None, &prefs));
apply(&mut prefs, Action::Choose { key: "xyz".to_owned(), language: "rust".to_owned() });
assert_eq!(prefs.ignored.len(), 0);
assert!(!needs_hint("a.xyz", resolve("a.xyz", "", &prefs.chosen).as_deref(), &prefs));
}
#[test]
fn the_picker_lists_the_common_languages() {
let all = languages();
assert_eq!(all.first().map(String::as_str), Some("Plain Text"));
for name in ["Rust", "JavaScript", "Python", "JSON"] {
assert!(all.iter().any(|language| language == name), "{name}");
}
}
}
@@ -0,0 +1,110 @@
//! The language controls of a source file's tab: the hint banner for a file type no grammar fits and the
//! chip in the tab header that changes the language at any time. Both open the same searchable picker.
use egui::{Align2, CornerRadius, FontId, Margin, Rect, Response, Sense, Ui, Vec2};
use super::language::{self, Action, PLAIN};
use crate::text::code_view as strings;
use crate::theme::{Tokens, color};
/// Text size of the banner and the chip, in points.
const TEXT_SIZE: f32 = 12.0;
/// Space around the chip's text, in points.
const CHIP_PAD: f32 = 10.0;
/// Side of the chip's arrow, in points.
const ARROW: f32 = 12.0;
/// Width of the picker, in points.
const PICKER_WIDTH: f32 = 240.0;
/// Tallest the picker's list grows, in points.
const PICKER_HEIGHT: f32 = 260.0;
/// Space between banner parts, in points.
const BANNER_GAP: f32 = 8.0;
/// The banner under the tab header: no grammar fits `path`'s type. Install one, pick a language or ignore it.
pub fn banner(ui: &mut Ui, tokens: &Tokens, path: &str) {
let colors = &tokens.theme.colors;
let (key, shown) = language::type_of(path);
egui::Frame::new().fill(color(colors.surface_2)).corner_radius(tokens.radius_small()).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = BANNER_GAP;
ui.label(egui::RichText::new(strings::banner().replace("{{type}}", &shown) /* i18n-ok */).size(TEXT_SIZE).color(color(colors.text_muted)));
if ui.add(button(tokens, strings::install(), false)).clicked() {
crate::note_view::request_grammar(ui.ctx(), &key);
}
let other = ui.add(button(tokens, strings::other(), false));
picker(&other, &key, None);
if ui.add(button(tokens, strings::ignore(), false)).clicked() {
language::post(ui.ctx(), Action::Ignore { key });
}
});
});
ui.add_space(BANNER_GAP);
}
/// The language chip in `rect` (the place the Visual/Source toggle takes in notes): shows the language of
/// `path` and opens the picker.
pub fn chip(ui: &mut Ui, tokens: &Tokens, rect: Rect, path: &str) {
let colors = &tokens.theme.colors;
let name = language::name_of(language::current(ui.ctx(), path).as_deref());
let hit = ui.interact(rect, egui::Id::new(("notescraft-language-chip", path)), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::same(crate::style::radius_u8(tokens.radius_small())), color(colors.surface));
let tint = color(colors.text);
let font = FontId::new(TEXT_SIZE, crate::fonts::semibold());
// The room left of the arrow: the full label when it fits, the bare language name when it does not.
let room = rect.width() - CHIP_PAD * 2.0 - ARROW;
let full = strings::chip().replace("{{language}}", &name);
let fits = |text: &str| ui.painter().layout_no_wrap(text.to_owned(), font.clone(), tint).size().x <= room;
let label = if fits(&full) { full } else { name.clone() };
hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone()));
let painter = ui.painter().with_clip_rect(Rect::from_min_max(rect.min, egui::pos2(rect.right() - CHIP_PAD - ARROW, rect.bottom())));
painter.text(egui::pos2(rect.left() + CHIP_PAD, rect.center().y), Align2::LEFT_CENTER, label, font, tint);
let arrow = Rect::from_center_size(egui::pos2(rect.right() - CHIP_PAD - ARROW / 2.0, rect.center().y), Vec2::splat(ARROW));
crate::icons::paint(ui, "chevron-down", arrow, color(colors.text_muted));
picker(&hit, &language::type_of(path).0, Some(&name));
}
/// A flat button in the theme's tones.
fn button<'a>(tokens: &Tokens, text: &'a str, selected: bool) -> egui::Button<'a> {
let colors = &tokens.theme.colors;
let fill = if selected { colors.selection } else { colors.surface };
egui::Button::new(egui::RichText::new(text).size(TEXT_SIZE).color(color(colors.text))).fill(color(fill)).corner_radius(tokens.radius_small())
}
/// The searchable list of languages, a menu under `anchor`; choosing one remembers it for files of type `key`.
fn picker(anchor: &Response, key: &str, current: Option<&str>) {
let ctx = anchor.ctx.clone();
let query_id = anchor.id.with("notescraft-language-query");
let shown = egui::Popup::menu(anchor).width(PICKER_WIDTH).show(|ui| {
let mut query = ui.data_mut(|data| data.get_temp::<String>(query_id)).unwrap_or_default();
let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(strings::search()).desired_width(f32::INFINITY));
if !ui.memory(|memory| memory.has_focus(search.id)) && !search.lost_focus() {
search.request_focus();
}
let needle = query.to_lowercase();
let matches: Vec<String> = language::languages().into_iter().filter(|name| needle.is_empty() || name.to_lowercase().contains(&needle)).collect();
let mut picked = None;
if search.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) {
picked = matches.first().cloned();
}
egui::ScrollArea::vertical().max_height(PICKER_HEIGHT).show(ui, |ui| {
for name in matches.iter().filter(|name| !name.is_empty()) {
if ui.selectable_label(current == Some(name.as_str()), name).clicked() {
picked = Some(name.clone());
}
}
if matches.is_empty() {
ui.label(strings::no_match());
}
});
ui.data_mut(|data| data.insert_temp(query_id, query));
if let Some(name) = picked {
let language = if name == "Plain Text" { PLAIN.to_owned() } else { name };
language::post(ui.ctx(), Action::Choose { key: key.to_owned(), language });
ui.close();
}
});
if shown.is_none() {
ctx.data_mut(|data| data.remove_temp::<String>(query_id));
}
}
+34 -6
View File
@@ -1,7 +1,13 @@
//! The read-only code and text viewer: highlighted rows with line numbers in the theme's monospace
//! font. It is not wired into the tab strip yet — code files still open in the text editor.
//! font, and [`edit`], the editable twin the tabs of code and text files use.
mod editor;
mod highlight;
pub mod language;
mod language_ui;
pub use editor::{MAX_EDIT_BYTES, edit};
pub use language_ui::{banner, chip};
pub use highlight::{MAX_LINE_CHARS, MAX_LINES, Span, TokenClass, highlight_lines};
@@ -21,16 +27,38 @@ const GUTTER_GAP: &str = " ";
/// `ext`: line numbers, highlighted rows in the theme's monospace font and, when the line cap cut
/// the document short, a notice saying so.
pub fn show(ui: &mut Ui, tokens: &Tokens, text: &str, ext: &str) {
let lines = highlight_lines(text, ext);
let (lines, total) = highlighted(ui.ctx(), text, ext);
let mono = FontId::monospace(CODE_SIZE);
let digits = lines.len().to_string().len();
egui::ScrollArea::both().id_salt("notescraft-code-view").auto_shrink([false, false]).show(ui, |ui| {
let row_height = ui.fonts_mut(|fonts| fonts.row_height(&mono)) + ROW_GAP;
// Only the rows in view are laid out: a file of 20 000 lines costs the same as one of 20.
egui::ScrollArea::both().id_salt("notescraft-code-view").auto_shrink([false, false]).show_rows(ui, row_height, lines.len(), |ui, rows| {
ui.spacing_mut().item_spacing.y = ROW_GAP;
for (index, spans) in lines.iter().enumerate() {
draw_row(ui, tokens, gutter_text(index.saturating_add(1), digits).as_str(), spans, &mono);
for index in rows {
if let Some(spans) = lines.get(index) {
draw_row(ui, tokens, gutter_text(index.saturating_add(1), digits).as_str(), spans, &mono);
}
}
notice(ui, tokens, lines.len(), text.lines().count());
});
notice(ui, tokens, lines.len(), total);
}
/// The highlighted lines of `text` and its real line count. The text is large and unchanged from frame to
/// frame, so the answer is kept against the text's place and length instead of being computed again.
fn highlighted(ctx: &egui::Context, text: &str, ext: &str) -> (std::sync::Arc<Vec<Vec<Span>>>, usize) {
type Kept = (usize, usize, String, std::sync::Arc<Vec<Vec<Span>>>, usize);
let id = egui::Id::new("notescraft-code-view-lines");
let key = (text.as_ptr() as usize, text.len());
if let Some((ptr, len, kept_ext, lines, total)) = ctx.data(|data| data.get_temp::<Kept>(id))
&& (ptr, len) == key
&& kept_ext == ext
{
return (lines, total);
}
let lines = std::sync::Arc::new(highlight_lines(text, ext));
let total = text.lines().count();
ctx.data_mut(|data| data.insert_temp::<Kept>(id, (key.0, key.1, ext.to_owned(), lines.clone(), total)));
(lines, total)
}
/// The right-aligned line number of row `number`, padded to `digits` digits plus a gap.
+169 -43
View File
@@ -2,7 +2,8 @@
//! card of audio, video, PDF and unknown files, with `file.info`-like properties and
//! Open-with-the-system, which the shell runs through its existing helper.
use egui::{Align, Align2, CornerRadius, FontId, Layout, Margin, Rect, RichText, Sense, Ui, Vec2};
use egui::text::LayoutJob;
use egui::{Align, Align2, Color32, CornerRadius, FontId, Label, Layout, Margin, Rect, RichText, Sense, Ui, Vec2};
use notescraft_engine::Session;
use notescraft_templates::{Date, DateTime};
@@ -31,8 +32,12 @@ const CARD_PADDING: i8 = 24;
const ICON_SIZE: f32 = 56.0;
/// Side of the icon glyph inside its square, points.
const ICON_INNER: f32 = 24.0;
/// Width of the property table, points.
const ROWS_WIDTH: f32 = 240.0;
/// Width of the label column of the property table, points.
const LABEL_WIDTH: f32 = 72.0;
/// Space between the label column and the values, points.
const LABEL_GAP: f32 = 8.0;
/// The picture format no decoder here reads; its card says why.
const AVIF: &str = "avif";
/// Text size of one property row, points.
const ROW_TEXT: f32 = 12.0;
/// Space between property rows, points.
@@ -60,8 +65,7 @@ pub fn show(ui: &mut Ui, tokens: &Tokens, path: &str) {
/// kind icon, the name, the properties and Open-with-the-system. Returns the file the shell opened
/// with the system when the button was clicked this frame.
pub fn show_info(ui: &mut Ui, tokens: &Tokens, session: &Session, path: &str) -> Option<String> {
let colors = &tokens.theme.colors;
let kind = TabKind::of(path);
let info = info_of(session, path);
let mut opened = None;
ui.vertical(|ui| {
let full = ui.max_rect();
@@ -69,28 +73,66 @@ pub fn show_info(ui: &mut Ui, tokens: &Tokens, session: &Session, path: &str) ->
let mut card = ui.new_child(egui::UiBuilder::new().id_salt("notescraft-file-card").max_rect(column));
// The estimate of the card's height, as a spacer above it, centres the card on the tab.
card.add_space(((card.max_rect().height() - CARD_HEIGHT) / 2.0).max(0.0));
egui::Frame::new().fill(color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::same(CARD_PADDING)).show(&mut card, |ui| {
ui.vertical_centered(|ui| {
ui.set_min_width(CARD_WIDTH - f32::from(CARD_PADDING) * 2.0);
backdrop(ui, tokens, kind.icon());
ui.add_space(ICON_GAP);
ui.label(RichText::new(name_of(path)).font(FontId::new(NAME_SIZE, crate::fonts::semibold())).color(color(colors.text)));
if matches!(kind, TabKind::Video | TabKind::Audio | TabKind::Pdf) {
ui.label(RichText::new(strings::soon()).size(NOTE_SIZE).color(color(colors.text_muted)));
}
ui.add_space(ACTION_GAP);
rows(ui, tokens, kind, session, path);
ui.add_space(ACTION_GAP);
if ui.add(open_button(tokens)).clicked() {
opened = Some(path.to_owned());
}
});
});
opened = card_in(&mut card, tokens, &info, path);
ui.allocate_rect(card.min_rect(), Sense::hover());
});
opened
}
/// The card itself, drawn into `ui` at the width it has; `Some` when the button was clicked.
fn card_in(ui: &mut Ui, tokens: &Tokens, info: &Info, path: &str) -> Option<String> {
let colors = &tokens.theme.colors;
let kind = TabKind::of(path);
let mut opened = None;
egui::Frame::new().fill(color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::same(CARD_PADDING)).show(ui, |ui| {
ui.vertical_centered(|ui| {
let width = ui.available_width().min(CARD_WIDTH - f32::from(CARD_PADDING) * 2.0);
ui.set_width(width);
backdrop(ui, tokens, kind.icon());
ui.add_space(ICON_GAP);
let font = FontId::new(NAME_SIZE, crate::fonts::semibold());
ui.add(Label::new(wrapped(name_of(path), font, color(colors.text), width, Align::Center)));
if let Some(note) = note_of(kind, path) {
ui.add(Label::new(wrapped(note, FontId::proportional(NOTE_SIZE), color(colors.text_muted), width, Align::Center)));
}
ui.add_space(ACTION_GAP);
rows(ui, tokens, kind, info, path, width);
ui.add_space(ACTION_GAP);
if ui.add(open_button(tokens)).clicked() {
opened = Some(path.to_owned());
}
});
});
opened
}
/// Whether `path` is a picture format this app has no decoder for: its tab is the info card.
#[must_use]
pub fn is_undecodable_picture(path: &str) -> bool {
extension_of(path) == AVIF
}
/// The line under the name: why there is no preview.
fn note_of(kind: TabKind, path: &str) -> Option<&'static str> {
if is_undecodable_picture(path) {
return Some(strings::avif_reason());
}
matches!(kind, TabKind::Video | TabKind::Audio | TabKind::Pdf).then(strings::soon)
}
/// The lowercase extension of a path, or nothing.
fn extension_of(path: &str) -> String {
name_of(path).rsplit_once('.').map_or_else(String::new, |(_, ext)| ext.to_ascii_lowercase())
}
/// `text` as a layout that wraps at `width` and breaks inside a word that is longer than the line.
fn wrapped(text: &str, font: FontId, tint: Color32, width: f32, align: Align) -> LayoutJob {
let mut job = LayoutJob::simple(text.to_owned(), font, tint, width);
job.wrap.break_anywhere = true;
job.halign = align;
job
}
/// The rounded icon square at the top of the card, with the tab kind's icon inside it.
fn backdrop(ui: &mut Ui, tokens: &Tokens, icon: &str) {
let colors = &tokens.theme.colors;
@@ -99,29 +141,70 @@ fn backdrop(ui: &mut Ui, tokens: &Tokens, icon: &str) {
crate::icons::paint(ui, icon, Rect::from_center_size(rect.center(), Vec2::splat(ICON_INNER)), color(colors.text_muted));
}
/// The property table: kind, size, last modification and the vault-relative path.
fn rows(ui: &mut Ui, tokens: &Tokens, kind: TabKind, session: &Session, path: &str) {
let info = info_of(session, path);
ui.allocate_ui_with_layout(Vec2::new(ROWS_WIDTH, 0.0), Layout::top_down(Align::Min), |ui| {
row(ui, tokens, strings::label_type(), kind_label(kind));
row(ui, tokens, strings::label_size(), size_text(info.size).as_str());
row(ui, tokens, strings::label_modified(), modified_text(info.modified_ms).as_str());
row(ui, tokens, strings::label_path(), path);
});
/// The property table: a label column of fixed width and the values beside it, wrapped to the card.
fn rows(ui: &mut Ui, tokens: &Tokens, kind: TabKind, info: &Info, path: &str, width: f32) {
let value_width = (width - LABEL_WIDTH - LABEL_GAP).max(0.0);
let kind_text = kind_label(kind, path);
for (label, value) in [
(strings::label_type(), kind_text.to_owned()),
(strings::label_size(), size_text(info.size)),
(strings::label_modified(), modified_text(info.modified_ms)),
] {
row(ui, tokens, label, &value, value.clone(), value_width);
}
let shown = elide_middle(ui, path, &FontId::proportional(ROW_TEXT), value_width);
row(ui, tokens, strings::label_path(), &shown, path.to_owned(), value_width);
}
/// One property row: the muted label left, the value right.
fn row(ui: &mut Ui, tokens: &Tokens, label: &str, value: &str) {
/// One property row. `value` is what is drawn, `full` what a click copies and the tooltip shows.
fn row(ui: &mut Ui, tokens: &Tokens, label: &str, value: &str, full: String, value_width: f32) {
let colors = &tokens.theme.colors;
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.label(RichText::new(label).size(ROW_TEXT).color(color(colors.text_muted)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(value).size(ROW_TEXT).color(color(colors.text)));
let font = FontId::proportional(ROW_TEXT);
ui.allocate_ui_with_layout(Vec2::new(LABEL_WIDTH + LABEL_GAP + value_width, 0.0), Layout::left_to_right(Align::Min), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.allocate_ui(Vec2::new(LABEL_WIDTH, 0.0), |ui| {
ui.set_width(LABEL_WIDTH);
ui.add(Label::new(RichText::new(label).font(font.clone()).color(color(colors.text_muted))).wrap_mode(egui::TextWrapMode::Truncate));
});
ui.add_space(LABEL_GAP);
ui.allocate_ui(Vec2::new(value_width, 0.0), |ui| {
ui.set_width(value_width);
let shown = ui.add(Label::new(wrapped(value, font, color(colors.text), value_width, Align::Min)).sense(Sense::click()));
let hint = if value == full { strings::copy_hint().to_owned() } else { full.clone() };
let shown = shown.on_hover_text(hint).on_hover_cursor(egui::CursorIcon::PointingHand);
if shown.clicked() {
ui.ctx().copy_text(full);
}
});
});
ui.add_space(ROW_GAP);
}
/// `text` cut in the middle with an ellipsis until it is no wider than `max_width`: both ends of a path stay readable.
fn elide_middle(ui: &Ui, text: &str, font: &FontId, max_width: f32) -> String {
let chars: Vec<char> = text.chars().collect();
let width_of = |candidate: &str| ui.painter().layout_no_wrap(candidate.to_owned(), font.clone(), Color32::WHITE).size().x;
if width_of(text) <= max_width {
return text.to_owned();
}
let joined = |kept: usize| {
let head: String = chars.iter().take(kept.div_ceil(2)).collect();
let tail: String = chars.iter().skip(chars.len().saturating_sub(kept / 2)).collect();
format!("{head}…{tail}")
};
// The widest cut that still fits; the width grows with the characters kept.
let (mut low, mut high) = (0_usize, chars.len());
while low < high {
let middle = low + (high - low).div_ceil(2);
if width_of(&joined(middle)) <= max_width {
low = middle;
} else {
high = middle - 1;
}
}
joined(low)
}
/// The primary Open-with-the-system button of the card.
fn open_button(tokens: &Tokens) -> egui::Button<'_> {
egui::Button::new(
@@ -138,8 +221,9 @@ fn name_of(path: &str) -> &str {
}
/// The label of the file kind, for the type row; the card is only drawn for kinds without a viewer.
fn kind_label(kind: TabKind) -> &'static str {
fn kind_label(kind: TabKind, path: &str) -> &'static str {
match kind {
TabKind::Image if is_undecodable_picture(path) => strings::kind_image(),
TabKind::Video => strings::kind_video(),
TabKind::Audio => strings::kind_audio(),
TabKind::Pdf => strings::kind_pdf(),
@@ -206,17 +290,17 @@ fn unix_ms(moment: std::time::SystemTime) -> Option<u64> {
#[cfg(test)]
mod tests {
use super::{kind_label, modified_text, moment_of, name_of, size_text};
use super::{Info, card_in, kind_label, modified_text, moment_of, name_of, size_text};
use crate::tabs::TabKind;
use crate::text::file_card as strings;
#[test]
fn kinds_carry_their_label() {
assert_eq!(kind_label(TabKind::Video), strings::kind_video());
assert_eq!(kind_label(TabKind::Audio), strings::kind_audio());
assert_eq!(kind_label(TabKind::Pdf), strings::kind_pdf());
assert_eq!(kind_label(TabKind::Other), strings::kind_other());
assert_eq!(kind_label(TabKind::Video, "a.mp4"), strings::kind_video());
assert_eq!(kind_label(TabKind::Audio, "x"), strings::kind_audio());
assert_eq!(kind_label(TabKind::Pdf, "x"), strings::kind_pdf());
assert_eq!(kind_label(TabKind::Other, "x"), strings::kind_other());
}
#[test]
@@ -250,4 +334,46 @@ mod tests {
assert_eq!(size_text(Some(1024)), "1 KB");
assert_eq!(size_text(Some(3000)), "3 KB");
}
/// Paints the card for `path` at `width` and returns the rectangle of every text it drew.
fn text_rects(path: &str, width: f32) -> Vec<(String, egui::Rect)> {
let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return Vec::new() };
let tokens = crate::theme::Tokens::new(theme);
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0))), ..Default::default() };
let info = Info { size: Some(5_000_000), modified_ms: Some(1_790_000_000_000) };
let mut rects = Vec::new();
for _ in 0..2 {
let mut out = ctx.run_ui(input.clone(), |ui| {
card_in(ui, &tokens, &info, path);
});
out.textures_delta.clear();
rects = out
.shapes
.iter()
.filter_map(|row| match &row.shape {
egui::Shape::Text(shaped) => Some((shaped.galley.text().to_owned(), shaped.visual_bounding_rect())),
_ => None,
})
.collect();
}
rects
}
#[test]
fn a_very_long_path_and_name_overlap_nothing() {
let name = format!("{}.ogg", "Excerpt-of-a-very-long-recording-name-".repeat(5));
let path = format!("bn/Attachments/{}/{name}", "audio".repeat(20));
assert!(path.len() >= 200 && name.len() >= 150);
let rects = text_rects(&path, 600.0);
assert!(rects.len() >= 6, "{rects:?}");
for (index, (first_text, first)) in rects.iter().enumerate() {
assert!(first.width() <= 330.0, "{first_text:?} is wider than the card: {first:?}");
for (second_text, second) in rects.iter().skip(index + 1) {
assert!(!first.shrink(0.5).intersects(second.shrink(0.5)), "{first_text:?} {first:?} overlaps {second_text:?} {second:?}");
}
}
assert!(rects.iter().any(|(text, _)| text.contains('…')), "the path is cut in the middle");
}
}
-4
View File
@@ -72,10 +72,6 @@ fn icon_of(node: &TreeNode) -> &'static str {
if node.folder {
return "folder";
}
let lower = node.path.to_ascii_lowercase();
if [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].iter().any(|ext| lower.ends_with(ext)) {
return "image";
}
crate::tabs::TabKind::of(&node.path).icon()
}
@@ -4,15 +4,16 @@
//! the placement of the picture before it is drawn. The colour of a mark is a
//! theme colour, resolved with the theme that is on now.
use egui::epaint::TextShape;
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, StrokeKind, Vec2};
use notescraft_annotate::{ColorRef, Item, Point, StrokeTool};
use crate::theme::Tokens;
use crate::theme::{Tokens, color};
use super::Picture;
use super::State;
use super::colour_of;
use super::place::to_screen;
use super::place::{arrow_barbs, picture_rect, to_screen};
/// The alpha of a highlighter stroke, over the picture.
const HIGHLIGHTER_ALPHA: u8 = 96;
@@ -20,25 +21,47 @@ const HIGHLIGHTER_ALPHA: u8 = 96;
/// The width of a highlighter stroke, as a multiple of the width of the tool.
const HIGHLIGHTER_SCALE: f32 = 3.0;
/// The length of the head of an arrow, as a multiple of the width of the line.
const ARROW_HEAD: f32 = 4.0;
/// The room around the text of a label, as a multiple of its font size.
const PILL_PAD: f32 = 0.25;
/// The half angle of the head of an arrow, in radians.
const ARROW_ANGLE: f32 = 0.42;
/// The share of the background tone the pill under a label keeps, so the picture shows through.
const PILL_OPACITY: f32 = 0.7;
/// Paints the marks of the state over the picture.
/// Side of the squares on the corners of the selected label, in points.
const HANDLE: f32 = 6.0;
/// Width of the outline of the selected label, in points.
const SELECTION: f32 = 1.5;
/// Paints the marks of the state over the picture, never outside it.
pub fn paint(ui: &egui::Ui, tokens: &Tokens, state: &State, picture: &Picture, canvas: Rect) {
let painter = ui.painter().with_clip_rect(canvas);
for item in &state.notes.items {
paint_item(&painter, tokens, item, picture, canvas);
if picture.size.is_none() {
return;
}
let painter = ui.painter().with_clip_rect(picture_rect(picture, canvas).intersect(canvas));
let editing = state.label.as_ref().and_then(|label| label.index);
for (index, item) in state.notes.items.iter().enumerate() {
if editing == Some(index) {
continue;
}
let selected = state.selected == Some(index) && state.label.is_none();
paint_item(&painter, tokens, item, picture, canvas, selected);
}
if let Some(draft) = &state.draft {
paint_item(&painter, tokens, draft, picture, canvas);
paint_item(&painter, tokens, draft, picture, canvas, false);
}
}
/// A segment with round caps, as shapes.
fn capped(shapes: &mut Vec<Shape>, start: Pos2, end: Pos2, stroke: Stroke) {
shapes.push(Shape::line_segment([start, end], stroke));
for cap in [start, end] {
shapes.push(Shape::circle_filled(cap, stroke.width / 2.0, stroke.color));
}
}
/// Paints one mark.
fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &Picture, canvas: Rect) {
fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &Picture, canvas: Rect, selected: bool) {
let zoom = picture.placement.zoom;
match item {
Item::Stroke { tool, color, width, points } => {
@@ -48,11 +71,15 @@ fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &P
Item::Line { color, width, from, to, arrow } => {
let start = to_screen(picture, canvas, *from);
let end = to_screen(picture, canvas, *to);
let stroke = Stroke::new(width * zoom, colour_of(tokens, *color));
painter.add(Shape::line(vec![start, end], stroke));
if *arrow {
paint_arrow(painter, start, end, stroke);
let stroke = Stroke::new((width * zoom).max(1.0), colour_of(tokens, *color));
let mut shapes = Vec::new();
capped(&mut shapes, start, end, stroke);
if *arrow && let Some(barbs) = arrow_barbs(start, end, stroke.width) {
for barb in barbs {
capped(&mut shapes, end, barb, stroke);
}
}
painter.add(Shape::Vec(shapes));
}
Item::Rect { color, width, min, max, filled } => {
let rect = box_of(picture, canvas, *min, *max);
@@ -71,13 +98,45 @@ fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &P
painter.add(Shape::ellipse_stroke(rect.center(), rect.size() / 2.0, stroke));
}
Item::Text { color, size, at, text } => {
let anchor = to_screen(picture, canvas, *at);
let font = FontId::new(size * zoom, egui::FontFamily::Proportional);
painter.text(anchor, egui::Align2::LEFT_TOP, text.clone(), font, colour_of(tokens, *color));
let area = paint_text(painter, tokens, picture, canvas, *at, *size, text, colour_of(tokens, *color));
if selected {
paint_selection(painter, tokens, area);
}
}
}
}
/// Paints a label on a pill, turned with the picture; answers the box it covers on the canvas.
#[expect(clippy::too_many_arguments, reason = "a label is its place, size, text and colour; a struct would only rename them")]
fn paint_text(painter: &egui::Painter, tokens: &Tokens, picture: &Picture, canvas: Rect, at: Point, size: f32, text: &str, ink: Color32) -> Rect {
let font = FontId::new(size * picture.placement.zoom, egui::FontFamily::Proportional);
let galley = painter.layout_no_wrap(text.to_owned(), font.clone(), ink);
let anchor = to_screen(picture, canvas, at);
let angle = picture.placement.rotation;
let (turn_sin, turn_cos) = angle.sin_cos();
let pad = font.size * PILL_PAD;
let extent = galley.size();
let mut area = Rect::NOTHING;
for corner in [Vec2::new(-pad, -pad), Vec2::new(extent.x + pad, -pad), Vec2::new(extent.x + pad, extent.y + pad), Vec2::new(-pad, extent.y + pad)] {
area.extend_with(anchor + Vec2::new(corner.x * turn_cos - corner.y * turn_sin, corner.x * turn_sin + corner.y * turn_cos));
}
// ponytail: the pill is the box of the turned corners, exact on quarter turns; a turn in
// between (while the picture is spinning) shows a slightly larger pill.
let pill = color(tokens.theme.colors.background).gamma_multiply(PILL_OPACITY);
painter.add(Shape::rect_filled(area, pad, pill));
painter.add(TextShape::new(anchor, galley, ink).with_angle_and_anchor(angle, egui::Align2::LEFT_TOP));
area
}
/// Outlines the selected label with a square on each corner.
fn paint_selection(painter: &egui::Painter, tokens: &Tokens, area: Rect) {
let accent = color(tokens.theme.colors.accent);
painter.add(Shape::rect_stroke(area, 0.0, Stroke::new(SELECTION, accent), StrokeKind::Outside));
for corner in [area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()] {
painter.add(Shape::rect_filled(Rect::from_center_size(corner, Vec2::splat(HANDLE)), 1.0, accent));
}
}
/// Paints a freehand stroke; a stroke of one point is a dot.
fn paint_stroke(painter: &egui::Painter, tokens: &Tokens, tool: StrokeTool, colour: ColorRef, width: f32, screen: &[Pos2]) {
let base = colour_of(tokens, colour);
@@ -97,23 +156,6 @@ fn paint_stroke(painter: &egui::Painter, tokens: &Tokens, tool: StrokeTool, colo
}
}
/// Paints the head of an arrow at the end of a line.
fn paint_arrow(painter: &egui::Painter, start: Pos2, end: Pos2, stroke: Stroke) {
let length = (end - start).length();
if length <= 0.0 {
return;
}
let delta = end - start;
let direction = delta / length;
let head = (stroke.width * ARROW_HEAD).max(4.0);
let angle = ARROW_ANGLE;
let (sine, cosine) = angle.sin_cos();
for flip in [1.0_f32, -1.0_f32] {
let wing = Vec2::new(direction.x * cosine - delta.y * sine * flip, direction.x * sine + delta.y * cosine * flip);
painter.add(Shape::line(vec![end, end - wing * head], stroke));
}
}
/// The box on the canvas that two corners of the picture span.
fn box_of(picture: &Picture, canvas: Rect, min: Point, max: Point) -> Rect {
let first = to_screen(picture, canvas, min);
@@ -0,0 +1,43 @@
//! The keys of the draw mode: one letter for each tool, undo and redo, Delete and Enter.
//!
//! They answer only while no text field has the keyboard, so typing a label never switches a tool.
use egui::{Context, Key, KeyboardShortcut, Modifiers};
use super::tools::commit_label;
use super::{State, Tool, text};
/// Undo.
pub const UNDO: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z);
/// Redo.
pub const REDO: KeyboardShortcut = KeyboardShortcut::new(Modifiers { shift: true, ..Modifiers::COMMAND }, Key::Z);
/// Answers the keys of the frame.
pub(super) fn handle(ctx: &Context, state: &mut State) {
if ctx.text_edit_focused() {
return;
}
let (redo, undo, done, delete, tool) = ctx.input_mut(|input| {
let redo = input.consume_shortcut(&REDO);
let undo = input.consume_shortcut(&UNDO);
let plain = !input.modifiers.command && !input.modifiers.alt;
let tool = if plain { Tool::ALL.into_iter().find(|tool| input.key_pressed(tool.key())) } else { None };
(redo, undo, plain && input.key_pressed(Key::Enter), plain && (input.key_pressed(Key::Delete) || input.key_pressed(Key::Backspace)), tool)
});
if redo {
state.redo();
} else if undo {
state.undo();
}
if delete {
text::delete_selected(state);
}
if let Some(tool) = tool {
state.pick(tool);
}
if done {
commit_label(state);
state.active = false;
}
}
@@ -16,18 +16,24 @@
mod banner;
mod draw;
mod io;
mod keys;
mod palette;
mod place;
mod text;
mod tool;
mod tools;
use std::time::Instant;
use egui::{Color32, Pos2, Rect, Ui, Vec2};
use notescraft_annotate::{Annotations, ColorRef, Edit, History, Item, Point, StrokeTool};
use notescraft_annotate::{Annotations, ColorRef, Edit, History, Item, Point};
use notescraft_engine::Session;
use crate::text::image_annotate as strings;
use crate::theme::{Tokens, color};
pub use text::Label;
pub use tool::Tool;
use super::transform::Placement;
@@ -38,85 +44,6 @@ pub const WIDTHS: [f32; 3] = [2.0, 4.0, 8.0];
/// How many theme colours a mark can take.
pub const COLOURS: usize = 8;
/// What the user can draw with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tool {
/// A freehand pen stroke.
#[default]
Pen,
/// A translucent freehand highlighter stroke.
Highlighter,
/// A straight line.
Line,
/// A straight line with a head.
Arrow,
/// A rectangle between two corners.
Rectangle,
/// An ellipse inside the box of two corners.
Ellipse,
/// A text label.
Text,
/// The eraser, which takes a whole mark away.
Eraser,
}
impl Tool {
/// The freehand tool this one is, when it is one.
#[must_use]
pub(super) fn stroke(self) -> Option<StrokeTool> {
match self {
Self::Pen => Some(StrokeTool::Pen),
Self::Highlighter => Some(StrokeTool::Highlighter),
_ => None,
}
}
/// The name of the tool for the tool bar.
#[must_use]
pub(super) fn label(self) -> &'static str {
match self {
Self::Pen => strings::pen(),
Self::Highlighter => strings::highlighter(),
Self::Line => strings::line(),
Self::Arrow => strings::arrow(),
Self::Rectangle => strings::rectangle(),
Self::Ellipse => strings::ellipse(),
Self::Text => strings::text(),
Self::Eraser => strings::eraser(),
}
}
/// A stable name of the tool, for the id of its button.
#[must_use]
pub(super) fn id(self) -> &'static str {
match self {
Self::Pen => "tool-pen",
Self::Highlighter => "tool-highlighter",
Self::Line => "tool-line",
Self::Arrow => "tool-arrow",
Self::Rectangle => "tool-rectangle",
Self::Ellipse => "tool-ellipse",
Self::Text => "tool-text",
Self::Eraser => "tool-eraser",
}
}
/// The icon of the tool for the tool bar.
#[must_use]
pub(super) fn icon(self) -> &'static str {
match self {
Self::Pen => "pen",
Self::Highlighter => "highlighter",
Self::Line => "minus",
Self::Arrow => "move-up-right",
Self::Rectangle => "square",
Self::Ellipse => "circle",
Self::Text => "type",
Self::Eraser => "eraser",
}
}
}
/// What the viewer tells the draw mode about the picture on the canvas.
#[derive(Debug, Clone, Default)]
pub struct Picture {
@@ -140,15 +67,6 @@ pub enum Notice {
},
}
/// A text label being typed.
#[derive(Debug, Clone, PartialEq)]
pub struct Label {
/// The point the label stands on.
pub at: Point,
/// The text of the label.
pub text: String,
}
/// The marks of one picture and the tools that make them.
#[derive(Debug)]
pub struct State {
@@ -168,6 +86,14 @@ pub struct State {
draft: Option<Item>,
/// The label being typed.
label: Option<Label>,
/// The index of the label that is selected.
selected: Option<usize>,
/// The label being carried to another place.
moving: Option<text::Moving>,
/// Where a press on empty picture will open the editor once the pointer is released.
placing: Option<Point>,
/// The box the editor of the label took on the canvas last frame.
editor: Option<Rect>,
/// The state of the read of the sidecar.
reading: io::Reading,
/// What the sidecar looked like when it was read last.
@@ -199,6 +125,10 @@ impl Default for State {
history: History::default(),
draft: None,
label: None,
selected: None,
moving: None,
placing: None,
editor: None,
reading: io::Reading::Waiting,
stamp: None,
notice: None,
@@ -262,6 +192,14 @@ impl State {
true
}
/// Takes `tool` in hand: the label being typed is kept, the mark being made and the selection are let go.
fn pick(&mut self, tool: Tool) {
tools::commit_label(self);
self.draft = None;
self.selected = None;
self.tool = tool;
}
/// Marks the state as changed; the frame that follows writes the sidecar.
fn touch(&mut self) {
self.dirty = true;
@@ -333,6 +271,9 @@ pub fn show(ui: &mut Ui, tokens: &Tokens, session: &Session, state: &mut State,
let area = Rect::from_min_max(canvas.min, Pos2::new(canvas.max.x, zone.min.y.max(canvas.min.y)));
tools::handle(ui, state, picture, canvas, area);
if state.active && !session.is_read_only() {
// The keys first: Enter that closes a label must not also close the draw mode.
keys::handle(ui.ctx(), state);
text::editor(ui, tokens, state, picture, canvas);
palette::bar(ui, tokens, state, zone);
}
banner::show(ui, tokens, state, canvas, session.is_read_only());
@@ -14,7 +14,9 @@ use crate::theme::{Tokens, color};
use super::State;
use super::Tool;
use super::WIDTHS;
use super::keys::{REDO, UNDO};
use super::palette;
use super::text::{TEXT_SIZES, size_of};
use super::tools::commit_label;
/// First guess of the height of the band on a wide window: card, status chip and gaps, points.
@@ -33,14 +35,14 @@ const RING_GAP: f32 = 3.0;
const RING_WIDTH: f32 = 2.0;
/// Radius of the smallest and the biggest width dot, points.
const DOT_RADII: [f32; 3] = [2.0, 3.5, 5.5];
/// Width of the field of a text label, points.
const LABEL_WIDTH: f32 = 160.0;
/// Smallest size of the letter that stands for a font size in the width control, points.
const LETTER_MIN: f32 = 11.0;
/// Largest size of that letter, points.
const LETTER_MAX: f32 = 18.0;
/// Size of the text of the status chip, points.
const STATUS_TEXT: f32 = 12.0;
/// Side of the status icon, points.
const STATUS_ICON: f32 = 14.0;
/// Every tool of the draw mode, in the order of the bar.
const ALL_TOOLS: [Tool; 8] = [Tool::Pen, Tool::Highlighter, Tool::Line, Tool::Arrow, Tool::Rectangle, Tool::Ellipse, Tool::Text, Tool::Eraser];
/// The height the draw bar takes at the bottom of the tab: what it measured last frame, since a
/// card that wraps onto more rows is taller than the first guess.
@@ -68,7 +70,6 @@ pub fn zone(ui: &Ui) -> Rect {
pub fn bar(ui: &mut Ui, tokens: &Tokens, state: &mut State, zone: Rect) {
let mut child = ui.new_child(UiBuilder::new().max_rect(zone).id_salt("notescraft-image-annotate-bar").layout(Layout::top_down(Align::Min)));
child.with_layout(Layout::top_down(Align::Center), |ui| {
label_field(ui, state);
status(ui, tokens, state);
});
bar::card_with(&mut child, tokens, true, |ui| {
@@ -96,11 +97,10 @@ pub fn bar(ui: &mut Ui, tokens: &Tokens, state: &mut State, zone: Rect) {
/// The tools, as icon buttons; the tool in hand takes the accent tone.
fn tools(ui: &mut Ui, tokens: &Tokens, state: &mut State) {
for tool in ALL_TOOLS {
for tool in Tool::ALL {
let look = if state.tool == tool { Look::Primary } else { Look::Plain };
if bar::button(ui, tokens, tool.id(), Some(tool.icon()), None, tool.label(), look).clicked() {
state.tool = tool;
state.draft = None;
if bar::button(ui, tokens, tool.id(), Some(tool.icon()), None, &tool.tip(), look).clicked() {
state.pick(tool);
}
}
}
@@ -135,8 +135,16 @@ fn widths(ui: &mut Ui, tokens: &Tokens, state: &mut State, size: f32) {
ui.painter().rect_filled(segment.shrink(2.0), tokens.radius_small(), color(colors.accent));
}
let ink = if chosen { color(colors.on_accent) } else { color(colors.text) };
ui.painter().circle_filled(segment.center(), DOT_RADII.get(index).copied().unwrap_or(DOT_RADII[0]), ink);
if named(response, &format!("{} {}", strings::width(), index + 1)).clicked() {
let text_tool = state.tool == Tool::Text;
if text_tool {
// The same three choices read as three sizes of letter while a label is made.
let letter = LETTER_MIN + (LETTER_MAX - LETTER_MIN) * (size_of(index) - TEXT_SIZES[0]) / (TEXT_SIZES[2] - TEXT_SIZES[0]);
ui.painter().text(segment.center(), egui::Align2::CENTER_CENTER, "A", egui::FontId::new(letter, crate::fonts::semibold()), ink); // i18n-ok: a specimen letter, not copy
} else {
ui.painter().circle_filled(segment.center(), DOT_RADII.get(index).copied().unwrap_or(DOT_RADII[0]), ink);
}
let name = if text_tool { strings::text_size() } else { strings::width() };
if named(response, &format!("{name} {}", index + 1)).clicked() {
state.width = index;
}
}
@@ -144,14 +152,17 @@ fn widths(ui: &mut Ui, tokens: &Tokens, state: &mut State, size: f32) {
/// Undo, redo, Clear and Done.
fn actions(ui: &mut Ui, tokens: &Tokens, state: &mut State) {
if bar::button(ui, tokens, "undo", Some("undo-2"), None, strings::undo(), Look::Plain).clicked() {
let (undo_tip, redo_tip) =
(format!("{} ({})", strings::undo(), ui.ctx().format_shortcut(&UNDO)), format!("{} ({})", strings::redo(), ui.ctx().format_shortcut(&REDO)));
if bar::button(ui, tokens, "undo", Some("undo-2"), None, &undo_tip, Look::Plain).clicked() {
state.undo();
}
if bar::button(ui, tokens, "redo", Some("redo-2"), None, strings::redo(), Look::Plain).clicked() {
if bar::button(ui, tokens, "redo", Some("redo-2"), None, &redo_tip, Look::Plain).clicked() {
state.redo();
}
clear_button(ui, tokens, state);
if bar::button(ui, tokens, "done", None, Some(strings::done()), strings::done(), Look::Primary).clicked() {
if bar::button(ui, tokens, "done", None, Some(strings::done()), &format!("{} ({})", strings::done(), egui::Key::Enter.name()), Look::Primary).clicked() {
commit_label(state);
state.active = false;
}
}
@@ -163,38 +174,28 @@ fn named(response: egui::Response, label: &str) -> egui::Response {
response
}
/// The field of a text label, when one is being typed, over the card.
fn label_field(ui: &mut Ui, state: &mut State) {
let Some(mut label) = state.label.take() else { return };
let mut add = false;
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut label.text).hint_text(strings::label()).desired_width(LABEL_WIDTH));
add = ui.button(strings::add_label()).clicked();
});
let enter = ui.input(|input| input.key_pressed(egui::Key::Enter));
state.label = Some(label);
if add || enter {
commit_label(state);
}
}
/// The chip over the card: a check and "Saved", a spinner and "Saving…", or why a save failed.
/// The chip over the card: a check and "Saved", a spinner and "Saving…", or why a save failed. It
/// stands in the middle of the tab, right over the card.
fn status(ui: &mut Ui, tokens: &Tokens, state: &State) {
let colors = &tokens.theme.colors;
ui.add_space(4.0);
ui.horizontal(|ui| {
if let Some(error) = &state.toast {
ui.label(RichText::new(error.clone()).size(STATUS_TEXT).color(color(colors.danger)));
return;
}
if state.dirty {
let font = egui::FontId::proportional(STATUS_TEXT);
let (text, ink) = match (&state.toast, state.dirty) {
(Some(error), _) => (error.clone(), color(colors.danger)),
(None, true) => (strings::saving().to_owned(), color(colors.text_muted)),
(None, false) => (strings::saved_short().to_owned(), color(colors.text_muted)),
};
let galley = ui.painter().layout_no_wrap(text, font, ink);
let icon = state.toast.is_none();
let width = galley.size().x + if icon { STATUS_ICON + ui.spacing().item_spacing.x } else { 0.0 };
ui.allocate_ui_with_layout(Vec2::new(width, STATUS_ICON.max(galley.size().y)), Layout::left_to_right(Align::Center), |ui| {
if icon && state.dirty {
ui.add(egui::Spinner::new().size(STATUS_ICON).color(color(colors.text_muted)));
} else {
} else if icon {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(STATUS_ICON), Sense::hover());
crate::icons::paint(ui, "check", rect, color(colors.success));
}
let text = if state.dirty { strings::saving() } else { strings::saved_short() };
ui.label(RichText::new(text).size(STATUS_TEXT).color(color(colors.text_muted)));
ui.label(RichText::new(galley.text()).size(STATUS_TEXT).color(ink));
});
}
@@ -14,6 +14,15 @@ use super::Picture;
/// How far the eraser reaches around the finger, in points.
const ERASER_REACH: f32 = 10.0;
/// The angle a held Shift snaps a line to, in degrees.
const SNAP_DEGREES: f32 = 15.0;
/// The half angle of the head of an arrow, in degrees.
const HEAD_DEGREES: f32 = 25.0;
/// The length of the head of an arrow, as a multiple of the width of the line.
const HEAD_LENGTH: f32 = 4.0;
/// The point of the picture under a point of the canvas.
#[must_use]
pub fn to_image(picture: &Picture, canvas: Rect, at: Pos2) -> Point {
@@ -50,3 +59,116 @@ pub fn reach(placement: &Placement) -> f32 {
}
ERASER_REACH / zoom
}
/// The box on the canvas the picture covers, turned or not.
#[must_use]
pub fn picture_rect(picture: &Picture, canvas: Rect) -> Rect {
let extent = picture.size.unwrap_or(Vec2::ZERO);
let corners = [Point::new(0.0, 0.0), Point::new(extent.x, 0.0), Point::new(extent.x, extent.y), Point::new(0.0, extent.y)];
let mut area = Rect::NOTHING;
for corner in corners {
area.extend_with(to_screen(picture, canvas, corner));
}
area
}
/// A point of the picture held inside the picture.
#[must_use]
pub fn clamp_to_picture(picture: &Picture, at: Point) -> Point {
let extent = picture.size.unwrap_or(Vec2::ZERO);
Point::new(at.x.clamp(0.0, extent.x.max(0.0)), at.y.clamp(0.0, extent.y.max(0.0)))
}
/// `to` turned about `from` to the nearest multiple of 15 degrees, at the same distance.
#[must_use]
pub fn snap_angle(from: Point, to: Point) -> Point {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let length = dx.hypot(dy);
if length <= f32::EPSILON || !length.is_finite() {
return to;
}
let step = SNAP_DEGREES.to_radians();
let snapped = (dy.atan2(dx) / step).round() * step;
Point::new(from.x + length * snapped.cos(), from.y + length * snapped.sin())
}
/// The ends of the two barbs of an arrow head at `end`, for a line that comes from `start`
/// with the given width on the canvas; `None` when the line has no length.
#[must_use]
pub fn arrow_barbs(start: Pos2, end: Pos2, width: f32) -> Option<[Pos2; 2]> {
let delta = end - start;
let length = delta.length();
if length <= f32::EPSILON || !length.is_finite() {
return None;
}
let back = -delta / length;
let head = width * HEAD_LENGTH;
let (sine, cosine) = HEAD_DEGREES.to_radians().sin_cos();
let barb = |side: f32| end + Vec2::new(back.x * cosine - back.y * sine * side, back.x * sine * side + back.y * cosine) * head;
Some([barb(1.0), barb(-1.0)])
}
#[cfg(test)]
mod tests {
use super::*;
/// A picture 200 x 100 on a 800 x 600 canvas with the given placement.
fn picture(zoom: f32, offset: Vec2, rotation: f32) -> (Picture, Rect) {
let placement = Placement { zoom, offset, rotation };
(Picture { size: Some(Vec2::new(200.0, 100.0)), placement }, Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)))
}
#[test]
fn screen_and_image_round_trip_for_every_turn_zoom_and_pan() {
for turns in 0..4_u8 {
for (zoom, offset) in [(1.0, Vec2::ZERO), (4.0, Vec2::new(30.0, -20.0)), (0.5, Vec2::new(-100.0, 80.0))] {
let (picture, canvas) = picture(zoom, offset, f32::from(turns) * std::f32::consts::FRAC_PI_2);
for point in [Point::new(0.0, 0.0), Point::new(200.0, 100.0), Point::new(37.0, 71.0)] {
let back = to_image(&picture, canvas, to_screen(&picture, canvas, point));
assert!((back.x - point.x).abs() < 1e-2 && (back.y - point.y).abs() < 1e-2, "turn {turns} zoom {zoom}: {point:?} came back as {back:?}");
}
}
}
}
#[test]
fn the_picture_rect_follows_zoom_pan_and_a_quarter_turn() {
let (flat, canvas) = picture(2.0, Vec2::new(10.0, 0.0), 0.0);
let rect = picture_rect(&flat, canvas);
assert!((rect.width() - 400.0).abs() < 1e-3 && (rect.height() - 200.0).abs() < 1e-3);
assert!((rect.center().x - 410.0).abs() < 1e-3 && (rect.center().y - 300.0).abs() < 1e-3);
let (turned, canvas) = picture(2.0, Vec2::ZERO, std::f32::consts::FRAC_PI_2);
let rect = picture_rect(&turned, canvas);
assert!((rect.width() - 200.0).abs() < 1e-3 && (rect.height() - 400.0).abs() < 1e-3, "{rect:?}");
}
#[test]
fn a_point_is_held_inside_the_picture() {
let (picture, _) = picture(1.0, Vec2::ZERO, 0.0);
assert_eq!(clamp_to_picture(&picture, Point::new(-30.0, 500.0)), Point::new(0.0, 100.0));
assert_eq!(clamp_to_picture(&picture, Point::new(20.0, 30.0)), Point::new(20.0, 30.0));
}
#[test]
fn shift_snaps_to_fifteen_degrees_and_keeps_the_length() {
let from = Point::new(10.0, 10.0);
let snapped = snap_angle(from, Point::new(10.0 + 100.0, 10.0 + 3.0));
assert!((snapped.x - 110.0).abs() < 0.1 && (snapped.y - 10.0).abs() < 1e-3, "{snapped:?}");
let diagonal = snap_angle(from, Point::new(60.0, 62.0));
assert!(((diagonal.x - 10.0) - (diagonal.y - 10.0)).abs() < 0.2, "{diagonal:?}");
assert_eq!(snap_angle(from, from), from);
}
#[test]
fn the_arrow_head_has_two_barbs_of_four_widths_at_twenty_five_degrees() {
let end = Pos2::new(100.0, 0.0);
let barbs = arrow_barbs(Pos2::new(0.0, 0.0), end, 3.0).expect("a line with length has a head");
for barb in barbs {
assert!(((barb - end).length() - 12.0).abs() < 1e-3, "barb length {}", (barb - end).length());
let angle = (barb - end).angle().abs().to_degrees();
assert!((angle - 180.0 + 25.0).abs() < 1e-2, "the barb leans back 25 degrees, got {angle}");
}
assert!(barbs[0].y * barbs[1].y < 0.0, "one barb on each side");
assert_eq!(arrow_barbs(end, end, 3.0), None);
}
}
@@ -0,0 +1,308 @@
//! Text labels: placing one where the picture was clicked, typing it in place, selecting,
//! moving, re-typing and deleting a label that is already there.
//!
//! The Text tool does it all. A press on empty picture opens an editor at that point; a press on
//! a label selects it and a drag moves it; a double click types it again; Delete takes it away.
use egui::{FontFamily, FontId, Frame, Id, Margin, Rect, Stroke, TextEdit, Ui, UiBuilder, Vec2};
use notescraft_annotate::{ColorRef, Edit, Item, Point};
use crate::theme::{Tokens, color};
use super::place::{clamp_to_picture, to_screen};
use super::{Picture, State, colour_of};
/// The font sizes of a label, in the pixels of the picture, one for each width of the bar.
pub const TEXT_SIZES: [f32; 3] = [14.0, 20.0, 28.0];
/// Space between the text of the editor and its border, in points.
const EDITOR_PAD: i8 = 4;
/// Width of the border of the editor, in points.
const EDITOR_BORDER: f32 = 1.5;
/// The least width of the editor, as a multiple of the font size on the canvas.
const EDITOR_MIN_EM: f32 = 3.0;
/// Room the caret needs after the last letter, in points.
const CARET_ROOM: f32 = 6.0;
/// A text label being typed.
#[derive(Debug, Clone, PartialEq)]
pub struct Label {
/// The point the label stands on, in the pixels of the picture.
pub at: Point,
/// The text of the label.
pub text: String,
/// The colour of the label.
pub color: ColorRef,
/// The font size, in the pixels of the picture.
pub size: f32,
/// The index of the mark it re-types, when it is not a new one.
pub index: Option<usize>,
/// Whether the editor still has to take the focus.
fresh: bool,
}
/// A label being dragged to another place.
#[derive(Debug, Clone, PartialEq)]
pub struct Moving {
/// The index of the label.
index: usize,
/// The distance from the anchor of the label to the point that was grabbed.
grab: Point,
/// The label as it was when the drag began.
from: Item,
}
/// The font size the next label takes, from the width in hand.
#[must_use]
pub fn size_of(width: usize) -> f32 {
TEXT_SIZES.get(width).copied().unwrap_or(TEXT_SIZES[1])
}
/// The index of the label under `at`, when the topmost mark there is one.
fn label_at(state: &State, at: Point, tolerance: f32) -> Option<usize> {
let index = state.notes.hit(at, tolerance)?;
matches!(state.notes.items.get(index), Some(Item::Text { .. })).then_some(index)
}
/// The press of the Text tool at `at`: grabs the label there, or opens an editor on empty picture.
pub(super) fn press(state: &mut State, at: Point, tolerance: f32) {
commit_label(state);
if let Some(index) = label_at(state, at, tolerance)
&& let Some(item) = state.notes.items.get(index).cloned()
&& let Item::Text { at: anchor, .. } = &item
{
state.selected = Some(index);
state.moving = Some(Moving { index, grab: Point::new(at.x - anchor.x, at.y - anchor.y), from: item });
return;
}
state.selected = None;
// The editor opens when the pointer is released: a click that is still going on would take the
// focus away from the field the moment it opens.
state.placing = Some(at);
}
/// The double click of the Text tool at `at`: types the label there again.
pub(super) fn retype(state: &mut State, at: Point, tolerance: f32) {
let Some(index) = label_at(state, at, tolerance) else { return };
let Some(Item::Text { color, size, at, text }) = state.notes.items.get(index).cloned() else { return };
state.moving = None;
state.selected = Some(index);
state.label = Some(Label { at, text, color, size, index: Some(index), fresh: true });
}
/// The drag of the Text tool: carries the label that was grabbed, held inside the picture.
pub(super) fn drag(state: &mut State, picture: &Picture, at: Point) {
let Some(moving) = state.moving.as_ref() else { return };
let target = clamp_to_picture(picture, Point::new(at.x - moving.grab.x, at.y - moving.grab.y));
if let Some(Item::Text { at: anchor, .. }) = state.notes.items.get_mut(moving.index) {
*anchor = target;
}
}
/// The release of the pointer: opens the editor where a press on empty picture was, or keeps a
/// label that was carried as one edit.
pub(super) fn release(state: &mut State) {
if let Some(at) = state.placing.take() {
state.label = Some(Label { at, text: String::new(), color: state.colour(), size: size_of(state.width), index: None, fresh: true });
}
let Some(moving) = state.moving.take() else { return };
let Some(now) = state.notes.items.get(moving.index).cloned() else { return };
if now == moving.from {
return;
}
state.history.push(Edit::Replace(moving.index, moving.from, now));
state.touch();
}
/// Takes the selected label away.
pub(super) fn delete_selected(state: &mut State) -> bool {
let Some(index) = state.selected.take() else { return false };
let Some(item) = state.notes.remove(index) else { return false };
state.history.push(Edit::Remove(index, item));
state.touch();
true
}
/// Keeps the label being typed: a new mark, the new text of one that was there, or, when the text
/// was emptied, the end of that mark. An empty new label makes nothing.
pub(super) fn commit_label(state: &mut State) {
let Some(label) = state.label.take() else { return };
let text = label.text.trim().to_owned();
let item = Item::Text { color: label.color, size: label.size, at: label.at, text: text.clone() };
match label.index {
None if text.is_empty() => {}
None => {
state.notes.items.push(item.clone());
state.history.push(Edit::Add(item));
state.touch();
}
Some(index) if text.is_empty() => {
if let Some(old) = state.notes.remove(index) {
state.history.push(Edit::Remove(index, old));
state.selected = None;
state.touch();
}
}
Some(index) => {
let Some(slot) = state.notes.items.get_mut(index) else { return };
if *slot == item {
return;
}
let old = std::mem::replace(slot, item.clone());
state.history.push(Edit::Replace(index, old, item));
state.touch();
}
}
}
/// Drops the label being typed without keeping it.
pub(super) fn cancel_label(state: &mut State) {
state.label = None;
}
/// Draws the editor of the label being typed, at the point it stands on, and answers its keys:
/// Enter or a click away keeps the label, Escape drops it.
pub(super) fn editor(ui: &mut Ui, tokens: &Tokens, state: &mut State, picture: &Picture, canvas: Rect) {
let Some(label) = state.label.as_mut() else {
state.editor = None;
return;
};
let colors = &tokens.theme.colors;
let font = FontId::new(label.size * picture.placement.zoom, FontFamily::Proportional);
let ink = colour_of(tokens, label.color);
let pad = f32::from(EDITOR_PAD);
let text_width = ui.painter().layout_no_wrap(label.text.clone(), font.clone(), ink).size().x;
let width = (text_width + CARET_ROOM).max(font.size * EDITOR_MIN_EM);
let anchor = to_screen(picture, canvas, label.at);
let place = Rect::from_min_size(anchor - Vec2::splat(pad), Vec2::new(width + pad * 2.0, font.size * 1.4 + pad * 2.0));
let frame = Frame::new()
.fill(color(colors.surface))
.stroke(Stroke::new(EDITOR_BORDER, color(colors.accent)))
.corner_radius(crate::style::radius_u8(tokens.radius_small()))
.inner_margin(Margin::same(EDITOR_PAD));
let mut child = ui.new_child(UiBuilder::new().max_rect(place).id_salt("notescraft-image-annotate-editor"));
let id = Id::new("notescraft-annotate-label-editor");
let output =
TextEdit::singleline(&mut label.text).id(id).font(font).text_color(ink).margin(Margin::ZERO).frame(frame).desired_width(width).show(&mut child);
let response = output.response;
if label.fresh {
response.request_focus();
label.fresh = false;
}
state.editor = Some(response.rect);
let escape = ui.input(|input| input.key_pressed(egui::Key::Escape));
if escape {
cancel_label(state);
} else if response.lost_focus() {
commit_label(state);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A state with one label at (10, 10).
fn with_label() -> State {
let mut state = State::default();
state.notes.items.push(Item::Text { color: ColorRef::Theme(0), size: 20.0, at: Point::new(10.0, 10.0), text: "hello".to_owned() });
state
}
#[test]
fn a_press_on_empty_picture_opens_an_editor_at_the_click() {
let mut state = State::default();
press(&mut state, Point::new(40.0, 50.0), 2.0);
release(&mut state);
let label = state.label.as_ref().expect("an editor is open");
assert_eq!(label.at, Point::new(40.0, 50.0));
assert_eq!(label.size, TEXT_SIZES[1], "the middle width is the middle size");
}
#[test]
fn commit_keeps_the_text_and_an_empty_label_makes_nothing() {
let mut state = State::default();
press(&mut state, Point::new(40.0, 50.0), 2.0);
release(&mut state);
commit_label(&mut state);
assert_eq!(state.notes.items.len(), 0, "an empty label makes nothing");
press(&mut state, Point::new(40.0, 50.0), 2.0);
release(&mut state);
if let Some(label) = state.label.as_mut() {
label.text = " مرحبا 你好 ".to_owned();
}
commit_label(&mut state);
assert!(matches!(state.notes.items.as_slice(), [Item::Text { text, at, .. }] if text == "مرحبا 你好" && *at == Point::new(40.0, 50.0)));
assert!(state.undo(), "the label is one step of the history");
assert_eq!(state.notes.items.len(), 0);
}
#[test]
fn cancel_drops_the_label() {
let mut state = State::default();
press(&mut state, Point::new(1.0, 1.0), 2.0);
release(&mut state);
if let Some(label) = state.label.as_mut() {
label.text = "gone".to_owned();
}
cancel_label(&mut state);
assert_eq!(state.label, None);
assert_eq!(state.notes.items.len(), 0);
}
#[test]
fn a_press_on_a_label_selects_and_a_drag_moves_it_as_one_step() {
let mut state = with_label();
let picture = Picture { size: Some(Vec2::new(200.0, 100.0)), ..Picture::default() };
press(&mut state, Point::new(15.0, 15.0), 2.0);
assert_eq!(state.selected, Some(0));
assert_eq!(state.label, None, "no editor opens on a label");
drag(&mut state, &picture, Point::new(65.0, 45.0));
release(&mut state);
assert!(matches!(state.notes.items.as_slice(), [Item::Text { at, .. }] if *at == Point::new(60.0, 40.0)));
assert!(state.undo());
assert!(matches!(state.notes.items.as_slice(), [Item::Text { at, .. }] if *at == Point::new(10.0, 10.0)));
}
#[test]
fn a_label_cannot_be_dragged_out_of_the_picture() {
let mut state = with_label();
let picture = Picture { size: Some(Vec2::new(200.0, 100.0)), ..Picture::default() };
press(&mut state, Point::new(15.0, 15.0), 2.0);
drag(&mut state, &picture, Point::new(-500.0, 900.0));
release(&mut state);
assert!(matches!(state.notes.items.as_slice(), [Item::Text { at, .. }] if *at == Point::new(0.0, 100.0)));
}
#[test]
fn a_double_click_types_the_label_again_and_emptying_it_removes_it() {
let mut state = with_label();
retype(&mut state, Point::new(15.0, 15.0), 2.0);
if let Some(label) = state.label.as_mut() {
label.text = "changed".to_owned();
}
commit_label(&mut state);
assert!(matches!(state.notes.items.as_slice(), [Item::Text { text, .. }] if text == "changed"));
retype(&mut state, Point::new(15.0, 15.0), 2.0);
if let Some(label) = state.label.as_mut() {
label.text.clear();
}
commit_label(&mut state);
assert_eq!(state.notes.items.len(), 0);
assert!(state.undo() && state.undo());
assert!(matches!(state.notes.items.as_slice(), [Item::Text { text, .. }] if text == "hello"));
}
#[test]
fn delete_takes_the_selected_label_away() {
let mut state = with_label();
press(&mut state, Point::new(15.0, 15.0), 2.0);
release(&mut state);
assert!(delete_selected(&mut state));
assert_eq!(state.notes.items.len(), 0);
assert!(!delete_selected(&mut state), "nothing is selected any more");
}
}
@@ -0,0 +1,108 @@
//! The tools of the draw mode: their names, ids, icons and hotkeys.
use notescraft_annotate::StrokeTool;
use crate::text::image_annotate as strings;
/// What the user can draw with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tool {
/// A freehand pen stroke.
#[default]
Pen,
/// A translucent freehand highlighter stroke.
Highlighter,
/// A straight line.
Line,
/// A straight line with a head.
Arrow,
/// A rectangle between two corners.
Rectangle,
/// An ellipse inside the box of two corners.
Ellipse,
/// A text label.
Text,
/// The eraser, which takes a whole mark away.
Eraser,
}
impl Tool {
/// Every tool, in the order of the bar.
pub(super) const ALL: [Self; 8] = [Self::Pen, Self::Highlighter, Self::Line, Self::Arrow, Self::Rectangle, Self::Ellipse, Self::Text, Self::Eraser];
/// The single key that picks the tool.
#[must_use]
pub(super) fn key(self) -> egui::Key {
match self {
Self::Pen => egui::Key::P,
Self::Highlighter => egui::Key::H,
Self::Line => egui::Key::L,
Self::Arrow => egui::Key::A,
Self::Rectangle => egui::Key::R,
Self::Ellipse => egui::Key::O,
Self::Text => egui::Key::T,
Self::Eraser => egui::Key::E,
}
}
/// The name of the tool with its key, for the hover tip.
#[must_use]
pub(super) fn tip(self) -> String {
format!("{} ({})", self.label(), self.key().name())
}
/// The freehand tool this one is, when it is one.
#[must_use]
pub(super) fn stroke(self) -> Option<StrokeTool> {
match self {
Self::Pen => Some(StrokeTool::Pen),
Self::Highlighter => Some(StrokeTool::Highlighter),
_ => None,
}
}
/// The name of the tool for the tool bar.
#[must_use]
pub(super) fn label(self) -> &'static str {
match self {
Self::Pen => strings::pen(),
Self::Highlighter => strings::highlighter(),
Self::Line => strings::line(),
Self::Arrow => strings::arrow(),
Self::Rectangle => strings::rectangle(),
Self::Ellipse => strings::ellipse(),
Self::Text => strings::text(),
Self::Eraser => strings::eraser(),
}
}
/// A stable name of the tool, for the id of its button.
#[must_use]
pub(super) fn id(self) -> &'static str {
match self {
Self::Pen => "tool-pen",
Self::Highlighter => "tool-highlighter",
Self::Line => "tool-line",
Self::Arrow => "tool-arrow",
Self::Rectangle => "tool-rectangle",
Self::Ellipse => "tool-ellipse",
Self::Text => "tool-text",
Self::Eraser => "tool-eraser",
}
}
/// The icon of the tool for the tool bar.
#[must_use]
pub(super) fn icon(self) -> &'static str {
match self {
Self::Pen => "pen",
Self::Highlighter => "highlighter",
Self::Line => "minus",
Self::Arrow => "move-up-right",
Self::Rectangle => "square",
Self::Ellipse => "circle",
Self::Text => "type",
Self::Eraser => "eraser",
}
}
}
@@ -2,13 +2,16 @@
//! a release, and the marks they make.
//!
//! One finger draws, two fingers pan and zoom the picture: while a second
//! finger is on the screen the tools keep still.
//! finger is on the screen the tools keep still. Every point a tool takes is
//! held inside the picture.
use egui::{Pos2, Rect, Sense, Ui, UiBuilder};
use egui::{PointerButton, Pos2, Rect, Sense, Ui, UiBuilder};
use notescraft_annotate::{Edit, Item, Point, simplify};
use super::place::{reach, to_image};
use super::{Label, Picture, State, Tool};
use super::place::{clamp_to_picture, picture_rect, reach, snap_angle, to_image};
use super::{Picture, State, Tool, text};
pub(super) use text::commit_label;
/// The most points a freehand stroke keeps.
const MAX_STROKE_POINTS: usize = 4_000;
@@ -16,8 +19,8 @@ const MAX_STROKE_POINTS: usize = 4_000;
/// How far a freehand stroke is straightened, in the pixels of the picture.
const SIMPLIFY_TOLERANCE: f32 = 1.5;
/// The size of a text label, in the pixels of the picture.
const LABEL_SIZE: f32 = 24.0;
/// The least a line or a box must be dragged to count as a mark, in points of the canvas.
const MIN_DRAG: f32 = 3.0;
/// Handles the pointer over the picture.
///
@@ -35,8 +38,9 @@ pub fn handle(ui: &mut Ui, state: &mut State, picture: &Picture, canvas: Rect, a
if pointer.touching {
return;
}
let tolerance = reach(&picture.placement);
if pointer.pressed {
start(state, picture, canvas, area, pointer.origin);
start(state, picture, canvas, area, pointer.origin, tolerance);
}
if pointer.down {
if let Some(at) = pointer.at
@@ -45,44 +49,57 @@ pub fn handle(ui: &mut Ui, state: &mut State, picture: &Picture, canvas: Rect, a
let image = to_image(picture, canvas, at);
match state.tool {
Tool::Eraser => {
erase(state, image, reach(&picture.placement));
erase(state, image, tolerance);
}
_ => extend(state, image),
Tool::Text => text::drag(state, picture, image),
_ => extend(state, picture, clamp_to_picture(picture, image), pointer.shift),
}
}
} else if state.draft.is_some() {
finish(state);
} else {
if state.draft.is_some() {
finish(state, MIN_DRAG / picture.placement.zoom);
}
text::release(state);
}
if pointer.double
&& state.tool == Tool::Text
&& let Some(at) = pointer.at
{
text::retype(state, to_image(picture, canvas, at), tolerance);
}
}
/// Starts a mark where the pointer was pressed.
fn start(state: &mut State, picture: &Picture, canvas: Rect, area: Rect, origin: Option<Pos2>) {
fn start(state: &mut State, picture: &Picture, canvas: Rect, area: Rect, origin: Option<Pos2>, tolerance: f32) {
let Some(origin) = origin else { return };
if !area.contains(origin) {
if !area.contains(origin) || state.editor.is_some_and(|editor| editor.contains(origin)) {
return;
}
let at = to_image(picture, canvas, origin);
match state.tool {
Tool::Text => {
state.label = Some(Label { at, text: String::new() });
}
Tool::Eraser => {}
_ => begin(state, at),
Tool::Text if picture_rect(picture, canvas).contains(origin) => text::press(state, at, tolerance),
Tool::Text | Tool::Eraser => {}
_ => begin(state, clamp_to_picture(picture, at)),
}
}
/// What the pointer does this frame.
#[expect(clippy::struct_excessive_bools, reason = "a snapshot of the input of one frame: each flag is its own fact")]
struct Pointer {
/// Whether a button is down.
down: bool,
/// Whether a button was pressed this frame.
pressed: bool,
/// Whether the press of this frame was the second of a double click.
double: bool,
/// Where the press began.
origin: Option<Pos2>,
/// Where the pointer is now.
at: Option<Pos2>,
/// Whether more than one finger is on the picture.
touching: bool,
/// Whether Shift is held.
shift: bool,
}
/// Reads the pointer of the frame.
@@ -90,9 +107,11 @@ fn pointer(ui: &Ui) -> Pointer {
ui.input(|input| Pointer {
down: input.pointer.any_down(),
pressed: input.pointer.any_pressed(),
double: input.pointer.button_double_clicked(PointerButton::Primary),
origin: input.pointer.press_origin(),
at: input.pointer.latest_pos(),
touching: input.multi_touch().is_some(),
shift: input.modifiers.shift,
})
}
@@ -109,8 +128,8 @@ fn begin(state: &mut State, at: Point) {
};
}
/// Extends the mark being made to `at`.
fn extend(state: &mut State, at: Point) {
/// Extends the mark being made to `at`, a point inside the picture; Shift snaps a line to 15 degrees.
fn extend(state: &mut State, picture: &Picture, at: Point, shift: bool) {
let Some(draft) = state.draft.as_mut() else { return };
match draft {
Item::Stroke { points, .. } => {
@@ -122,15 +141,28 @@ fn extend(state: &mut State, at: Point) {
points.push(at);
}
}
Item::Line { to, .. } => *to = at,
Item::Line { from, to, .. } => *to = if shift { clamp_to_picture(picture, snap_angle(*from, at)) } else { at },
Item::Rect { max, .. } | Item::Ellipse { max, .. } => *max = at,
Item::Text { .. } => {}
}
}
/// Finishes the mark being made and keeps it.
fn finish(state: &mut State) {
/// Whether a line or a box was dragged too little to be a mark: a click makes nothing.
fn is_degenerate(item: &Item, least: f32) -> bool {
let span = |first: Point, second: Point| (second.x - first.x).hypot(second.y - first.y) < least;
match item {
Item::Line { from, to, .. } => span(*from, *to),
Item::Rect { min, max, .. } | Item::Ellipse { min, max, .. } => span(*min, *max),
Item::Stroke { .. } | Item::Text { .. } => false,
}
}
/// Finishes the mark being made and keeps it; a line or a box dragged less than `least` is dropped.
fn finish(state: &mut State, least: f32) {
let Some(draft) = state.draft.take() else { return };
if is_degenerate(&draft, least) {
return;
}
let item = match draft {
Item::Stroke { tool, color, width, points } => Item::Stroke { tool, color, width, points: simplify(&points, SIMPLIFY_TOLERANCE) },
other => other,
@@ -145,19 +177,70 @@ fn erase(state: &mut State, at: Point, tolerance: f32) -> bool {
let Some(index) = state.notes.hit(at, tolerance) else { return false };
let Some(item) = state.notes.remove(index) else { return false };
state.history.push(Edit::Remove(index, item));
state.selected = None;
state.touch();
true
}
/// Keeps the label being typed as a mark.
pub(super) fn commit_label(state: &mut State) {
let Some(label) = state.label.take() else { return };
let text = label.text.trim();
if text.is_empty() {
return;
#[cfg(test)]
mod tests {
use super::*;
use egui::Vec2;
use notescraft_annotate::ColorRef;
/// A 200 x 100 picture at 100% (nothing turned).
fn picture() -> Picture {
Picture { size: Some(Vec2::new(200.0, 100.0)), ..Picture::default() }
}
/// A state with the tool in hand and a draft begun at (20, 20).
fn begun(tool: Tool) -> State {
let mut state = State { tool, ..State::default() };
begin(&mut state, Point::new(20.0, 20.0));
state
}
#[test]
fn a_line_goes_from_the_press_to_the_pointer_and_stays_in_the_picture() {
let mut state = begun(Tool::Line);
extend(&mut state, &picture(), clamp_to_picture(&picture(), Point::new(900.0, -40.0)), false);
assert!(matches!(&state.draft, Some(Item::Line { from, to, arrow: false, .. }) if *from == Point::new(20.0, 20.0) && *to == Point::new(200.0, 0.0)));
}
#[test]
fn the_arrow_tool_makes_a_line_with_a_head() {
let mut state = begun(Tool::Arrow);
extend(&mut state, &picture(), Point::new(120.0, 20.0), false);
finish(&mut state, 1.0);
assert!(matches!(state.notes.items.as_slice(), [Item::Line { arrow: true, to, .. }] if *to == Point::new(120.0, 20.0)));
}
#[test]
fn shift_snaps_the_line_to_fifteen_degrees() {
let mut state = begun(Tool::Line);
extend(&mut state, &picture(), Point::new(120.0, 23.0), true);
assert!(matches!(&state.draft, Some(Item::Line { to, .. }) if (to.y - 20.0).abs() < 0.1 && to.x > 110.0));
}
#[test]
fn a_click_without_a_drag_makes_nothing_but_a_dot_still_does() {
for tool in [Tool::Line, Tool::Arrow, Tool::Rectangle, Tool::Ellipse] {
let mut state = begun(tool);
finish(&mut state, 1.0);
assert_eq!(state.notes.items.len(), 0, "{tool:?}");
}
let mut state = begun(Tool::Pen);
finish(&mut state, 1.0);
assert_eq!(state.notes.items.len(), 1, "a pen click is a dot");
}
#[test]
fn the_eraser_takes_a_line_and_an_arrow() {
let mut state = State::default();
for arrow in [false, true] {
state.notes.items.push(Item::Line { color: ColorRef::Theme(0), width: 2.0, from: Point::new(10.0, 10.0), to: Point::new(90.0, 10.0), arrow });
assert!(erase(&mut state, Point::new(50.0, 11.0), 3.0));
}
assert_eq!(state.notes.items.len(), 0);
}
let item = Item::Text { color: state.colour(), size: LABEL_SIZE, at: label.at, text: text.to_owned() };
state.notes.items.push(item.clone());
state.history.push(Edit::Add(item));
state.touch();
}
@@ -55,7 +55,10 @@ pub fn canvas(ui: &mut Ui, tokens: &Tokens, view: &mut View, reserve: f32) -> Re
if response.double_clicked() {
toggle_fit(view, rect);
}
answer_keys(child.ctx(), view, rect);
// A key typed into a text field (a label of the draw mode) is not a shortcut of the lightbox.
if !child.ctx().text_edit_focused() {
answer_keys(child.ctx(), view, rect);
}
let compact = crate::style::compact::is_compact(child.ctx());
chevrons(&child, tokens, view, rect, compact);
paint_answer(&mut child, tokens, view, rect);
@@ -203,4 +203,27 @@ mod tests {
fn a_size_of_zero_is_not_a_size() {
assert_eq!(png_dimensions(&png(0, 10)), None);
}
/// An ICO of two PNG pictures, 16x16 and 64x64 (the layout browsers' favicons have).
fn two_size_ico() -> Vec<u8> {
let (small, large) = (encoded(image::ImageFormat::Png, 16, 16), encoded(image::ImageFormat::Png, 64, 64));
let mut ico = vec![0, 0, 1, 0, 2, 0];
let first = 6 + 2 * 16;
for (side, png, at) in [(16_u8, &small, first), (64_u8, &large, first + small.len())] {
ico.extend_from_slice(&[side, side, 0, 0, 1, 0, 32, 0]);
ico.extend_from_slice(&u32::try_from(png.len()).expect("small").to_le_bytes());
ico.extend_from_slice(&u32::try_from(at).expect("small").to_le_bytes());
}
ico.extend_from_slice(&small);
ico.extend_from_slice(&large);
ico
}
#[test]
fn an_ico_tells_the_size_of_and_decodes_its_largest_picture() {
let ico = two_size_ico();
assert_eq!(dimensions(&ico), Some((64, 64)));
let decoded = egui_extras::image::load_image_bytes(&ico).expect("the ICO decodes");
assert_eq!(decoded.size, [64, 64]);
}
}
@@ -0,0 +1,75 @@
//! Icons (`.ico`): the largest picture of the file.
//!
//! The `image` crate decodes an ICO only when its PNG entries are RGBA, and favicons often are not, so a
//! PNG entry is handed over as a plain PNG, which every colour type decodes. A bitmap entry stays as it is.
/// Bytes of the ICO header: reserved, type (1) and the number of pictures.
const HEADER: usize = 6;
/// Bytes of one directory entry.
const ENTRY: usize = 16;
/// Most directory entries read.
const MAX_ENTRIES: usize = 256;
/// The side an entry byte of 0 stands for.
const SIDE_ZERO: u32 = 256;
/// The first bytes of every PNG.
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
/// The bytes to decode for a file: the PNG of the largest picture of an ICO, else the file itself.
#[must_use]
pub fn picture_bytes(bytes: &[u8]) -> &[u8] {
largest_png(bytes).unwrap_or(bytes)
}
/// The PNG data of the largest entry of an ICO; `None` when it is not an ICO or the entry is a bitmap.
fn largest_png(bytes: &[u8]) -> Option<&[u8]> {
if bytes.get(..4)? != [0, 0, 1, 0] {
return None;
}
let count = usize::from(u16::from_le_bytes(bytes.get(4..6)?.try_into().ok()?)).min(MAX_ENTRIES);
let mut best: Option<(u32, usize, usize)> = None;
for index in 0..count {
let at = HEADER.checked_add(index.checked_mul(ENTRY)?)?;
let entry = bytes.get(at..at.checked_add(ENTRY)?)?;
let side = |byte: u8| if byte == 0 { SIDE_ZERO } else { u32::from(byte) };
let area = side(*entry.first()?) * side(*entry.get(1)?);
let length = usize::try_from(u32::from_le_bytes(entry.get(8..12)?.try_into().ok()?)).ok()?;
let offset = usize::try_from(u32::from_le_bytes(entry.get(12..16)?.try_into().ok()?)).ok()?;
if best.is_none_or(|(largest, _, _)| area > largest) {
best = Some((area, offset, length));
}
}
let (_, offset, length) = best?;
let data = bytes.get(offset..offset.checked_add(length)?)?;
data.starts_with(&PNG_MAGIC).then_some(data)
}
#[cfg(test)]
mod tests {
use super::picture_bytes;
/// An ICO with one RGB (not RGBA) PNG entry of `side` pixels.
fn rgb_ico(side: u8) -> (Vec<u8>, Vec<u8>) {
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::new(u32::from(side), u32::from(side))).write_to(&mut png, image::ImageFormat::Png).expect("png");
let png = png.into_inner();
let mut ico = vec![0, 0, 1, 0, 1, 0, side, side, 0, 0, 1, 0, 24, 0];
ico.extend_from_slice(&u32::try_from(png.len()).expect("small").to_le_bytes());
ico.extend_from_slice(&22_u32.to_le_bytes());
ico.extend_from_slice(&png);
(ico, png)
}
#[test]
fn an_rgb_png_icon_decodes_through_its_png() {
let (ico, png) = rgb_ico(32);
assert!(egui_extras::image::load_image_bytes(&ico).is_err(), "the crate refuses an RGB entry");
assert_eq!(picture_bytes(&ico), png.as_slice());
assert_eq!(egui_extras::image::load_image_bytes(picture_bytes(&ico)).map(|image| image.size).ok(), Some([32, 32]));
}
#[test]
fn anything_else_passes_through() {
assert_eq!(picture_bytes(b"GIF89a"), b"GIF89a");
assert_eq!(picture_bytes(&[0, 0, 1, 0, 9, 0]), [0, 0, 1, 0, 9, 0], "a truncated directory is left to the decoder");
}
}
@@ -63,6 +63,7 @@ pub fn mime_of(path: &str) -> &'static str {
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
+1 -1
View File
@@ -156,7 +156,7 @@ fn decode(file: &Path, vector: Option<f32>, max_side: usize) -> Result<Picture,
log::warn!("image: {} cannot be read: {error}", file.display());
LoadError::Unreadable
})?;
let image = egui_extras::image::load_image_bytes(&bytes).map_err(|error| {
let image = egui_extras::image::load_image_bytes(super::ico::picture_bytes(&bytes)).map_err(|error| {
log::warn!("image: {} cannot be decoded: {error}", file.display());
LoadError::Undecodable
})?;
@@ -9,12 +9,15 @@ mod annotate;
mod canvas;
mod chrome;
mod header;
mod ico;
mod info;
mod load;
mod shrink;
mod transform;
mod vector;
pub use ico::picture_bytes;
use std::time::{Duration, Instant};
use egui::{Pos2, Rect, RichText, Ui, UiBuilder, Vec2};
+6 -1
View File
@@ -216,7 +216,12 @@ pub fn show_inline(ui: &mut Ui, tokens: &Tokens, src: &str, size: f32) -> Respon
let (rect, response) = ui.allocate_exact_size(egui::vec2(math.width, extent), Sense::hover());
let origin = Pos2::new(rect.left(), rect.top() + math.height);
paint_math(ui, tokens, &math, origin, color(tokens.theme.colors.text));
response
// A formula with an unknown command still draws; the parser says what it did not understand.
let has_invalid = math.items.iter().any(|item| matches!(item, Item::Glyphs { face: Face::Invalid, .. }));
match notescraft_math::parse(src).err().filter(|_| has_invalid) {
Some(error) => response.on_hover_text(error.to_string()),
None => response,
}
}
Err(message) => error_fallback(ui, tokens, src, &message),
}
@@ -0,0 +1,178 @@
//! The audio player's public surface: the commands the views send, the state they read, and the pure
//! rules (clock, vault paths, video hand-over) behind them.
use std::path::{Path, PathBuf};
use egui::{Context, Id};
use super::spectrum::Feed;
/// Seconds `ffplay` needs from spawn to the first sound at normal speed (measured with `-stats`: 0.22
/// to 0.23); the clock waits this long. Calibration knob.
pub const START_LAG: f32 = 0.22;
/// The same at 1.5x and 2x, where the `atempo` filter adds latency (measured: 0.42).
pub const FAST_START_LAG: f32 = 0.4;
/// Speeds from here up use [`FAST_START_LAG`].
const FAST_FROM: f32 = 1.25;
/// The id the pending requests of the views wait under.
#[must_use]
pub fn commands_id() -> Id {
Id::new("notescraft-audio-commands")
}
/// The id the vault folder is published under, for the views that have no session.
#[must_use]
pub fn root_id() -> Id {
Id::new("notescraft-audio-root")
}
/// The file of the vault path `rel` under `root`: only plain folder and file names are accepted.
#[must_use]
pub fn vault_file(root: &Path, rel: &str) -> Option<PathBuf> {
let relative = Path::new(rel);
let plain = relative.components().all(|part| matches!(part, std::path::Component::Normal(_)));
(plain && !rel.is_empty()).then(|| root.join(relative))
}
/// The file of the vault path `rel`, for the views that have no session (the note embeds).
#[must_use]
pub fn embed_file(ctx: &Context, rel: &str) -> Option<PathBuf> {
let root = ctx.data(|data| data.get_temp::<PathBuf>(root_id()))?;
vault_file(&root, rel)
}
/// The id the state of the player is published under.
#[must_use]
pub fn state_id() -> Id {
Id::new("notescraft-audio-state")
}
/// What a view asks of the player.
#[derive(Debug, Clone, PartialEq)]
pub enum AudioCommand {
/// Play `path` from `from` seconds, whatever plays now stops.
Play {
/// Vault path of the file.
path: String,
/// Where to start, seconds.
from: f32,
},
/// Jump the current file to this many seconds.
Seek(f32),
/// Hold the current file (`true`) or go on from where it was held.
Pause(bool),
/// Play at this speed (0.5 to 2).
Speed(f32),
/// Set the volume, 0 to [`MAX_VOLUME`].
Volume(u8),
/// Silence the sound, or bring it back.
Mute(bool),
/// Start again from the top when the file ends.
Loop(bool),
/// Stop whatever plays.
Stop,
}
/// What the views read.
#[expect(clippy::struct_excessive_bools, reason = "the player state is a set of independent switches the views read one by one")]
#[derive(Clone)]
pub struct AudioState {
/// `ffplay` is installed.
pub available: bool,
/// Vault path of the file that plays or is held.
pub path: Option<String>,
/// The file was asked for and its sound has not started yet.
pub starting: bool,
/// Seconds played so far.
pub position: f32,
/// Length in seconds, when the probe told.
pub duration: Option<f32>,
/// Playback speed, 0.5 to 2.
pub speed: f32,
/// Volume, 0 to [`MAX_VOLUME`].
pub volume: u8,
/// Whether the sound is silenced.
pub muted: bool,
/// Whether the file starts again when it ends.
pub looping: bool,
/// Whether the file is held, its position kept.
pub paused: bool,
/// The samples for the spectrum, while the sound runs.
pub feed: Option<Feed>,
}
impl AudioState {
/// Whether the sound is going out now.
#[must_use]
pub fn is_sounding(&self) -> bool {
self.path.is_some() && !self.paused && !self.starting
}
/// Whether `path` is the file the player has (playing, held or starting).
#[must_use]
pub fn has(&self, path: &str) -> bool {
self.path.as_deref() == Some(path)
}
}
/// Sends `command` to the player.
pub fn command(ctx: &Context, command: AudioCommand) {
ctx.data_mut(|data| data.get_temp_mut_or_default::<Vec<AudioCommand>>(commands_id()).push(command));
ctx.request_repaint();
}
/// The state published by the last frame of the app.
#[must_use]
pub fn state(ctx: &Context) -> Option<AudioState> {
ctx.data(|data| data.get_temp::<AudioState>(state_id()))
}
/// The position `elapsed` wall seconds after a start at `from`: the start lag first, then the speed.
#[must_use]
pub fn clock_position(from: f32, elapsed: f32, speed: f32, duration: Option<f32>) -> f32 {
let lag = if speed >= FAST_FROM { FAST_START_LAG } else { START_LAG };
let moved = from + (elapsed - lag).max(0.0) * speed;
match duration {
Some(total) if total.is_finite() => moved.min(total),
_ => moved,
}
}
/// Whether a video that just became active must stop the audio: only the moment it starts, so the
/// audio that asked the video to stop is not stopped by the video it is stopping.
#[must_use]
pub fn yields_to_video(was_active: bool, is_active: bool) -> bool {
is_active && !was_active
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_clock_waits_for_the_sound_then_runs_at_the_speed() {
assert_eq!(clock_position(10.0, 0.05, 1.0, None), 10.0, "inside the start lag");
assert!((clock_position(10.0, START_LAG + 2.0, 1.0, None) - 12.0).abs() < 1e-4);
assert!((clock_position(10.0, FAST_START_LAG + 2.0, 2.0, None) - 14.0).abs() < 1e-4);
assert_eq!(clock_position(10.0, 100.0, 1.0, Some(30.0)), 30.0, "never past the end");
}
#[test]
fn vault_paths_cannot_leave_the_vault() {
let root = Path::new("/vault");
assert_eq!(vault_file(root, "a/b.mp3"), Some(PathBuf::from("/vault/a/b.mp3")));
assert_eq!(vault_file(root, "../x.mp3"), None);
assert_eq!(vault_file(root, "/etc/passwd"), None);
assert_eq!(vault_file(root, "a/../../x"), None);
assert_eq!(vault_file(root, ""), None);
}
#[test]
fn a_video_only_stops_the_audio_when_it_starts() {
assert!(yields_to_video(false, true));
assert!(!yields_to_video(true, true), "the video the audio asked to stop is not a new start");
assert!(!yields_to_video(true, false));
assert!(!yields_to_video(false, false));
}
}
@@ -0,0 +1,107 @@
//! The audio embed of a note: a compact card with play and hold, the name, the time and a mini
//! waveform that seeks. It drives the same player as the tab, so one sound plays at a time.
use egui::{Margin, RichText, Sense, Ui, Vec2};
use super::Ctl;
use super::bus;
use super::waveform::{self, Wave};
use crate::text::media_view as media;
use crate::text::media_view::audio as strings;
use crate::theme::{Tokens, color};
/// Diameter of the play button, points.
const PLAY: f32 = 36.0;
/// Side of the icon inside the play button, points.
const PLAY_ICON: f32 = 16.0;
/// Height of the mini waveform, points.
const WAVE_HEIGHT: f32 = 32.0;
/// Gap between the controls and the text column, points.
const GAP: f32 = 10.0;
/// Draws the audio embed of the vault file `path`. Returns whether Open with the system was asked.
pub fn show(ui: &mut Ui, tokens: &Tokens, path: &str) -> bool {
let ctx = ui.ctx().clone();
let colors = &tokens.theme.colors;
let state = bus::state(&ctx);
let available = state.as_ref().is_none_or(|state| state.available);
let file = bus::embed_file(&ctx, path);
let analysis = waveform::analysis(&ctx, path, file.as_deref());
let mine = state.as_ref().filter(|state| state.has(path));
let ctl = Ctl {
path,
running: mine.is_some(),
paused: mine.is_some_and(|state| state.paused),
position: mine.map_or(0.0, |state| state.position),
duration: mine.and_then(|state| state.duration).or(analysis.duration),
state: state.as_ref(),
file,
by_tab: false,
};
let mut open = false;
egui::Frame::new().fill(color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
if play_button(ui, tokens, &ctl, available) {
if available {
ctl.toggle(&ctx);
} else {
open = true;
}
}
ui.add_space(GAP);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(super::super::name_of(path)).color(color(colors.text)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
open |= super::super::bar::button(
ui,
tokens,
"audio-embed-open",
Some("external-link"),
None,
media::open(),
super::super::bar::Look::Plain,
)
.clicked();
ui.label(
RichText::new(super::super::controls::clock_text(ctl.position, ctl.duration))
.size(crate::style::TEXT_TINY)
.color(color(colors.text_muted)),
);
});
});
let wave = Wave {
id: strings::waveform(),
peaks: analysis.peaks.as_deref(),
failed: analysis.failed,
position: ctl.position,
duration: ctl.duration,
hover_time: true,
};
let hit = waveform::show(ui, tokens, Vec2::new(ui.available_width(), WAVE_HEIGHT), &wave);
if let Some(to) = hit.chosen.filter(|_| available) {
ctl.go(&ctx, to);
}
});
});
});
if mine.is_some_and(super::bus::AudioState::is_sounding) {
ctx.request_repaint_after(std::time::Duration::from_millis(33));
}
open
}
/// The round accent play or hold button; true when pressed.
fn play_button(ui: &mut Ui, tokens: &Tokens, ctl: &Ctl<'_>, available: bool) -> bool {
let colors = &tokens.theme.colors;
let (area, response) = ui.allocate_exact_size(Vec2::splat(PLAY), Sense::click());
let hover = crate::motion::hover(ui.ctx(), ("notescraft-audio-embed-play", ctl.path), &response);
let fill = crate::motion::mix(color(colors.accent), color(colors.text), hover * 0.15);
ui.painter().circle_filled(area.center(), PLAY / 2.0, fill);
let (icon, tip) = if ctl.running && !ctl.paused { ("pause", media::pause()) } else { ("play", media::play()) };
crate::icons::paint(ui, icon, egui::Rect::from_center_size(area.center(), Vec2::splat(PLAY_ICON)), color(colors.on_accent));
let tip = if available { tip } else { strings::no_player() };
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
response.on_hover_text(tip).clicked()
}
@@ -0,0 +1,159 @@
//! A radix-2 FFT written for the spectrum: planned once (twiddles and bit-reversal table), then
//! run in place on caller-owned buffers, so a frame allocates nothing.
use std::f32::consts::TAU;
/// A plan for transforms of one power-of-two length.
pub struct Fft {
size: usize,
/// `cos(2 pi k / size)` for `k < size / 2`.
cos: Vec<f32>,
/// `-sin(2 pi k / size)` for `k < size / 2` (the forward transform's sign).
sin: Vec<f32>,
/// The index each position swaps with in the bit-reversal pass.
reverse: Vec<usize>,
}
impl Fft {
/// Plans a transform of `size` points; `None` unless `size` is a power of two of at least 2.
#[must_use]
pub fn new(size: usize) -> Option<Self> {
if size < 2 || !size.is_power_of_two() {
return None;
}
let bits = size.trailing_zeros();
let half = size / 2;
let angle = |k: usize| {
#[expect(clippy::cast_precision_loss, reason = "k is below the FFT size, far under 2^24")]
let k = k as f32;
#[expect(clippy::cast_precision_loss, reason = "size is a small power of two, exact in f32")]
let n = size as f32;
TAU * k / n
};
let cos = (0..half).map(|k| angle(k).cos()).collect();
let sin = (0..half).map(|k| -angle(k).sin()).collect();
let reverse = (0..size).map(|index| index.reverse_bits() >> (usize::BITS - bits)).collect();
Some(Self { size, cos, sin, reverse })
}
/// The number of points the plan transforms.
#[must_use]
pub fn size(&self) -> usize {
self.size
}
/// Transforms `re`/`im` in place. Returns `false` and leaves them alone when either is not
/// exactly [`Fft::size`] long.
pub fn transform(&self, re: &mut [f32], im: &mut [f32]) -> bool {
if re.len() != self.size || im.len() != self.size {
return false;
}
for (index, &other) in self.reverse.iter().enumerate() {
if other > index {
re.swap(index, other);
im.swap(index, other);
}
}
let mut span = 2;
while span <= self.size {
let half = span / 2;
let stride = self.size / span;
for (re_block, im_block) in re.chunks_exact_mut(span).zip(im.chunks_exact_mut(span)) {
let (re_low, re_high) = re_block.split_at_mut(half);
let (im_low, im_high) = im_block.split_at_mut(half);
let lanes = re_low.iter_mut().zip(re_high).zip(im_low.iter_mut().zip(im_high));
for (k, ((a_re, b_re), (a_im, b_im))) in lanes.enumerate() {
let (c, s) = (self.cos.get(k * stride).copied().unwrap_or(1.0), self.sin.get(k * stride).copied().unwrap_or(0.0));
let t_re = *b_re * c - *b_im * s;
let t_im = *b_re * s + *b_im * c;
*b_re = *a_re - t_re;
*b_im = *a_im - t_im;
*a_re += t_re;
*a_im += t_im;
}
}
span *= 2;
}
true
}
}
/// The Hann window of `size` points, for tapering a block before the transform.
#[must_use]
pub fn hann(size: usize) -> Vec<f32> {
#[expect(clippy::cast_precision_loss, reason = "window sizes are small")]
let span = size.saturating_sub(1).max(1) as f32;
(0..size)
.map(|index| {
#[expect(clippy::cast_precision_loss, reason = "window sizes are small")]
let at = index as f32;
0.5 - 0.5 * (TAU * at / span).cos()
})
.collect()
}
#[cfg(test)]
#[expect(clippy::cast_precision_loss, reason = "test sample counts are far below 2^24")]
mod tests {
use super::{Fft, hann};
const N: usize = 1024;
const RATE: f32 = 22_050.0;
fn magnitudes(fft: &Fft, signal: &[f32]) -> Vec<f32> {
let mut re = signal.to_vec();
let mut im = vec![0.0; signal.len()];
assert!(fft.transform(&mut re, &mut im));
re.iter().zip(&im).take(signal.len() / 2).map(|(r, i)| r.hypot(*i)).collect()
}
#[test]
fn a_sine_peaks_in_its_own_bin() {
let fft = Fft::new(N).expect("a power of two");
let bin = 40_usize;
let freq = bin as f32 * RATE / N as f32;
let signal: Vec<f32> = (0..N).map(|i| (std::f32::consts::TAU * freq * i as f32 / RATE).sin()).collect();
let mags = magnitudes(&fft, &signal);
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).map(|(i, _)| i);
assert_eq!(peak, Some(bin));
assert!((mags[bin] - N as f32 / 2.0).abs() < 1.0, "a unit sine reads N/2: {}", mags[bin]);
}
#[test]
fn a_440_hz_sine_lands_within_one_bin() {
let fft = Fft::new(2048).expect("a power of two");
let window = hann(2048);
let signal: Vec<f32> = (0..2048).map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / RATE).sin() * window[i]).collect();
let mags = magnitudes(&fft, &signal);
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).map_or(0, |(i, _)| i);
let expected = 440.0 / (RATE / 2048.0);
assert!((peak as f32 - expected).abs() <= 1.0, "peak bin {peak}, expected {expected}");
}
#[test]
fn silence_stays_zero() {
let fft = Fft::new(256).expect("a power of two");
assert!(magnitudes(&fft, &[0.0; 256]).iter().all(|m| *m == 0.0));
}
#[test]
fn only_powers_of_two_are_planned() {
assert!(Fft::new(0).is_none());
assert!(Fft::new(1).is_none());
assert!(Fft::new(1000).is_none());
assert!(Fft::new(2).is_some());
let fft = Fft::new(8).expect("a power of two");
assert!(!fft.transform(&mut [0.0; 4], &mut [0.0; 8]), "a wrong length is refused");
}
#[test]
fn dc_goes_to_bin_zero_and_the_window_tapers_to_zero() {
let fft = Fft::new(64).expect("a power of two");
let mags = magnitudes(&fft, &[1.0; 64]);
assert!((mags[0] - 64.0).abs() < 1e-3);
assert!(mags[1..].iter().all(|m| *m < 1e-3));
let w = hann(64);
assert!(w[0].abs() < 1e-6 && w[63].abs() < 1e-6);
assert!(w[31] > 0.99);
}
}
@@ -0,0 +1,331 @@
//! The audio tab and the audio embed: sound through `ffplay`, a waveform that seeks, and a live
//! spectrum. No decoder is linked; without `ffplay` the tab offers the system player.
pub mod bus;
pub mod embed;
pub mod fft;
pub mod peaks;
pub mod player;
mod process;
pub mod ring;
pub mod spectrum;
pub mod waveform;
use std::sync::Arc;
use std::time::Duration;
use egui::{Context, Id, Margin, Sense, Ui, Vec2};
use notescraft_engine::Session;
use self::bus::{AudioCommand, AudioState, command};
use super::bar::{Look, button, card_with, chip, divider, more};
use super::controls::clock_text;
use super::seek::{KEY_SEEK, SKIP, next_speed, skipped, volume_step};
use super::{file_size, header, message_card};
use crate::text::media_view as media;
use crate::text::media_view::audio as strings;
use crate::theme::{Tokens, color};
/// Widest the tab's column grows, points.
const COLUMN_MAX_WIDTH: f32 = 960.0;
/// Padding inside a card, points.
const CARD_PADDING: i8 = 12;
/// Gap between two cards, points.
const GAP: f32 = 12.0;
/// Height of the waveform, points.
const WAVE_HEIGHT: f32 = 120.0;
/// Height of the spectrum at most, points.
const SPECTRUM_MAX: f32 = 200.0;
/// Height of the spectrum at least, points.
const SPECTRUM_MIN: f32 = 96.0;
/// Share of the free height the spectrum takes.
const SPECTRUM_SHARE: f32 = 0.45;
/// How soon the next frame is asked for while the spectrum moves.
const SPECTRUM_FRAME: Duration = Duration::from_millis(16);
/// How soon the next frame is asked for while the playhead moves.
const HEAD_FRAME: Duration = Duration::from_millis(33);
/// What the controls need to know about the file at hand.
pub(super) struct Ctl<'a> {
pub path: &'a str,
/// The player has this file (playing, held or starting).
pub running: bool,
/// The file is held.
pub paused: bool,
/// Seconds played.
pub position: f32,
/// Length in seconds, when known.
pub duration: Option<f32>,
pub state: Option<&'a AudioState>,
/// The file on disk.
pub file: Option<std::path::PathBuf>,
/// Whether a tab (not an embed) draws these controls, so the sound stops with the tab.
pub by_tab: bool,
}
impl Ctl<'_> {
/// The main button and Space: start from the top when stopped, otherwise hold or go on.
pub fn toggle(&self, ctx: &Context) {
if self.running {
command(ctx, AudioCommand::Pause(!self.paused));
} else {
self.go(ctx, 0.0);
}
self.own(ctx);
}
/// Moves to `to` seconds: a seek while the file is the player's, a start from there otherwise.
pub fn go(&self, ctx: &Context, to: f32) {
let to = skipped(to, 0.0, self.duration);
if self.running {
command(ctx, AudioCommand::Seek(to));
} else {
command(ctx, AudioCommand::Play { path: self.path.to_owned(), from: to });
}
self.own(ctx);
}
fn own(&self, ctx: &Context) {
if self.by_tab {
mark_owned(ctx, self.path);
}
}
}
/// The keys of the audio tab: Space plays and holds, the arrows seek and set the volume, M mutes.
fn answer_keys(ctx: &Context, ctl: &Ctl<'_>) {
use egui::{Key, Modifiers};
if ctx.egui_wants_keyboard_input() {
return;
}
let pressed = |key| ctx.input_mut(|input| input.consume_key(Modifiers::NONE, key));
if pressed(Key::Space) {
ctl.toggle(ctx);
}
if pressed(Key::ArrowLeft) {
ctl.go(ctx, skipped(ctl.position, -KEY_SEEK, ctl.duration));
}
if pressed(Key::ArrowRight) {
ctl.go(ctx, skipped(ctl.position, KEY_SEEK, ctl.duration));
}
let volume = ctl.state.map_or(crate::app::video::MAX_VOLUME, |state| state.volume);
if pressed(Key::ArrowUp) {
command(ctx, AudioCommand::Volume(volume_step(volume, true)));
}
if pressed(Key::ArrowDown) {
command(ctx, AudioCommand::Volume(volume_step(volume, false)));
}
if pressed(Key::M) {
command(ctx, AudioCommand::Mute(!ctl.state.is_some_and(|state| state.muted)));
}
}
fn spectrum_id() -> Id {
Id::new("notescraft-audio-spectrum-on")
}
/// Whether the spectrum shows; remembered in the egui memory, on at first.
fn spectrum_on(ctx: &Context) -> bool {
ctx.data_mut(|data| data.get_persisted::<bool>(spectrum_id())).unwrap_or(true)
}
/// A card on the canvas holding one block of the tab.
fn card(ui: &mut Ui, tokens: &Tokens, add: impl FnOnce(&mut Ui)) {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::same(CARD_PADDING)).show(ui, |ui| {
ui.set_min_width(ui.available_width());
add(ui);
});
}
/// Draws the audio tab of `path`: the header, the spectrum, the waveform and the controls. Returns
/// the file to open with the system, when the user asked for it.
pub fn show(ui: &mut Ui, tokens: &Tokens, session: &Session, path: &str) -> Option<String> {
let ctx = ui.ctx().clone();
let state = bus::state(&ctx);
mark_open(&ctx, path);
header(ui, tokens, "music", path, file_size(session, path));
if state.as_ref().is_some_and(|state| !state.available) {
return message_card(ui, tokens, strings::no_player()).then(|| path.to_owned());
}
let file = session.vault().resolve(path).ok();
let analysis = waveform::analysis(&ctx, path, file.as_deref());
let mine = state.as_ref().filter(|state| state.has(path));
let ctl = Ctl {
path,
running: mine.is_some(),
paused: mine.is_some_and(|state| state.paused),
position: mine.map_or(0.0, |state| state.position),
duration: mine.and_then(|state| state.duration).or(analysis.duration),
state: state.as_ref(),
file,
by_tab: true,
};
answer_keys(&ctx, &ctl);
let compact = crate::style::compact::is_compact(&ctx);
let width = ui.available_width().min(COLUMN_MAX_WIDTH);
let pad = ((ui.available_width() - width) / 2.0).max(0.0);
let free = ui.available_height();
let sounding = mine.is_some_and(AudioState::is_sounding);
let mut open = false;
ui.horizontal_top(|ui| {
ui.add_space(pad);
ui.vertical(|ui| {
ui.set_width(width);
if spectrum_on(&ctx) {
let height = (free * SPECTRUM_SHARE).clamp(SPECTRUM_MIN, SPECTRUM_MAX);
card(ui, tokens, |ui| {
let (area, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let dt = ctx.input(|input| input.stable_dt);
let more_frames = spectrum::step(&ctx, mine.and_then(|state| state.feed.as_ref()), ctl.position, sounding, dt);
spectrum::paint(ui, tokens, area);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, strings::spectrum_label()));
if more_frames {
ctx.request_repaint_after(SPECTRUM_FRAME);
}
});
ui.add_space(GAP);
}
card(ui, tokens, |ui| {
let wave = waveform::Wave {
id: strings::waveform(),
peaks: analysis.peaks.as_deref(),
failed: analysis.failed,
position: ctl.position,
duration: ctl.duration,
hover_time: true,
};
let hit = waveform::show(ui, tokens, Vec2::new(ui.available_width(), WAVE_HEIGHT), &wave);
if let Some(to) = hit.chosen {
ctl.go(&ctx, to);
}
});
ui.add_space(GAP);
open = controls(ui, tokens, &ctl, compact);
});
});
if sounding {
ctx.request_repaint_after(HEAD_FRAME);
}
open.then(|| path.to_owned())
}
/// The controls card. Returns whether Open with the system was pressed.
fn controls(ui: &mut Ui, tokens: &Tokens, ctl: &Ctl<'_>, compact: bool) -> bool {
let ctx = ui.ctx().clone();
let mut open = false;
let (icon, tip) = if ctl.running && !ctl.paused { ("pause", media::pause()) } else { ("play", media::play()) };
card_with(ui, tokens, compact, |ui| {
if button(ui, tokens, "audio-toggle", Some(icon), None, tip, Look::Primary).clicked() {
ctl.toggle(&ctx);
}
if button(ui, tokens, "audio-back", Some("rewind"), None, media::back_ten(), Look::Plain).clicked() {
ctl.go(&ctx, skipped(ctl.position, -SKIP, ctl.duration));
}
if button(ui, tokens, "audio-forward", Some("fast-forward"), None, media::forward_ten(), Look::Plain).clicked() {
ctl.go(&ctx, skipped(ctl.position, SKIP, ctl.duration));
}
chip(ui, tokens, &clock_text(ctl.position, ctl.duration));
if compact {
more(ui, tokens, media::more(), |ui| open = extras(ui, tokens, ctl, true));
} else {
divider(ui, tokens);
open = extras(ui, tokens, ctl, false);
}
});
open
}
/// The buttons beyond the transport. Returns whether Open with the system was pressed.
fn extras(ui: &mut Ui, tokens: &Tokens, ctl: &Ctl<'_>, labelled: bool) -> bool {
let ctx = ui.ctx().clone();
let label = |text: &'static str| labelled.then_some(text);
let (speed, muted, looping) = ctl.state.map_or((1.0, false, false), |state| (state.speed, state.muted, state.looping));
if button(ui, tokens, "audio-replay", Some("rotate-ccw"), label(media::replay()), media::replay(), Look::Plain).clicked() {
if ctl.running {
command(&ctx, AudioCommand::Pause(false));
}
ctl.go(&ctx, 0.0);
}
let speed_text = media::speed_chip().replace("{{speed}}", &speed.to_string());
let speed_tip = media::speed().replace("{{speed}}", &speed.to_string());
if button(ui, tokens, "audio-speed", None, Some(&speed_text), &speed_tip, Look::Plain).clicked() {
command(&ctx, AudioCommand::Speed(next_speed(speed)));
}
let look = if looping { Look::Primary } else { Look::Plain };
if button(ui, tokens, "audio-loop", Some("repeat"), label(media::looping()), media::looping(), look).clicked() {
command(&ctx, AudioCommand::Loop(!looping));
}
let (icon, tip) = if muted { ("volume-x", media::unmute()) } else { ("volume-2", media::mute()) };
let volume = ctl.state.map_or(crate::app::video::MAX_VOLUME, |state| state.volume);
let volume_tip = strings::volume().replace("{{percent}}", &volume.to_string());
if button(ui, tokens, "audio-mute", Some(icon), label(tip), &format!("{tip} ({volume_tip})"), Look::Plain).clicked() {
command(&ctx, AudioCommand::Mute(!muted));
}
let on = spectrum_on(&ctx);
if button(ui, tokens, "audio-spectrum", None, Some(strings::spectrum()), strings::spectrum_tip(), if on { Look::Primary } else { Look::Plain }).clicked() {
ctx.data_mut(|data| data.insert_persisted(spectrum_id(), !on));
}
super::actions::file_actions(ui, tokens, ctl.path, ctl.file.as_deref(), labelled)
}
/// The id the audio tab's liveness marker is stored under.
fn open_id() -> Id {
Id::new("notescraft-audio-view-open-tab")
}
/// The id the ownership marker is stored under: the path a tab (not an embed) started.
fn owned_id() -> Id {
Id::new("notescraft-audio-view-owned")
}
fn sweeper_id() -> Id {
Id::new("notescraft-audio-view-sweeper")
}
/// Marks the audio tab at `path` as drawn this pass, so its sound is kept.
fn mark_open(ctx: &Context, path: &str) {
if ctx.data(|data| data.get_temp::<bool>(sweeper_id())).is_none() {
ctx.on_end_pass("notescraft-audio-view-sweeper", Arc::new(sweep));
ctx.data_mut(|data| data.insert_temp(sweeper_id(), true));
}
ctx.data_mut(|data| data.insert_temp(open_id(), path.to_owned()));
}
/// Marks that the sound of `path` was started by a tab that owns it.
pub(super) fn mark_owned(ctx: &Context, path: &str) {
ctx.data_mut(|data| data.insert_temp(owned_id(), path.to_owned()));
}
/// The pass-end hook: a sound a tab started stops once no tab draws that file any more.
fn sweep(ui: &mut Ui) {
let ctx = ui.ctx();
let open = ctx.data_mut(|data| data.remove_temp::<String>(open_id()));
let Some(path) = bus::state(ctx).and_then(|state| state.path) else {
ctx.data_mut(|data| data.remove_temp::<String>(owned_id()));
return;
};
let owned = ctx.data(|data| data.get_temp::<String>(owned_id()));
if must_stop(Some(path.as_str()), owned.as_deref(), open.as_deref()) {
command(ctx, AudioCommand::Stop);
}
}
/// Whether the sound has to stop: a tab owns it and no tab draws it any more.
fn must_stop(playing: Option<&str>, owned: Option<&str>, open: Option<&str>) -> bool {
let Some(path) = playing else { return false };
owned == Some(path) && open != Some(path)
}
#[cfg(test)]
mod tests {
use super::must_stop;
#[test]
fn a_sound_stops_when_its_tab_is_not_drawn_any_more() {
assert!(must_stop(Some("a.mp3"), Some("a.mp3"), None));
assert!(!must_stop(Some("a.mp3"), Some("a.mp3"), Some("a.mp3")));
assert!(!must_stop(Some("a.mp3"), None, None), "an embed's sound is left alone");
assert!(!must_stop(Some("a.mp3"), Some("b.mp3"), None));
assert!(!must_stop(None, Some("a.mp3"), None));
}
}
@@ -0,0 +1,230 @@
//! The peaks of a file: the whole file decoded once at 8 kHz mono by `ffmpeg`, folded into min/max
//! buckets (bounded at 30 minutes) and reduced to the columns the waveform draws.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
/// Sample rate of the waveform stream, Hz.
const RATE: usize = 8000;
/// Longest stretch of audio decoded for the waveform, seconds (30 minutes).
const MAX_SECONDS: usize = 30 * 60;
/// Samples read at most.
const MAX_SAMPLES: usize = MAX_SECONDS * RATE;
/// Samples folded into one bucket (100 buckets per second).
const BUCKET: usize = 80;
/// Buckets kept at most (the 30 minutes at 100 per second).
const MAX_BUCKETS: usize = MAX_SECONDS * 100;
/// Peak columns a cached waveform keeps.
pub const COLUMNS: usize = 1500;
/// Peaks quieter than this are left as they are instead of being scaled up to full height.
const NOISE_FLOOR: f32 = 1.0e-3;
/// Bytes read from the decoder at a time.
const CHUNK: usize = 16 * 1024;
/// The peaks of a file: one `(min, max)` pair per column, scaled so the loudest reaches 1.
pub struct Peaks {
/// The columns, left to right.
pub columns: Vec<(f32, f32)>,
/// Seconds of audio the columns cover.
pub seconds: f32,
}
/// Folds a stream of samples into `(min, max)` buckets of [`BUCKET`] samples.
#[derive(Default)]
pub struct Bucketer {
buckets: Vec<(f32, f32)>,
min: f32,
max: f32,
count: usize,
total: usize,
}
impl Bucketer {
/// Takes one sample; ignored once [`MAX_SAMPLES`] were taken.
pub fn push(&mut self, sample: f32) {
if self.total >= MAX_SAMPLES || self.buckets.len() >= MAX_BUCKETS {
return;
}
let sample = if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 };
if self.count == 0 {
(self.min, self.max) = (sample, sample);
} else {
self.min = self.min.min(sample);
self.max = self.max.max(sample);
}
self.count += 1;
self.total += 1;
if self.count == BUCKET {
self.flush();
}
}
/// Whether the cap on samples was reached.
#[must_use]
pub fn is_full(&self) -> bool {
self.total >= MAX_SAMPLES
}
fn flush(&mut self) {
if self.count > 0 {
self.buckets.push((self.min, self.max));
self.count = 0;
}
}
/// Ends the stream: the peaks, or `None` when no sample came.
#[must_use]
pub fn finish(mut self) -> Option<Peaks> {
self.flush();
if self.buckets.is_empty() {
return None;
}
#[expect(clippy::cast_precision_loss, reason = "at most 14.4 million samples, a second's error is invisible")]
let seconds = self.total as f32 / RATE as f32;
Some(Peaks { columns: reduce(&self.buckets, COLUMNS), seconds })
}
}
/// `source` resampled to exactly `count` columns (min of mins, max of maxes; a column repeats when
/// there are fewer sources than columns). Empty when either is empty.
#[must_use]
pub fn merge(source: &[(f32, f32)], count: usize) -> Vec<(f32, f32)> {
if source.is_empty() {
return Vec::new();
}
(0..count)
.map(|column| {
let from = column * source.len() / count;
let to = ((column + 1) * source.len() / count).max(from + 1).min(source.len());
source.get(from..to).unwrap_or_default().iter().fold((f32::MAX, f32::MIN), |(lo, hi), (min, max)| (lo.min(*min), hi.max(*max)))
})
.collect()
}
/// [`merge`], then scaled so the loudest peak reaches 1 (unless the file is near silent).
#[must_use]
pub fn reduce(source: &[(f32, f32)], count: usize) -> Vec<(f32, f32)> {
let mut columns = merge(source, count);
let loudest = columns.iter().fold(0.0_f32, |top, (min, max)| top.max(min.abs()).max(max.abs()));
if loudest > NOISE_FLOOR {
for (min, max) in &mut columns {
*min /= loudest;
*max /= loudest;
}
}
columns
}
/// Decodes `file` and reduces it. Blocks: call it from a worker thread.
#[must_use]
pub fn decode(file: &Path) -> Option<Peaks> {
let mut child = Command::new("ffmpeg")
.args(["-v", "error", "-nostdin", "-i"])
.arg(file)
.args(["-vn", "-ac", "1", "-ar", &RATE.to_string(), "-f", "f32le", "-"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let mut stdout = child.stdout.take()?;
let mut bucketer = Bucketer::default();
let mut chunk = vec![0_u8; CHUNK];
let mut carry = 0;
while !bucketer.is_full() {
let Some(room) = chunk.get_mut(carry..) else { break };
let Ok(read) = stdout.read(room) else { break };
if read == 0 {
break;
}
let have = carry + read;
let whole = have - have % 4;
for bytes in chunk.get(..whole).unwrap_or_default().as_chunks::<4>().0 {
bucketer.push(f32::from_le_bytes(*bytes));
}
chunk.copy_within(whole..have, 0);
carry = have - whole;
}
let _ = child.kill();
let _ = child.wait();
bucketer.finish()
}
/// The length of the file in seconds, by `ffprobe`. Blocks: call it from a worker thread.
#[must_use]
pub fn probe_duration(file: &Path) -> Option<f32> {
let out = Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1"])
.arg(file)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
let seconds = String::from_utf8(out.stdout).ok()?.trim().parse::<f32>().ok()?;
(seconds.is_finite() && (0.0..=86_400.0).contains(&seconds)).then_some(seconds)
}
#[cfg(test)]
#[expect(clippy::cast_precision_loss, reason = "test sample counts are far below 2^24")]
mod tests {
use super::*;
fn feed(samples: impl Iterator<Item = f32>) -> Option<Peaks> {
let mut bucketer = Bucketer::default();
for sample in samples {
bucketer.push(sample);
}
bucketer.finish()
}
#[test]
fn a_square_wave_reads_flat_full_peaks() {
let peaks = feed((0..RATE * 2).map(|i| if (i / 20) % 2 == 0 { 0.5 } else { -0.5 })).expect("samples came");
assert!((peaks.seconds - 2.0).abs() < 1e-3);
assert_eq!(peaks.columns.len(), COLUMNS);
assert!(peaks.columns.iter().all(|(min, max)| (*min + 1.0).abs() < 1e-5 && (*max - 1.0).abs() < 1e-5), "normalised to 1");
}
#[test]
fn a_ramp_grows_column_by_column_and_columns_cap_at_1500() {
let peaks = feed((0..RATE * 60).map(|i| i as f32 / (RATE * 60) as f32)).expect("samples came");
assert_eq!(peaks.columns.len(), COLUMNS);
let tops: Vec<f32> = peaks.columns.iter().map(|(_, max)| *max).collect();
assert!(tops.windows(2).all(|pair| pair[1] >= pair[0]));
assert!((tops[COLUMNS - 1] - 1.0).abs() < 1e-5);
}
#[test]
fn silence_stays_silent_and_no_samples_make_no_peaks() {
let peaks = feed(std::iter::repeat_n(0.0, RATE)).expect("samples came");
assert!(peaks.columns.iter().all(|(min, max)| *min == 0.0 && *max == 0.0));
assert!(feed(std::iter::empty()).is_none());
}
#[test]
fn the_decode_stops_at_thirty_minutes() {
let mut bucketer = Bucketer::default();
for _ in 0..MAX_SAMPLES + 5000 {
bucketer.push(0.1);
}
assert!(bucketer.is_full());
let peaks = bucketer.finish().expect("peaks");
assert!((peaks.seconds - MAX_SECONDS as f32).abs() < 1e-2);
}
#[test]
fn hostile_samples_are_held_to_range() {
let peaks = feed([f32::NAN, f32::INFINITY, -9.0, 9.0].into_iter().chain(std::iter::repeat_n(0.0, 100))).expect("samples came");
assert!(peaks.columns.iter().all(|(min, max)| min.is_finite() && max.is_finite() && *min >= -1.0 && *max <= 1.0));
}
#[test]
fn merging_never_makes_more_columns_than_asked_or_present() {
let source = [(-0.1, 0.2), (-0.5, 0.3), (-0.2, 0.9), (0.0, 0.1)];
assert_eq!(merge(&source, 2), vec![(-0.5, 0.3), (-0.2, 0.9)]);
assert_eq!(merge(&source, 10).len(), 10);
assert_eq!(merge(&source, 0), Vec::new());
assert_eq!(merge(&[], 5), Vec::new());
}
}
@@ -0,0 +1,301 @@
//! The audio player: one file at a time, a wall clock times the speed for the position, pause kills
//! the processes and a resume starts them at the kept position, the way the video player does.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{Receiver, TryRecvError};
use std::time::{Duration, Instant};
use egui::Context;
pub use super::bus::{AudioCommand, AudioState, command, embed_file, state};
use super::bus::{clock_position, commands_id, root_id, state_id, vault_file, yields_to_video};
use super::process::{Run, Start, spawn_launch, spawn_sound, stop_child};
use crate::app::video::MAX_VOLUME;
/// How often the player looks at its processes while a file plays.
const TICK: Duration = Duration::from_millis(100);
/// A sound that ends this soon after it started failed to decode, seconds.
const FAILED_WITHIN: f32 = 0.4;
struct Settings {
speed: f32,
volume: u8,
muted: bool,
looping: bool,
}
impl Default for Settings {
fn default() -> Self {
Self { speed: 1.0, volume: MAX_VOLUME, muted: false, looping: false }
}
}
impl Settings {
fn loudness(&self) -> u8 {
if self.muted { 0 } else { self.volume }
}
}
struct Playing {
path: String,
file: PathBuf,
/// Source second the clock started from; the kept position while held.
from: f32,
run: Option<Run>,
pending: Option<Receiver<Option<Run>>>,
duration: Option<f32>,
paused: bool,
}
impl Playing {
fn stop(&mut self) {
self.pending = None;
if let Some(run) = self.run.as_mut() {
run.stop();
}
self.run = None;
}
fn position(&self, speed: f32) -> f32 {
match (&self.run, self.paused) {
(Some(run), false) => clock_position(self.from, run.since.elapsed().as_secs_f32(), speed, self.duration),
_ => self.from,
}
}
fn restart(&mut self, ctx: &Context, settings: &Settings, from: f32) {
self.paused = false;
self.duration = self.run.as_ref().and_then(|run| run.duration).or(self.duration);
self.stop();
self.from = from.max(0.0);
let start = Start { file: self.file.clone(), from: self.from, speed: settings.speed, volume: settings.loudness(), duration: self.duration };
self.pending = spawn_launch(ctx, start);
}
}
/// The audio file that plays, and whether `ffplay` was found.
#[derive(Default)]
pub struct AudioPlayer {
playing: Option<Playing>,
available: Option<bool>,
settings: Settings,
video_was_active: bool,
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
if let Some(playing) = self.playing.as_mut() {
playing.stop();
}
}
}
/// The vault path of the video that plays or is starting, if any.
fn active_video(ctx: &Context) -> Option<String> {
let state = crate::app::video::state(ctx)?;
state.playing.map(|(path, _)| path).or(state.starting)
}
impl AudioPlayer {
/// Applies the requests of the views, watches the processes and publishes the state. Returns a
/// notice for the user when a file could not start.
pub fn tick(&mut self, ctx: &Context, root: &Path) -> Option<&'static str> {
ctx.data_mut(|data| data.insert_temp(root_id(), root.to_path_buf()));
let resolve = |path: &str| vault_file(root, path);
let available =
*self.available.get_or_insert_with(|| Command::new("ffplay").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok());
let video = active_video(ctx);
if yields_to_video(self.video_was_active, video.is_some()) {
self.stop();
}
for request in ctx.data_mut(|data| data.remove_temp::<Vec<AudioCommand>>(commands_id())).unwrap_or_default() {
self.apply(ctx, request, available, video.as_deref(), &resolve);
}
self.video_was_active = video.is_some();
let notice = self.poll_start();
self.poll_end(ctx);
if self.playing.as_ref().is_some_and(|playing| !playing.paused) {
ctx.request_repaint_after(TICK);
}
ctx.data_mut(|data| data.insert_temp(state_id(), self.state(available)));
notice
}
/// Stops whatever plays.
pub fn stop(&mut self) {
if let Some(mut playing) = self.playing.take() {
playing.stop();
}
}
/// The path that plays or is held now.
#[must_use]
pub fn current(&self) -> Option<&str> {
self.playing.as_ref().map(|playing| playing.path.as_str())
}
/// Applies one request of a view. A new file stops the old one first, and a video that plays is
/// asked to stop: one sound at a time.
pub fn apply(&mut self, ctx: &Context, request: AudioCommand, available: bool, video: Option<&str>, resolve: &dyn Fn(&str) -> Option<PathBuf>) {
let settings = &mut self.settings;
match request {
AudioCommand::Play { path, from } => {
self.stop();
let Some(file) = available.then(|| resolve(&path)).flatten() else { return };
if let Some(video) = video {
crate::app::video::request(ctx, video);
}
let mut playing = Playing { path, file, from, run: None, pending: None, duration: None, paused: false };
playing.restart(ctx, &self.settings, from);
self.playing = Some(playing);
}
AudioCommand::Seek(to) => {
if let Some(playing) = self.playing.as_mut() {
if playing.paused {
playing.from = to.max(0.0);
} else {
playing.restart(ctx, settings, to);
}
}
}
AudioCommand::Pause(true) => {
if let Some(playing) = self.playing.as_mut().filter(|playing| !playing.paused) {
let at = playing.position(settings.speed);
playing.duration = playing.run.as_ref().and_then(|run| run.duration).or(playing.duration);
playing.stop();
playing.from = at;
playing.paused = true;
}
}
AudioCommand::Pause(false) => {
if let Some(playing) = self.playing.as_mut().filter(|playing| playing.paused) {
let from = playing.from;
playing.restart(ctx, settings, from);
}
}
AudioCommand::Speed(speed) => {
let from = self.playing.as_ref().map(|playing| playing.position(settings.speed));
let held = self.playing.as_ref().is_some_and(|playing| playing.paused);
settings.speed = speed;
if let (Some(playing), Some(from), false) = (self.playing.as_mut(), from, held) {
playing.restart(ctx, settings, from);
}
}
AudioCommand::Volume(volume) => {
settings.volume = volume.min(MAX_VOLUME);
settings.muted = false;
self.resound();
}
AudioCommand::Mute(muted) => {
settings.muted = muted;
self.resound();
}
AudioCommand::Loop(looping) => settings.looping = looping,
AudioCommand::Stop => self.stop(),
}
}
/// Starts only the sound again at the current position with the current volume; the spectrum
/// stream keeps running, its feed is keyed by source second.
fn resound(&mut self) {
let settings = &self.settings;
let Some(playing) = self.playing.as_mut().filter(|playing| !playing.paused) else { return };
let position = playing.position(settings.speed);
let file = playing.file.clone();
let Some(run) = playing.run.as_mut() else { return };
stop_child(&mut run.sound);
run.sound = spawn_sound(&file, position, settings.speed, settings.loudness()).ok().flatten();
playing.from = position;
run.since = Instant::now();
}
fn state(&self, available: bool) -> AudioState {
let settings = &self.settings;
let playing = self.playing.as_ref();
AudioState {
available,
path: playing.map(|playing| playing.path.clone()),
starting: playing.is_some_and(|playing| playing.pending.is_some()),
position: playing.map_or(0.0, |playing| playing.position(settings.speed)),
duration: playing.and_then(|playing| playing.run.as_ref().and_then(|run| run.duration).or(playing.duration)),
speed: settings.speed,
volume: settings.volume,
muted: settings.muted,
looping: settings.looping,
paused: playing.is_some_and(|playing| playing.paused),
feed: playing.and_then(|playing| playing.run.as_ref().and_then(|run| run.feed.clone())),
}
}
/// Takes the answer of the worker that starts the file, once it has one.
fn poll_start(&mut self) -> Option<&'static str> {
let playing = self.playing.as_mut()?;
let pending = playing.pending.as_ref()?;
match pending.try_recv() {
Ok(Some(run)) => {
playing.duration = run.duration.or(playing.duration);
playing.run = Some(run);
playing.pending = None;
None
}
Ok(None) | Err(TryRecvError::Disconnected) => {
self.playing = None;
Some(crate::text::media_view::audio::no_player())
}
Err(TryRecvError::Empty) => None,
}
}
/// Looks for a sound that ran out: start over when looping, stop otherwise.
fn poll_end(&mut self, ctx: &Context) {
let Some(playing) = self.playing.as_mut().filter(|playing| !playing.paused) else { return };
let at = playing.position(self.settings.speed);
let Some(run) = playing.run.as_mut() else { return };
let exited = run.sound.as_mut().is_some_and(|sound| matches!(sound.try_wait(), Ok(Some(_))));
let silent_end = run.sound.is_none() && run.duration.is_some_and(|total| at >= total);
if !exited && !silent_end {
return;
}
let failed = run.since.elapsed().as_secs_f32() < FAILED_WITHIN;
if self.settings.looping && !failed {
playing.restart(ctx, &self.settings, 0.0);
} else {
self.stop();
}
}
}
#[cfg(test)]
mod tests {
use super::super::bus::AudioCommand;
use super::*;
#[test]
fn a_new_file_replaces_the_one_that_plays() {
let ctx = Context::default();
let mut player = AudioPlayer::default();
let resolve = |path: &str| Some(PathBuf::from(format!("/nonexistent/{path}")));
player.apply(&ctx, AudioCommand::Play { path: "a.mp3".into(), from: 0.0 }, true, None, &resolve);
assert_eq!(player.current(), Some("a.mp3"));
player.apply(&ctx, AudioCommand::Play { path: "b.ogg".into(), from: 3.0 }, true, None, &resolve);
assert_eq!(player.current(), Some("b.ogg"), "only one file at a time");
player.apply(&ctx, AudioCommand::Stop, true, None, &resolve);
assert_eq!(player.current(), None);
player.apply(&ctx, AudioCommand::Play { path: "c.wav".into(), from: 0.0 }, false, None, &resolve);
assert_eq!(player.current(), None, "without ffplay nothing starts");
}
#[test]
fn a_held_file_keeps_its_position_through_a_seek_and_resumes_from_it() {
let ctx = Context::default();
let mut player = AudioPlayer::default();
let resolve = |path: &str| Some(PathBuf::from(format!("/nonexistent/{path}")));
player.apply(&ctx, AudioCommand::Play { path: "a.mp3".into(), from: 5.0 }, true, None, &resolve);
player.apply(&ctx, AudioCommand::Pause(true), true, None, &resolve);
player.apply(&ctx, AudioCommand::Seek(42.0), true, None, &resolve);
let state = player.state(true);
assert!(state.paused);
assert_eq!(state.position, 42.0);
}
}
@@ -0,0 +1,137 @@
//! The processes behind one started file: `ffplay` for the sound and `ffmpeg` for the spectrum stream,
//! both started on a worker thread so a frame never waits for a spawn.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, SendError};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Instant;
use egui::Context;
use super::spectrum::{Feed, RATE, Ring};
use super::waveform::probe_duration;
use crate::app::video::MAX_VOLUME;
/// Bytes read from the spectrum decoder at a time.
const CHUNK: usize = 8 * 1024;
/// The processes of one started file.
pub(super) struct Run {
pub(super) sound: Option<Child>,
pub(super) decoder: Option<Child>,
pub(super) feed: Option<Feed>,
/// When the clock started.
pub(super) since: Instant,
pub(super) duration: Option<f32>,
}
impl Run {
pub(super) fn stop(&mut self) {
stop_child(&mut self.sound);
stop_child(&mut self.decoder);
}
}
pub(super) fn stop_child(child: &mut Option<Child>) {
if let Some(mut child) = child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
/// What a worker is asked to start.
pub(super) struct Start {
pub(super) file: PathBuf,
pub(super) from: f32,
pub(super) speed: f32,
pub(super) volume: u8,
pub(super) duration: Option<f32>,
}
/// The sound of `file` from `from` seconds at `speed` and `volume`; none at volume 0.
pub(super) fn spawn_sound(file: &Path, from: f32, speed: f32, volume: u8) -> Result<Option<Child>, std::io::Error> {
if volume == 0 {
return Ok(None);
}
Command::new("ffplay")
.args(["-nodisp", "-autoexit", "-loglevel", "quiet", "-vn", "-ss", &format!("{from:.3}"), "-af", &format!("atempo={speed}"), "-volume"])
.arg(volume.min(MAX_VOLUME).to_string())
.arg(file)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(Some)
}
/// The 22 kHz mono stream for the spectrum, throttled to the playback speed, with the thread that
/// fills the ring from it.
fn spawn_decoder(file: &Path, from: f32, speed: f32) -> Option<(Child, Feed)> {
let mut child = Command::new("ffmpeg")
.args(["-v", "error", "-nostdin", "-ss", &format!("{from:.3}"), "-readrate", &speed.to_string(), "-readrate_initial_burst", "0", "-i"])
.arg(file)
.args(["-vn", "-ac", "1", "-ar", &RATE.to_string(), "-f", "f32le", "-"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let Some(mut stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let ring = Arc::new(Mutex::new(Ring::default()));
let sink = ring.clone();
let started = std::thread::Builder::new().name("notescraft-audio-spectrum".to_owned()).spawn(move || {
let mut chunk = vec![0_u8; CHUNK];
let mut carry = 0;
let mut samples = Vec::with_capacity(CHUNK / 4);
while let Some(room) = chunk.get_mut(carry..) {
let Ok(read) = stdout.read(room) else { break };
if read == 0 {
break;
}
let have = carry + read;
let whole = have - have % 4;
samples.clear();
samples.extend(chunk.get(..whole).unwrap_or_default().as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)));
sink.lock().unwrap_or_else(PoisonError::into_inner).push(&samples);
chunk.copy_within(whole..have, 0);
carry = have - whole;
}
});
if started.is_err() {
let _ = child.kill();
let _ = child.wait();
return None;
}
Some((child, Feed { ring, base: from }))
}
/// Probes (unless known) and starts both processes. A missing `ffplay` fails the start; a spectrum
/// decoder that cannot start only leaves the bars at rest.
fn launch(start: &Start) -> Option<Run> {
let duration = start.duration.or_else(|| probe_duration(&start.file));
let sound = spawn_sound(&start.file, start.from, start.speed, start.volume).ok()?;
let (decoder, feed) = match spawn_decoder(&start.file, start.from, start.speed) {
Some((child, feed)) => (Some(child), Some(feed)),
None => (None, None),
};
Some(Run { sound, decoder, feed, since: Instant::now(), duration })
}
pub(super) fn spawn_launch(ctx: &Context, start: Start) -> Option<Receiver<Option<Run>>> {
let (sender, receiver) = std::sync::mpsc::channel::<Option<Run>>();
let worker = ctx.clone();
let spawned = std::thread::Builder::new().name("notescraft-audio-start".to_owned()).spawn(move || {
let run = launch(&start);
if let Err(SendError(Some(mut orphan))) = sender.send(run) {
orphan.stop();
}
worker.request_repaint();
});
spawned.ok().map(|_| receiver)
}
@@ -0,0 +1,107 @@
//! The ring of recent PCM samples the spectrum reads: a decoder's reader thread pushes, the frame
//! reads the window at the playback clock.
use std::sync::{Arc, Mutex};
use super::spectrum::RATE;
/// Seconds of PCM the ring keeps.
const RING_SECONDS: usize = 8;
/// Samples the ring keeps.
const RING_SAMPLES: usize = RATE as usize * RING_SECONDS;
/// Recent mono samples of the file, filled by the decoder's reader thread.
pub struct Ring {
buf: Vec<f32>,
written: u64,
}
impl Default for Ring {
fn default() -> Self {
Self::new(RING_SAMPLES)
}
}
impl Ring {
/// An empty ring that keeps `capacity` samples (at least one).
#[must_use]
pub fn new(capacity: usize) -> Self {
Self { buf: vec![0.0; capacity.max(1)], written: 0 }
}
/// How many samples were pushed in all.
#[must_use]
pub fn written(&self) -> u64 {
self.written
}
/// Appends `samples`, dropping the oldest ones when the ring is full.
pub fn push(&mut self, samples: &[f32]) {
let capacity = self.buf.len() as u64;
for sample in samples {
let slot = usize::try_from(self.written % capacity).unwrap_or(0);
if let Some(cell) = self.buf.get_mut(slot) {
*cell = *sample;
}
self.written = self.written.saturating_add(1);
}
}
/// Fills `out` with the samples just before index `end`; what is not (or no longer) in the ring reads
/// as silence. Returns how many samples were real.
pub fn window(&self, end: u64, out: &mut [f32]) -> usize {
let capacity = self.buf.len() as u64;
let first = end.saturating_sub(out.len() as u64);
let oldest = self.written.saturating_sub(capacity);
let mut real = 0;
for (offset, cell) in out.iter_mut().enumerate() {
let index = first.saturating_add(offset as u64);
*cell = 0.0;
if index < self.written && index >= oldest {
let slot = usize::try_from(index % capacity).unwrap_or(0);
*cell = self.buf.get(slot).copied().unwrap_or(0.0);
real += 1;
}
}
real
}
}
/// The ring of the playing file with the source second its first sample belongs to.
#[derive(Clone)]
pub struct Feed {
/// The samples; the reader thread locks it for a moment per chunk.
pub ring: Arc<Mutex<Ring>>,
/// Source second of sample 0.
pub base: f32,
}
impl Feed {
/// The index of the sample at source second `position`.
#[must_use]
pub fn index_at(&self, position: f32) -> u64 {
let seconds = (position - self.base).max(0.0);
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a non-negative second count times the rate")]
let index = (f64::from(seconds) * f64::from(RATE)) as u64;
index
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ring_keeps_the_newest_samples_and_forgets_the_oldest() {
let mut ring = Ring::new(4);
ring.push(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
let mut out = [9.0; 4];
assert_eq!(ring.window(6, &mut out), 4);
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
let mut old = [9.0; 2];
assert_eq!(ring.window(2, &mut old), 0, "overwritten samples are gone");
assert_eq!(old, [0.0, 0.0]);
let mut ahead = [9.0; 2];
assert_eq!(ring.window(8, &mut ahead), 0, "samples not decoded yet read silence");
}
}
@@ -0,0 +1,323 @@
//! The live spectrum: a ring of recent PCM samples that a decoder fills, the analyzer that turns the
//! window at the playback clock into logarithmic bands, and the bars drawn from them.
use std::sync::{Arc, Mutex, PoisonError};
use egui::{Mesh, Rect, Shape, Ui, Vec2, epaint::Vertex, pos2};
use super::fft::{Fft, hann};
pub use super::ring::{Feed, Ring};
use crate::theme::{Tokens, color};
/// Sample rate of the spectrum stream, Hz.
pub const RATE: u32 = 22_050;
/// Samples in the analysed window (a power of two).
pub const WINDOW: usize = 2048;
/// Number of logarithmic bands drawn.
pub const BANDS: usize = 48;
/// Lowest band edge, Hz.
const LOW_HZ: f32 = 60.0;
/// Highest band edge, Hz.
const HIGH_HZ: f32 = 10_000.0;
/// The dynamic range mapped onto the bar height, decibels below full scale.
const RANGE_DB: f32 = 70.0;
/// Seconds a bar takes to rise most of the way to a louder level.
const ATTACK_S: f32 = 0.035;
/// How far a bar falls per second (1.0 is the full height).
const FALL_PER_S: f32 = 1.6;
/// Seconds a peak tick holds before it falls.
const HOLD_S: f32 = 0.45;
/// How far a peak tick falls per second after its hold.
const PEAK_FALL_PER_S: f32 = 0.8;
/// Below this level a bar counts as at rest.
const REST: f32 = 0.002;
/// Gap between two bars as a fraction of a slot.
const GAP_FRACTION: f32 = 0.28;
/// Height of the peak tick, points.
const TICK: f32 = 2.0;
/// Opacity of the peak tick.
const TICK_ALPHA: f32 = 0.85;
/// The level, 0 to 1, a spectrum magnitude reads as: decibels below a full-scale sine.
#[must_use]
pub fn level_of(magnitude: f32) -> f32 {
#[expect(clippy::cast_precision_loss, reason = "the window size is small")]
let reference = WINDOW as f32 / 4.0;
if !magnitude.is_finite() || magnitude <= 1.0e-9 {
return 0.0;
}
((20.0 * (magnitude / reference).log10() + RANGE_DB) / RANGE_DB).clamp(0.0, 1.0)
}
/// `level` moved toward `target` over `dt` seconds: a fast rise, a slow fall.
#[must_use]
pub fn smooth(level: f32, target: f32, dt: f32) -> f32 {
if target > level { level + (target - level) * (1.0 - (-dt / ATTACK_S).exp()) } else { (level - FALL_PER_S * dt).max(target) }
}
/// The peak tick after `dt` seconds: it jumps up with the bar, holds, then falls. Returns the
/// tick and the hold time left.
#[must_use]
pub fn hold(peak: f32, left: f32, level: f32, dt: f32) -> (f32, f32) {
if level >= peak {
(level, HOLD_S)
} else if left > 0.0 {
(peak, left - dt)
} else {
((peak - PEAK_FALL_PER_S * dt).max(level), 0.0)
}
}
/// The edges of band `index` in Hz (`index` up to [`BANDS`] gives the top edge).
fn edge(index: usize) -> f32 {
#[expect(clippy::cast_precision_loss, reason = "a band index is tiny")]
let fraction = index as f32 / BANDS as f32;
LOW_HZ * (HIGH_HZ / LOW_HZ).powf(fraction)
}
/// The band a frequency falls in, when it is inside the drawn range.
#[must_use]
pub fn band_of(hz: f32) -> Option<usize> {
(0..BANDS).find(|band| hz >= edge(*band) && hz < edge(band + 1))
}
/// Turns windows of samples into smoothed bar levels. All buffers are planned once.
pub struct Analyzer {
fft: Fft,
window: Vec<f32>,
re: Vec<f32>,
im: Vec<f32>,
/// FFT bins `(from, to)` each band takes the loudest of.
ranges: [(usize, usize); BANDS],
/// The smoothed level of each bar, 0 to 1.
pub levels: [f32; BANDS],
/// The peak tick of each bar, 0 to 1.
pub peaks: [f32; BANDS],
left: [f32; BANDS],
}
impl Analyzer {
/// Plans the analyzer; `None` only if the window size were not a power of two.
#[must_use]
pub fn new() -> Option<Self> {
let fft = Fft::new(WINDOW)?;
#[expect(clippy::cast_precision_loss, reason = "small constants")]
let bin_hz = RATE as f32 / WINDOW as f32;
let mut ranges = [(0, 1); BANDS];
for (band, range) in ranges.iter_mut().enumerate() {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "positive bin numbers far below usize")]
let (from, to) = ((edge(band) / bin_hz) as usize, (edge(band + 1) / bin_hz).ceil() as usize);
*range = (from, to.max(from + 1));
}
Some(Self {
fft,
window: hann(WINDOW),
re: vec![0.0; WINDOW],
im: vec![0.0; WINDOW],
ranges,
levels: [0.0; BANDS],
peaks: [0.0; BANDS],
left: [0.0; BANDS],
})
}
/// The target level of each band for the window ending at `end` in `ring`; zeros when it is silent.
pub fn targets(&mut self, ring: &Ring, end: u64) -> [f32; BANDS] {
let mut out = [0.0; BANDS];
if ring.window(end, &mut self.re) == 0 {
return out;
}
for (sample, taper) in self.re.iter_mut().zip(&self.window) {
*sample *= taper;
}
self.im.fill(0.0);
if !self.fft.transform(&mut self.re, &mut self.im) {
return out;
}
for (target, (from, to)) in out.iter_mut().zip(self.ranges) {
let loudest = self.re.iter().zip(&self.im).take(to).skip(from).map(|(r, i)| r.hypot(*i)).fold(0.0_f32, f32::max);
*target = level_of(loudest);
}
out
}
/// Moves the bars toward `targets` over `dt` seconds.
pub fn advance(&mut self, targets: &[f32; BANDS], dt: f32) {
let dt = dt.clamp(0.0, 0.25);
for (((level, peak), left), target) in self.levels.iter_mut().zip(&mut self.peaks).zip(&mut self.left).zip(targets) {
*level = smooth(*level, *target, dt);
(*peak, *left) = hold(*peak, *left, *level, dt);
}
}
/// Whether every bar and tick is at rest, so no frame needs to be asked for.
#[must_use]
pub fn is_at_rest(&self) -> bool {
self.levels.iter().chain(&self.peaks).all(|level| *level < REST)
}
/// Replaces the bars, for the tests and snapshots that draw a known spectrum.
pub fn set(&mut self, levels: &[f32; BANDS]) {
self.levels = *levels;
self.peaks = *levels;
}
}
/// The analyzer kept for the context, shared between frames.
pub type Shared = Arc<Mutex<Option<Analyzer>>>;
/// The context's analyzer, created on first use.
#[must_use]
pub fn shared(ctx: &egui::Context) -> Shared {
let id = egui::Id::new("notescraft-audio-analyzer");
ctx.data_mut(|data| data.get_temp_mut_or_insert_with::<Shared>(id, || Arc::new(Mutex::new(Analyzer::new()))).clone())
}
/// One frame of the analyzer for the playing `feed` at `position` (or silence when `playing` is false).
/// Returns whether another frame is wanted.
pub fn step(ctx: &egui::Context, feed: Option<&Feed>, position: f32, playing: bool, dt: f32) -> bool {
let shared = shared(ctx);
let mut guard = shared.lock().unwrap_or_else(PoisonError::into_inner);
let Some(analyzer) = guard.as_mut() else { return false };
let targets = match feed.filter(|_| playing) {
Some(feed) => {
let ring = feed.ring.lock().unwrap_or_else(PoisonError::into_inner);
// A decoder that runs behind the clock shows its newest samples instead of silence.
let end = feed.index_at(position).min(ring.written());
analyzer.targets(&ring, end)
}
None => [0.0; BANDS],
};
analyzer.advance(&targets, dt);
playing || !analyzer.is_at_rest()
}
/// Draws the bars of the shared analyzer inside `area`: rounded, accent to link from the bottom up,
/// with a peak tick.
pub fn paint(ui: &Ui, tokens: &Tokens, area: Rect) {
let shared = shared(ui.ctx());
let guard = shared.lock().unwrap_or_else(PoisonError::into_inner);
let Some(analyzer) = guard.as_ref() else { return };
paint_bars(ui, tokens, area, &analyzer.levels, &analyzer.peaks);
}
/// Draws `levels` and `peaks` (0 to 1 each) as bars across `area`.
pub fn paint_bars(ui: &Ui, tokens: &Tokens, area: Rect, levels: &[f32; BANDS], peaks: &[f32; BANDS]) {
let colors = &tokens.theme.colors;
let (bottom, top, tick) = (color(colors.accent), color(colors.link), color(colors.text).gamma_multiply(TICK_ALPHA));
#[expect(clippy::cast_precision_loss, reason = "a band count is tiny")]
let slot = area.width() / BANDS as f32;
let width = (slot * (1.0 - GAP_FRACTION)).max(1.0);
let radius = width / 2.0;
let mut mesh = Mesh::default();
let mut circles = Vec::with_capacity(BANDS);
for (index, (level, peak)) in levels.iter().zip(peaks).enumerate() {
#[expect(clippy::cast_precision_loss, reason = "a band index is tiny")]
let left = area.left() + slot * index as f32 + (slot - width) / 2.0;
let height = (area.height() - TICK) * level.clamp(0.0, 1.0);
let reach = height.max(width);
let blend = if area.height() > 0.0 { reach / area.height() } else { 0.0 };
let cap = crate::motion::mix(bottom, top, blend.clamp(0.0, 1.0));
let y_top = area.bottom() - reach;
let y_body = y_top + radius;
let corners =
[(pos2(left, area.bottom()), bottom), (pos2(left + width, area.bottom()), bottom), (pos2(left + width, y_body), cap), (pos2(left, y_body), cap)];
let base = u32::try_from(mesh.vertices.len()).unwrap_or(0);
for (pos, tint) in corners {
mesh.vertices.push(Vertex { pos, uv: egui::epaint::WHITE_UV, color: tint });
}
mesh.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
circles.push(Shape::circle_filled(pos2(left + radius, y_body), radius, cap));
let tick_y = area.bottom() - (area.height() - TICK) * peak.clamp(0.0, 1.0) - TICK;
if *peak > *level + 0.02 && tick_y < y_top - TICK {
circles.push(Shape::rect_filled(Rect::from_min_size(pos2(left, tick_y), Vec2::new(width, TICK)), egui::CornerRadius::same(1), tick));
}
}
let painter = ui.painter_at(area);
painter.add(Shape::mesh(mesh));
painter.extend(circles);
}
#[cfg(test)]
#[expect(clippy::cast_precision_loss, reason = "test sample counts are far below 2^24")]
mod tests {
use super::*;
fn sine(hz: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| (std::f32::consts::TAU * hz * i as f32 / RATE as f32).sin()).collect()
}
fn fed(hz: f32) -> (Analyzer, Ring) {
let mut ring = Ring::default();
ring.push(&sine(hz, WINDOW * 2));
(Analyzer::new().expect("planned"), ring)
}
#[test]
fn a_440_hz_sine_lights_the_band_that_holds_440() {
let (mut analyzer, ring) = fed(440.0);
let targets = analyzer.targets(&ring, WINDOW as u64 * 2);
let loudest = targets.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).map(|(i, _)| i);
let expected = band_of(440.0).expect("440 Hz is in range");
assert!(loudest.is_some_and(|band| band.abs_diff(expected) <= 1), "{loudest:?} vs {expected}");
assert!(targets[expected] > 0.85, "a full-scale sine is near the top: {}", targets[expected]);
assert!(targets[BANDS - 1] < 0.45, "far bands stay low: {}", targets[BANDS - 1]);
}
#[test]
fn silence_and_a_window_before_any_sample_read_zero() {
let mut analyzer = Analyzer::new().expect("planned");
let mut ring = Ring::default();
assert!(analyzer.targets(&ring, 5000).iter().all(|t| *t == 0.0), "nothing decoded yet");
ring.push(&[0.0; WINDOW]);
assert!(analyzer.targets(&ring, WINDOW as u64).iter().all(|t| *t == 0.0), "silence");
}
#[test]
fn bars_rise_fast_and_fall_slowly_to_zero() {
let up = smooth(0.0, 1.0, 1.0 / 60.0);
assert!(up > 0.3, "{up}");
let down = smooth(1.0, 0.0, 1.0 / 60.0);
assert!(down > 0.9 && down < 1.0, "{down}");
let mut level = 1.0;
for _ in 0..120 {
level = smooth(level, 0.0, 1.0 / 60.0);
}
assert_eq!(level, 0.0, "after two seconds it rests");
}
#[test]
fn the_peak_tick_holds_then_falls() {
let (peak, left) = hold(0.0, 0.0, 0.8, 0.016);
assert_eq!(peak, 0.8);
let (peak, left) = hold(peak, left, 0.2, 0.2);
assert_eq!(peak, 0.8, "held");
let (peak, left) = hold(peak, left, 0.2, 0.3);
assert_eq!(peak, 0.8, "hold ends this step");
let (peak, _) = hold(peak, left, 0.2, 0.2);
assert!((0.2..0.8).contains(&peak), "falls after the hold: {peak}");
}
#[test]
fn the_analyzer_falls_back_to_rest_when_paused() {
let (mut analyzer, ring) = fed(1000.0);
let on = analyzer.targets(&ring, WINDOW as u64 * 2);
for _ in 0..10 {
analyzer.advance(&on, 1.0 / 60.0);
}
assert!(!analyzer.is_at_rest());
for _ in 0..300 {
analyzer.advance(&[0.0; BANDS], 1.0 / 60.0);
}
assert!(analyzer.is_at_rest());
}
#[test]
fn bands_are_logarithmic_between_60_hz_and_10_khz() {
assert_eq!(band_of(59.0), None);
assert_eq!(band_of(60.0), Some(0));
assert_eq!(band_of(9999.0), Some(BANDS - 1));
assert_eq!(band_of(10_000.0), None);
assert!(band_of(1000.0) > band_of(200.0));
}
}
@@ -0,0 +1,333 @@
//! The static waveform: the whole file decoded once at 8 kHz mono on a worker thread, reduced to
//! min/max peak columns and cached per file (at most [`MAX_FILES`]), then drawn as bars that seek.
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant, SystemTime};
use egui::{Context, FontId, Id, Rect, Sense, Ui, Vec2};
pub use super::peaks::{COLUMNS, Peaks, probe_duration};
use super::peaks::{decode, merge};
use crate::text::media_view::audio as strings;
use crate::theme::{Tokens, color};
/// Files whose peaks stay cached.
pub const MAX_FILES: usize = 16;
/// Decodes that may run at once.
const MAX_WORKERS: usize = 2;
/// How often a cached entry checks that its file did not change, seconds.
const RECHECK_SECONDS: u64 = 2;
/// Width of one bar, points.
const BAR: f32 = 3.0;
/// Gap between two bars, points.
const BAR_GAP: f32 = 1.5;
/// The shortest bar, points.
const MIN_BAR: f32 = 2.0;
/// Width of the playhead, points.
const HEAD: f32 = 2.0;
/// Opacity of the part not played yet.
const UNPLAYED: f32 = 0.55;
/// Text size of the hover time, points.
const HOVER_TEXT: f32 = 11.0;
/// Longest the skeleton waits between two animation frames.
const SKELETON_FRAME: Duration = Duration::from_millis(80);
/// How long the pending decode is re-asked for when all workers are busy.
const BUSY_RETRY: Duration = Duration::from_millis(250);
/// The size and time of a file, to notice that it changed.
type Stamp = (u64, Option<SystemTime>);
fn stamp(file: &Path) -> Option<Stamp> {
let meta = std::fs::metadata(file).ok()?;
Some((meta.len(), meta.modified().ok()))
}
enum Slot {
Loading,
Ready(Arc<Peaks>),
Failed,
}
struct Entry {
path: String,
stamp: Stamp,
checked: Instant,
slot: Slot,
duration: Option<f32>,
}
/// The cache of analyses: at most [`MAX_FILES`] entries, oldest dropped first, and a count of the
/// decodes running.
#[derive(Default)]
pub struct Library {
entries: Vec<Entry>,
running: usize,
}
/// What the cache knows about a file.
#[derive(Clone, Default)]
pub struct Analysis {
/// The peaks, once decoded.
pub peaks: Option<Arc<Peaks>>,
/// The length in seconds, once probed.
pub duration: Option<f32>,
/// The decode failed.
pub failed: bool,
/// Waiting for a worker slot or for the decode.
pub waiting: bool,
}
impl Library {
/// Drops the oldest finished entries until there is room for one more.
fn make_room(&mut self) {
while self.entries.len() >= MAX_FILES {
let Some(oldest) = self.entries.iter().position(|entry| !matches!(entry.slot, Slot::Loading)) else { return };
self.entries.remove(oldest);
}
}
fn find(&mut self, path: &str) -> Option<&mut Entry> {
self.entries.iter_mut().find(|entry| entry.path == path)
}
/// Number of files cached.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether nothing is cached.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
type Shared = Arc<Mutex<Library>>;
fn library(ctx: &Context) -> Shared {
ctx.data_mut(|data| data.get_temp_mut_or_insert_with::<Shared>(Id::new("notescraft-audio-library"), Shared::default).clone())
}
/// The analysis of the vault file `path` (on disk at `file`): starts the worker the first time and
/// answers from the cache afterwards.
pub fn analysis(ctx: &Context, path: &str, file: Option<&Path>) -> Analysis {
let Some(file) = file else { return Analysis { failed: true, ..Analysis::default() } };
let shared = library(ctx);
let mut lib = shared.lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
if let Some(entry) = lib.find(path)
&& now.duration_since(entry.checked).as_secs() >= RECHECK_SECONDS
&& !matches!(entry.slot, Slot::Loading)
{
entry.checked = now;
if stamp(file) != Some(entry.stamp) {
lib.entries.retain(|entry| entry.path != path);
}
}
if let Some(entry) = lib.find(path) {
return match &entry.slot {
Slot::Loading => Analysis { duration: entry.duration, waiting: true, ..Analysis::default() },
Slot::Ready(peaks) => Analysis { peaks: Some(peaks.clone()), duration: entry.duration, ..Analysis::default() },
Slot::Failed => Analysis { duration: entry.duration, failed: true, ..Analysis::default() },
};
}
let Some(seen) = stamp(file) else { return Analysis { failed: true, ..Analysis::default() } };
if lib.running >= MAX_WORKERS {
ctx.request_repaint_after(BUSY_RETRY);
return Analysis { waiting: true, ..Analysis::default() };
}
lib.make_room();
lib.running += 1;
lib.entries.push(Entry { path: path.to_owned(), stamp: seen, checked: now, slot: Slot::Loading, duration: None });
drop(lib);
spawn(ctx, &shared, path, file, seen);
Analysis { waiting: true, ..Analysis::default() }
}
fn spawn(ctx: &Context, shared: &Shared, path: &str, file: &Path, seen: Stamp) {
let job = (ctx.clone(), shared.clone(), path.to_owned());
let file = file.to_path_buf();
let started = std::thread::Builder::new().name("notescraft-audio-peaks".to_owned()).spawn(move || {
let (ctx, shared, path) = job;
let update = |change: &dyn Fn(&mut Entry)| {
let mut lib = shared.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(entry) = lib.find(&path).filter(|entry| entry.stamp == seen) {
change(entry);
}
};
let duration = probe_duration(&file);
update(&|entry| entry.duration = duration);
ctx.request_repaint();
let peaks = decode(&file).map(Arc::new);
let covered = peaks.as_ref().map(|peaks| peaks.seconds);
update(&|entry| {
entry.duration = entry.duration.or(covered);
entry.slot = peaks.clone().map_or(Slot::Failed, Slot::Ready);
});
let mut lib = shared.lock().unwrap_or_else(PoisonError::into_inner);
lib.running = lib.running.saturating_sub(1);
drop(lib);
ctx.request_repaint();
});
if let Err(error) = started {
log::warn!("audio: the waveform worker did not start: {error}");
let mut lib = shared.lock().unwrap_or_else(PoisonError::into_inner);
lib.running = lib.running.saturating_sub(1);
if let Some(entry) = lib.find(path) {
entry.slot = Slot::Failed;
}
}
}
/// The second under a pointer at `x` on a waveform from `left`, `width` wide, of `duration` seconds.
#[must_use]
pub fn time_at(x: f32, left: f32, width: f32, duration: f32) -> f32 {
super::super::seek::seek_position(x, left, width, duration)
}
/// What the waveform answered this frame.
#[derive(Debug, Clone, Copy, Default)]
pub struct Hit {
/// The second chosen by a click or by letting go of a drag.
pub chosen: Option<f32>,
/// The second to show as the playhead while dragging.
pub preview: Option<f32>,
/// The second under the pointer.
pub hover: Option<f32>,
}
/// What to draw on the waveform.
pub struct Wave<'a> {
/// Stable widget name; also the accessible label.
pub id: &'a str,
/// The peaks, when decoded.
pub peaks: Option<&'a Peaks>,
/// The decode failed.
pub failed: bool,
/// Seconds played.
pub position: f32,
/// Length in seconds, when known (without it nothing can be sought).
pub duration: Option<f32>,
/// Show the time under the pointer.
pub hover_time: bool,
}
/// Draws the waveform in a rectangle of `size` and answers a click or a drag.
pub fn show(ui: &mut Ui, tokens: &Tokens, size: Vec2, wave: &Wave<'_>) -> Hit {
let colors = &tokens.theme.colors;
let (area, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
let total = wave.duration.filter(|seconds| *seconds > 0.0);
let at = |x: f32| total.map(|seconds| time_at(x, area.left(), area.width(), seconds));
let mut hit = Hit { hover: response.hover_pos().and_then(|pointer| at(pointer.x)), ..Hit::default() };
if let Some(pointer) = response.interact_pointer_pos() {
if response.dragged() {
hit.preview = at(pointer.x);
}
if response.clicked() || response.drag_stopped() {
hit.chosen = at(pointer.x);
}
}
let shown = hit.preview.unwrap_or(wave.position);
let fraction = total.map_or(0.0, |seconds| (shown / seconds).clamp(0.0, 1.0));
let head = area.left() + area.width() * fraction;
let painter = ui.painter_at(area);
let mid = area.center().y;
let slot = BAR + BAR_GAP;
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a positive width over a few points")]
let count = ((area.width() / slot).floor() as usize).max(1);
let played = color(colors.accent);
let rest = color(colors.text_muted).gamma_multiply(UNPLAYED);
let radius = egui::CornerRadius::same(crate::style::radius_u8(BAR / 2.0));
if let Some(peaks) = wave.peaks {
let covered = total.map_or(1.0, |seconds| (peaks.seconds / seconds).clamp(0.0, 1.0));
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, reason = "a positive bar count scaled by a fraction")]
let drawn = ((count as f32 * covered).round() as usize).max(1);
for (index, (min, max)) in merge(&peaks.columns, drawn).into_iter().enumerate() {
#[expect(clippy::cast_precision_loss, reason = "a bar index is tiny")]
let x = area.left() + slot * index as f32 + BAR / 2.0;
let height = ((max - min) * area.height() * 0.5).max(MIN_BAR);
let centre = mid - (max + min) * area.height() * 0.25;
let bar = Rect::from_center_size(egui::pos2(x, centre), Vec2::new(BAR, height.min(area.height())));
painter.rect_filled(bar, radius, if x <= head { played } else { rest });
}
} else if wave.failed {
painter.text(
area.center(),
egui::Align2::CENTER_CENTER,
strings::waveform_failed(),
FontId::proportional(crate::style::TEXT_TINY),
color(colors.text_muted),
);
} else {
skeleton(ui, tokens, &painter, area, count);
}
if total.is_some() && wave.peaks.is_some() {
painter.rect_filled(Rect::from_center_size(egui::pos2(head, mid), Vec2::new(HEAD, area.height())), egui::CornerRadius::same(1), color(colors.text));
}
if wave.hover_time && !response.dragged() {
paint_hover(ui, tokens, area, &response, hit.hover);
}
let label = wave.id.to_owned();
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, label.clone()));
hit
}
/// A guide line and the time bubble at the pointer.
fn paint_hover(ui: &Ui, tokens: &Tokens, area: Rect, response: &egui::Response, hover: Option<f32>) {
let (Some(seconds), Some(pointer)) = (hover, response.hover_pos()) else { return };
let colors = &tokens.theme.colors;
let layer = ui.painter_at(area.expand(HOVER_TEXT * 2.0));
layer.line_segment(
[egui::pos2(pointer.x, area.top()), egui::pos2(pointer.x, area.bottom())],
egui::Stroke::new(1.0, color(colors.text_muted).gamma_multiply(0.6)),
);
let galley = layer.layout_no_wrap(super::super::controls::clock(seconds), FontId::proportional(HOVER_TEXT), color(colors.text));
let pad = Vec2::new(6.0, 2.0);
let size = galley.size() + pad * 2.0;
let x = (pointer.x - size.x / 2.0).clamp(area.left(), (area.right() - size.x).max(area.left()));
let bubble = Rect::from_min_size(egui::pos2(x, area.top()), size);
layer.rect_filled(bubble, egui::CornerRadius::same(crate::style::radius_u8(tokens.radius_small())), color(colors.surface_2));
layer.galley(bubble.min + pad, galley, color(colors.text));
}
/// Grey bars that breathe while the peaks are decoded.
fn skeleton(ui: &Ui, tokens: &Tokens, painter: &egui::Painter, area: Rect, count: usize) {
let slot = BAR + BAR_GAP;
#[expect(clippy::cast_possible_truncation, reason = "the clock only needs to be smooth")]
let time = ui.input(|input| input.time) as f32;
let ink = color(tokens.theme.colors.border);
for index in 0..count {
#[expect(clippy::cast_precision_loss, reason = "a bar index is tiny")]
let (phase, x) = (index as f32 * 0.35 - time * 3.0, area.left() + slot * index as f32 + BAR / 2.0);
let height = area.height() * (0.18 + 0.12 * (phase.sin() + 1.0));
painter.rect_filled(Rect::from_center_size(egui::pos2(x, area.center().y), Vec2::new(BAR, height)), egui::CornerRadius::same(1), ink);
}
ui.ctx().request_repaint_after(SKELETON_FRAME);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pointer_seeks_in_proportion_over_the_waveform() {
assert_eq!(time_at(150.0, 100.0, 200.0, 60.0), 15.0);
assert_eq!(time_at(0.0, 100.0, 200.0, 60.0), 0.0);
assert_eq!(time_at(900.0, 100.0, 200.0, 60.0), 60.0);
}
#[test]
fn the_cache_keeps_sixteen_files_and_never_drops_a_running_decode() {
let mut lib = Library::default();
for index in 0..40 {
lib.make_room();
let slot = if index == 0 { Slot::Loading } else { Slot::Failed };
lib.entries.push(Entry { path: format!("{index}.mp3"), stamp: (0, None), checked: Instant::now(), slot, duration: None });
}
assert_eq!(lib.len(), MAX_FILES);
assert!(lib.find("0.mp3").is_some(), "the entry still loading survives");
assert!(lib.find("39.mp3").is_some());
}
}
@@ -7,6 +7,7 @@
//! Stop button stops it.
pub(crate) mod actions;
pub mod audio;
pub(crate) mod bar;
mod controls;
pub mod pdf;
@@ -38,7 +38,7 @@ mod tests {
#[test]
fn the_command_names_the_file_or_its_folder() {
let Some((program, args)) = reveal_command(Path::new("/vault/media/clip.mp4")) else { return };
assert!(!program.is_empty());
assert_ne!(program.len(), 0);
assert!(args.iter().any(|arg| arg.to_string_lossy().contains("media")), "{args:?}");
if cfg!(target_os = "macos") {
assert_eq!(args.first().map(|arg| arg.to_string_lossy().into_owned()), Some(String::from("-R")));
+1 -1
View File
@@ -32,7 +32,7 @@ pub use blocks::paragraph::set_strict as set_strict_breaks;
pub use blocks::query::{MAX_ROWS as MAX_QUERY_ROWS, QueryResults, queries_in, set_results as set_query_results};
pub use blocks::rich::image_targets;
use blocks::rich::text_line::render_text_line;
pub use blocks::{EmbedNotes, dismissed_offers, set_dismissed_offers, set_embeds, take_grammar_request};
pub use blocks::{EmbedNotes, dismissed_offers, request_grammar, set_dismissed_offers, set_embeds, take_grammar_request};
use blocks::{callout_card, code_block, display_math, properties_card, table};
pub use edit::NoteEdit;
pub use inlines::tag_target;
+4 -1
View File
@@ -5,23 +5,26 @@ mod callout_body;
mod code_actions;
mod code_card;
#[cfg(test)]
#[path = "blocks/tests/code_card_tests.rs"]
mod code_card_tests;
mod embed;
mod embed_data;
#[cfg(test)]
#[path = "blocks/tests/fold_tests.rs"]
mod fold_tests;
pub(super) mod footnotes;
pub(super) mod list_fold;
pub mod paragraph;
pub(super) mod query;
#[cfg(test)]
#[path = "blocks/tests/render_tests.rs"]
mod render_tests;
pub(super) mod rich;
pub(crate) mod table;
mod tree_card;
pub(super) use code_actions::take_edit as code_card_edit;
pub use code_actions::{dismissed_offers, set_dismissed_offers, take_grammar_request};
pub use code_actions::{dismissed_offers, request_grammar, set_dismissed_offers, take_grammar_request};
pub(super) use code_card::take_click as code_card_clicked;
pub use embed::set_embeds;
pub(super) use embed::{Call, details, show as show_embed};
@@ -169,7 +169,7 @@ fn grammar_id() -> Id {
}
/// Asks the app to open the grammar dialog for `label`.
pub(super) fn request_grammar(ctx: &Context, label: &str) {
pub fn request_grammar(ctx: &Context, label: &str) {
ctx.data_mut(|data| data.insert_temp(grammar_id(), label.to_owned()));
}
@@ -112,7 +112,7 @@ fn media_kind(path: &str) -> Option<&'static str> {
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
match extension.as_str() {
"pdf" => Some(strings::media_pdf()),
"mp3" | "wav" | "ogg" | "oga" | "flac" | "m4a" | "aac" | "opus" => Some(strings::media_audio()),
"mp3" | "wav" | "ogg" | "oga" | "flac" | "m4a" | "aac" | "opus" | "3gp" | "weba" => Some(strings::media_audio()),
"mp4" | "mov" | "mkv" | "webm" | "ogv" | "avi" => Some(strings::media_video()),
_ => None,
}
@@ -124,6 +124,14 @@ fn media_card(ui: &mut Ui, tokens: &Tokens, path: &str, response: &mut NoteViewR
let colors = &tokens.theme.colors;
let kind = media_kind(path).unwrap_or_default();
let is_video = kind == strings::media_video();
if kind == strings::media_audio() {
// Audio plays inside the note: a compact player card with a mini waveform.
if crate::media_view::audio::embed::show(ui, tokens, path) {
response.open_file = Some(path.to_owned());
}
ui.add_space(GAP_BELOW);
return;
}
let video = crate::app::video::state(ui.ctx());
if is_video {
// The playing frame, else a still from the file.
@@ -161,12 +169,6 @@ fn media_card(ui: &mut Ui, tokens: &Tokens, path: &str, response: &mut NoteViewR
crate::app::video::request(ui.ctx(), path);
}
}
if kind == strings::media_audio() {
let playing = crate::app::player::playing(ui.ctx()).as_deref() == Some(path);
if ui.button(if playing { strings::media_stop() } else { strings::media_play() }).clicked() {
crate::app::player::request(ui.ctx(), path);
}
}
});
});
});
@@ -27,7 +27,7 @@ pub(in crate::views::note_view) const INLINE_IMAGE_EM: f32 = 1.2;
/// Widest picture, in points, that is drawn inside the text line as an icon.
pub(in crate::views::note_view) const ICON_MAX: f32 = 32.0;
/// Extensions of the pictures an embed shows.
const IMAGE_EXTENSIONS: [&str; 7] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
const IMAGE_EXTENSIONS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
/// Marks the source of a [`Span::Math`] that stands for a picture in the text.
const INLINE_IMAGE: &str = "\u{1}image\u{1}";
/// Nesting of styled HTML tags that is followed; deeper tags are dropped.
+24 -3
View File
@@ -66,11 +66,11 @@ pub enum TabKind {
}
/// Image extensions.
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif"];
/// Video extensions.
const VIDEO_EXTS: &[&str] = &["mp4", "webm", "mov", "mkv", "m4v"];
const VIDEO_EXTS: &[&str] = &["mp4", "webm", "mov", "mkv", "m4v", "ogv"];
/// Audio extensions.
const AUDIO_EXTS: &[&str] = &["mp3", "wav", "ogg", "m4a", "flac", "opus", "aac"];
const AUDIO_EXTS: &[&str] = &["mp3", "wav", "ogg", "m4a", "flac", "opus", "aac", "3gp"];
/// Source code extensions.
const CODE_EXTS: &[&str] = &[
"rs", "py", "js", "mjs", "ts", "tsx", "jsx", "go", "java", "kt", "c", "h", "cpp", "hpp", "cc", "cs", "rb", "php", "sh", "bash", "zsh", "fish", "lua",
@@ -183,4 +183,25 @@ mod kind_tests {
assert!(TabKind::of("blob.xyz").is_binary());
assert!(!TabKind::of("main.rs").is_binary());
}
#[test]
fn source_files_are_never_diagrams_and_obsidian_formats_open() {
for (path, kind) in [
("ar/publish/publish.css", TabKind::Code),
("publish.JS", TabKind::Code),
("a.json", TabKind::Text),
("a.toml", TabKind::Text),
("a.yaml", TabKind::Text),
("a.mmd", TabKind::Diagram),
("favicon.ico", TabKind::Image),
("a.avif", TabKind::Image),
("a.ogv", TabKind::Video),
("a.webm", TabKind::Video),
("a.mkv", TabKind::Video),
("a.3gp", TabKind::Audio),
("a.flac", TabKind::Audio),
] {
assert_eq!(TabKind::of(path), kind, "{path}");
}
}
}
+135
View File
@@ -0,0 +1,135 @@
//! The Activity tab of the right card, driven headless: the feed newest
//! first, the unread count on the tab, the badge of an unread event and a
//! click that opens the note at its line.
use egui_kittest::kittest::Queryable;
use notescraft_themes::builtin;
use notescraft_ui_egui::activity_panel::{ActivityData, ActivityResponse, ActivityTarget, parse_feed};
use notescraft_ui_egui::right_view::{RightInputs, RightTab};
use notescraft_ui_egui::theme::Tokens;
use serde_json::json;
/// The panel over a feed of the vault, with the answer of the server as data.
struct Panel {
/// What the panel draws.
data: ActivityData,
/// The theme of the app.
tokens: Tokens,
/// What the user pressed.
pressed: Option<ActivityResponse>,
/// Whether the fonts are in, which takes one pass.
warmed: bool,
}
/// The answer of `activity.list`: two events, the older one unread.
fn answer() -> serde_json::Value {
json!({
"events": [
{ "id": 1, "ts_ms": 10, "kind": "mention", "actor": "u-ana", "actor_name": "Ana",
"note": "notes/a.md", "thread_id": "c-1", "line": 3, "read": false },
{ "id": 2, "ts_ms": 20, "kind": "comment", "actor": "u-bob", "actor_name": "Bob",
"note": "notes/b.md", "read": true }
],
"head": 2,
"more": false,
"unread": 1
})
}
/// The panel over the feed of the answer.
fn panel() -> egui_kittest::Harness<'static, Panel> {
let feed = parse_feed(&answer());
let data = ActivityData { events: feed.events, unread: feed.unread, loaded: true, unbound: false };
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
egui_kittest::Harness::builder().with_size(egui::Vec2::new(420.0, 900.0)).with_max_steps(200).build_ui_state(
|ui, state: &mut Panel| {
if !state.warmed {
// Fonts bind one pass after installing; the first pass warms only.
notescraft_ui_egui::fonts::install(ui.ctx());
state.warmed = true;
return;
}
let response = notescraft_ui_egui::activity_panel::show(ui, &state.tokens, &state.data);
if response.open.is_some() || response.mark_read.is_some() {
state.pressed = Some(response);
}
},
Panel { data, tokens, pressed: None, warmed: false },
)
}
/// The feed lists the events newest first.
#[test]
fn the_feed_is_listed_newest_first() {
let mut harness = panel();
harness.run();
assert_eq!(harness.query_all_by_label("Ana").count(), 1, "the mention is listed");
assert_eq!(harness.query_all_by_label("Bob").count(), 1, "the comment is listed");
let mention = harness.get_by_label("Ana").rect();
let comment = harness.get_by_label("Bob").rect();
assert!(comment.min.y < mention.min.y, "the newest event comes first");
}
/// The unread count is shown, and the button that marks the feed read.
#[test]
fn the_unread_count_is_shown() {
let mut harness = panel();
harness.run();
assert_eq!(harness.query_all_by_label("1 unread").count(), 1, "the panel says how much is unread");
assert_eq!(harness.query_all_by_label("Mark all as read").count(), 1, "the feed can be marked read");
}
/// Clicking the note of an event opens it at its line.
#[test]
fn a_click_opens_the_note_at_its_line() {
let mut harness = panel();
harness.run();
harness.get_by_label("notes/a.md:3").click();
harness.run();
let open = harness.state().pressed.clone().expect("the note is opened").open.expect("the note is opened");
assert_eq!(open, ActivityTarget { path: "notes/a.md".to_owned(), line: Some(3) });
}
/// The button marks the unread events read.
#[test]
fn the_button_marks_the_events_read() {
let mut harness = panel();
harness.run();
harness.get_by_label("Mark all as read").click();
harness.run();
let marked = harness.state().pressed.clone().expect("the feed is marked read").mark_read;
assert_eq!(marked, Some(vec![1]), "the unread event is the one marked");
}
/// The tab of the card shows the unread count of the feed.
#[test]
fn the_tab_shows_the_unread_count() {
let feed = parse_feed(&answer());
let data = ActivityData { events: feed.events, unread: 3, loaded: true, unbound: false };
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
let mut harness = egui_kittest::Harness::builder().with_size(egui::Vec2::new(420.0, 200.0)).with_max_steps(200).build_ui_state(
|ui, state: &mut (Tokens, ActivityData, bool)| {
let (tokens, data, warmed) = state;
if !*warmed {
// Fonts bind one pass after installing; the first pass warms only.
notescraft_ui_egui::fonts::install(ui.ctx());
*warmed = true;
return;
}
let inputs = RightInputs {
backlinks: &notescraft_ui_egui::backlinks_panel::BacklinksData::default(),
outline: &[],
active_entry: None,
related: &notescraft_ui_egui::related_panel::RelatedData::default(),
lint: &notescraft_ui_egui::lint_panel::LintData::default(),
comments: &notescraft_ui_egui::comment_panel::CommentData::default(),
activity: data,
};
let mut tab = RightTab::Activity;
let _ = notescraft_ui_egui::right_view::draw(ui, tokens, &mut tab, &inputs, true);
},
(tokens, data, false),
);
harness.run();
assert_eq!(harness.query_all_by_label("3").count(), 1, "the badge of the tab shows the unread count");
}
+375
View File
@@ -0,0 +1,375 @@
//! The audio tab and the audio embed, drawn headless: the controls and the waveform area are there,
//! Space asks the player for a start, the waveform fills from a real decode, and an ignored test
//! plays a second of sound through `ffplay`.
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui_kittest::kittest::Queryable;
use notescraft_engine::Session;
use notescraft_themes::builtin;
use notescraft_ui_egui::media_view::audio::bus::{AudioCommand, AudioState, commands_id, state_id};
use notescraft_ui_egui::media_view::audio::spectrum::{self, Feed, Ring};
use notescraft_ui_egui::media_view::audio::{self, waveform};
use notescraft_ui_egui::theme::Tokens;
const RATE: u32 = 44_100;
const SECONDS: u32 = 5;
/// The length of the sweep, in seconds, as a float.
const SECONDS_F: f32 = 5.0;
/// The time of sample `index` at `rate` samples per second, without lossy integer casts.
fn seconds_at(index: u32, rate: u32) -> f32 {
let whole = f32::from(u16::try_from(index / rate).unwrap_or(u16::MAX));
let part = f32::from(u16::try_from(index % rate).unwrap_or(0)) / f32::from(u16::try_from(rate).unwrap_or(1));
whole + part
}
/// A float sample as a 16-bit one, clamped to the range of the type.
#[allow(clippy::cast_possible_truncation, reason = "the value is clamped to the i16 range first")]
fn quantize(value: f32) -> i16 {
value.round().clamp(f32::from(i16::MIN), f32::from(i16::MAX)) as i16
}
fn tokens() -> Tokens {
Tokens::new(builtin("catppuccin-mocha").expect("the built-in theme"))
}
/// The sample `t` seconds into a 150 Hz to 3.5 kHz sweep (inside the 4 kHz the waveform stream keeps), loud in the middle.
fn sweep(t: f32) -> f32 {
let phase = std::f32::consts::TAU * (150.0 * t + 335.0 * t * t);
phase.sin() * (0.2 + 0.8 * (std::f32::consts::PI * t / SECONDS_F).sin())
}
/// A 16-bit mono WAV of the sweep.
fn wav_bytes() -> Vec<u8> {
let samples: Vec<i16> = (0..RATE * SECONDS).map(|i| quantize(sweep(seconds_at(i, RATE)) * 30_000.0)).collect();
let data = u32::try_from(samples.len() * 2).expect("small");
let mut out = Vec::new();
out.extend(b"RIFF");
out.extend((36 + data).to_le_bytes());
out.extend(b"WAVEfmt ");
out.extend(16_u32.to_le_bytes());
out.extend(1_u16.to_le_bytes());
out.extend(1_u16.to_le_bytes());
out.extend(RATE.to_le_bytes());
out.extend((RATE * 2).to_le_bytes());
out.extend(2_u16.to_le_bytes());
out.extend(16_u16.to_le_bytes());
out.extend(b"data");
out.extend(data.to_le_bytes());
out.extend(samples.iter().flat_map(|sample| sample.to_le_bytes()));
out
}
fn session_with_wav(name: &str) -> Session {
let root = std::env::temp_dir().join(format!("notescraft-audio-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::create_dir_all(&root);
let _ = std::fs::write(root.join("song.wav"), wav_bytes());
Session::open(&root).expect("a temp vault opens")
}
fn idle(available: bool) -> AudioState {
AudioState {
available,
path: None,
starting: false,
position: 0.0,
duration: None,
speed: 1.0,
volume: 100,
muted: false,
looping: false,
paused: false,
feed: None,
}
}
fn has_ffmpeg() -> bool {
std::process::Command::new("ffmpeg").arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().is_ok()
}
/// A harness over the audio tab of `song.wav`, with the state `state` published each frame.
fn harness(name: &str, state: impl Fn(&egui::Context) -> AudioState + 'static) -> egui_kittest::Harness<'static, bool> {
let session = session_with_wav(name);
let tokens = tokens();
egui_kittest::Harness::builder().with_size(egui::Vec2::new(1100.0, 700.0)).build_ui_state(
move |ui, warmed: &mut bool| {
if !*warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
*warmed = true;
return;
}
let published = state(ui.ctx());
ui.ctx().data_mut(|data| data.insert_temp(state_id(), published));
audio::show(ui, &tokens, &session, "song.wav");
},
false,
)
}
#[test]
fn the_audio_tab_has_its_controls_and_a_waveform_and_space_asks_for_a_start() {
let mut harness = harness("controls", |_| idle(true));
harness.run_steps(4);
for label in [
"Play",
"Back 10 seconds",
"Forward 10 seconds",
"Play from the start",
"Loop",
"Mute",
"Spectrum",
"Waveform",
"Frequency spectrum",
"Open with the system",
"Show in the file manager",
"Copy the path",
] {
assert!(harness.get_all_by_label_contains(label).count() >= 1, "the {label} control is there");
}
harness.key_press(egui::Key::Space);
harness.step();
let queued = harness.ctx.data(|data| data.get_temp::<Vec<AudioCommand>>(commands_id())).unwrap_or_default();
assert!(matches!(queued.first(), Some(AudioCommand::Play { path, from }) if path == "song.wav" && *from == 0.0), "{queued:?}");
}
#[test]
fn without_ffplay_the_tab_offers_the_system_player() {
let mut harness = harness("noplayer", |_| idle(false));
harness.run_steps(3);
assert!(harness.get_all_by_label_contains("ffplay is not installed").count() >= 1);
assert_eq!(harness.get_all_by_label("Open with the system").count(), 1);
}
#[test]
fn the_main_button_holds_a_playing_file_and_the_volume_keys_step_the_volume() {
let mut harness = harness("hold", |_| AudioState { path: Some("song.wav".into()), position: 2.0, duration: Some(5.0), ..idle(true) });
harness.run_steps(3);
harness.get_by_label("Pause").click();
harness.step();
let queued = harness.ctx.data(|data| data.get_temp::<Vec<AudioCommand>>(commands_id())).unwrap_or_default();
assert!(queued.contains(&AudioCommand::Pause(true)), "{queued:?}");
harness.key_press(egui::Key::ArrowDown);
harness.key_press(egui::Key::M);
harness.step();
let queued = harness.ctx.data(|data| data.get_temp::<Vec<AudioCommand>>(commands_id())).unwrap_or_default();
assert!(queued.contains(&AudioCommand::Volume(90)) && queued.contains(&AudioCommand::Mute(true)), "{queued:?}");
}
#[test]
fn the_waveform_fills_from_a_real_decode_and_is_cached() {
if !has_ffmpeg() {
return;
}
let mut harness = harness("peaks", |_| idle(true));
let session_root = std::env::temp_dir().join(format!("notescraft-audio-peaks-{}", std::process::id())).join("song.wav");
let started = Instant::now();
let peaks = loop {
harness.run_steps(2);
let seen = waveform::analysis(&harness.ctx, "song.wav", Some(&session_root));
if let Some(peaks) = seen.peaks {
assert!((seen.duration.unwrap_or(0.0) - SECONDS_F).abs() < 0.1, "the probe read the length: {:?}", seen.duration);
break peaks;
}
assert!(started.elapsed() < Duration::from_secs(10), "the decode did not finish");
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(peaks.columns.len(), waveform::COLUMNS);
assert!((peaks.seconds - SECONDS_F).abs() < 0.1);
let loudest = peaks.columns.iter().fold(0.0_f32, |top, (min, max)| top.max(min.abs()).max(*max));
assert!((loudest - 1.0).abs() < 1e-3, "normalised: {loudest}");
let again = Instant::now();
assert!(waveform::analysis(&harness.ctx, "song.wav", Some(&session_root)).peaks.is_some(), "the second look is instant");
assert!(again.elapsed() < Duration::from_millis(50));
}
#[test]
fn the_embed_shows_a_play_button_and_a_waveform_and_asks_for_a_start() {
let tokens = tokens();
let mut harness = egui_kittest::Harness::builder().with_size(egui::Vec2::new(700.0, 200.0)).build_ui_state(
move |ui, warmed: &mut bool| {
if !*warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
*warmed = true;
return;
}
ui.ctx().data_mut(|data| data.insert_temp(state_id(), idle(true)));
audio::embed::show(ui, &tokens, "song.wav");
},
false,
);
harness.run_steps(3);
assert!(harness.get_all_by_label_contains("Waveform").count() >= 1);
harness.get_by_label("Play").click();
harness.step();
let queued = harness.ctx.data(|data| data.get_temp::<Vec<AudioCommand>>(commands_id())).unwrap_or_default();
assert!(matches!(queued.first(), Some(AudioCommand::Play { path, .. }) if path == "song.wav"), "{queued:?}");
}
/// A feed holding the sweep, as the decoder would have filled it.
fn sweep_feed() -> Feed {
let mut ring = Ring::default();
let samples: Vec<f32> = (0..spectrum::RATE * SECONDS).map(|i| sweep(seconds_at(i, spectrum::RATE))).collect();
ring.push(&samples);
Feed { ring: Arc::new(Mutex::new(ring)), base: 0.0 }
}
#[test]
fn the_spectrum_follows_the_sweep_at_the_playback_position() {
let feed = sweep_feed();
let ctx = egui::Context::default();
let loudest_at = |position: f32| {
for _ in 0..40 {
spectrum::step(&ctx, Some(&feed), position, true, 1.0 / 60.0);
}
let shared = spectrum::shared(&ctx);
let guard = shared.lock().expect("the analyzer lock");
let levels = guard.as_ref().expect("planned").levels;
levels.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).map(|(band, _)| band).expect("bands")
};
let early = loudest_at(0.5);
let late = loudest_at(4.0);
assert!(late > early + 10, "the loudest band climbs with the sweep: {early} then {late}");
for _ in 0..300 {
spectrum::step(&ctx, None, 0.0, false, 1.0 / 60.0);
}
assert!(!spectrum::step(&ctx, None, 0.0, false, 1.0 / 60.0), "at rest no frame is wanted");
}
/// Renders the playing tab to `NOTESCRAFT_AUDIO_PNG` (a look for the eye), with the sweep on the spectrum.
#[test]
#[ignore = "needs a GPU adapter; run by hand with NOTESCRAFT_AUDIO_PNG=/path/out.png"]
fn the_playing_audio_tab_renders() {
let Ok(out) = std::env::var("NOTESCRAFT_AUDIO_PNG") else { return };
let feed = sweep_feed();
let mut harness = harness("render", move |_| AudioState {
path: Some("song.wav".into()),
position: 3.0,
duration: Some(SECONDS_F),
feed: Some(feed.clone()),
..idle(true)
});
let started = Instant::now();
loop {
harness.run_steps(5);
std::thread::sleep(Duration::from_millis(40));
if started.elapsed() > Duration::from_millis(2500) {
break;
}
}
harness.render().expect("the frame renders").save(out).expect("the png is written");
}
/// Renders the embed card (idle, then playing) to `NOTESCRAFT_AUDIO_PNG` with `.embed-*.png` appended.
#[test]
#[ignore = "needs a GPU adapter; run by hand with NOTESCRAFT_AUDIO_PNG=/path/out"]
fn the_audio_embed_renders() {
let Ok(out) = std::env::var("NOTESCRAFT_AUDIO_PNG") else { return };
let _session = session_with_wav("embed-render");
let tokens = tokens();
let root = std::env::temp_dir().join(format!("notescraft-audio-embed-render-{}", std::process::id()));
let _ = std::fs::create_dir_all(&root);
let _ = std::fs::write(root.join("song.wav"), wav_bytes());
let mut harness = egui_kittest::Harness::builder().with_size(egui::Vec2::new(720.0, 150.0)).build_ui_state(
move |ui, warmed: &mut bool| {
if !*warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
*warmed = true;
return;
}
ui.ctx()
.data_mut(|data| data.insert_temp(state_id(), AudioState { path: Some("song.wav".into()), position: 2.0, duration: Some(5.0), ..idle(true) }));
ui.ctx().data_mut(|data| data.insert_temp(audio::bus::root_id(), root.clone()));
ui.add_space(8.0);
audio::embed::show(ui, &tokens, "song.wav");
},
false,
);
let started = Instant::now();
while started.elapsed() < Duration::from_millis(2000) {
harness.run_steps(3);
std::thread::sleep(Duration::from_millis(40));
}
harness.render().expect("the frame renders").save(format!("{out}.embed.png")).expect("the png is written");
}
/// Plays a second of the wav through `ffplay` and checks the position advances.
#[test]
#[ignore = "plays sound: needs ffplay and an audio device"]
fn a_wav_really_plays_and_its_position_advances() {
use notescraft_ui_egui::media_view::audio::bus::{command, state};
use notescraft_ui_egui::media_view::audio::player::AudioPlayer;
let dir = std::env::temp_dir().join(format!("notescraft-audio-play-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let file: &Path = &dir.join("song.wav");
std::fs::write(file, wav_bytes()).expect("the wav is written");
let ctx = egui::Context::default();
let mut player = AudioPlayer::default();
command(&ctx, AudioCommand::Play { path: "song.wav".into(), from: 1.0 });
let started = Instant::now();
let mut seen = Vec::new();
while started.elapsed() < Duration::from_millis(1800) {
player.tick(&ctx, &dir);
seen.push((started.elapsed().as_secs_f32(), state(&ctx).map_or(0.0, |state| state.position)));
std::thread::sleep(Duration::from_millis(20));
}
let last = state(&ctx).expect("the state is published");
assert!(last.available, "ffplay is installed");
assert_eq!(last.path.as_deref(), Some("song.wav"));
assert!(last.position > 1.8 && last.position < 2.8, "one second in from 1.0: {seen:?}");
assert!(last.feed.is_some(), "the spectrum stream runs");
command(&ctx, AudioCommand::Stop);
player.tick(&ctx, &dir);
assert!(state(&ctx).is_some_and(|state| state.path.is_none()), "stopped");
}
/// Plays every file of `NOTESCRAFT_AUDIO_DIR` for a second and prints one line per format.
#[test]
#[ignore = "plays sound: needs ffplay, an audio device and NOTESCRAFT_AUDIO_DIR=/folder/of/audio"]
fn every_format_of_a_folder_plays_and_gets_a_waveform() {
use notescraft_ui_egui::media_view::audio::bus::{command, state};
use notescraft_ui_egui::media_view::audio::player::AudioPlayer;
let Ok(dir) = std::env::var("NOTESCRAFT_AUDIO_DIR") else { return };
let dir = std::path::PathBuf::from(dir);
let ctx = egui::Context::default();
let mut player = AudioPlayer::default();
let mut names: Vec<String> =
std::fs::read_dir(&dir).expect("the folder reads").filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned())).collect();
names.sort();
let mut failures = Vec::new();
for name in names {
command(&ctx, AudioCommand::Play { path: name.clone(), from: 0.0 });
let started = Instant::now();
while started.elapsed() < Duration::from_millis(1300) {
player.tick(&ctx, &dir);
std::thread::sleep(Duration::from_millis(20));
}
let seen = state(&ctx).expect("state");
let waveform_ready = loop {
let analysis = waveform::analysis(&ctx, &name, Some(&dir.join(&name)));
if analysis.peaks.is_some() || analysis.failed || started.elapsed() > Duration::from_secs(10) {
break analysis.peaks.is_some();
}
std::thread::sleep(Duration::from_millis(30));
};
let spectrum = seen.feed.as_ref().is_some_and(|feed| feed.ring.lock().is_ok_and(|ring| ring.written() > 0));
let plays = seen.has(&name) && seen.position > 0.5;
println!(
"FORMAT {name}: plays={plays} position={:.2} duration={:?} waveform={waveform_ready} spectrum_samples={spectrum}",
seen.position, seen.duration
);
if !(plays && waveform_ready && spectrum) {
failures.push(name);
}
command(&ctx, AudioCommand::Stop);
player.tick(&ctx, &dir);
}
assert!(failures.is_empty(), "{failures:?}");
}
+15 -3
View File
@@ -532,24 +532,36 @@ fn search_results_can_be_ordered_by_name_and_collapsed_to_titles() {
rows.into_iter().map(|(_, name)| name.to_owned()).collect::<Vec<_>>()
};
let mut texts = Vec::new();
for _ in 0..40 {
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts).len() == 3 {
break;
}
}
let ranked = order(&texts);
assert_eq!(ranked.len(), 3, "{:?}", names(&texts));
assert_eq!(ranked.first().map(String::as_str), Some("mid.md"), "most matches first: {ranked:?}");
let pill = |texts: &[Painted], label: &str| find(texts, label).pos + egui::vec2(8.0, 6.0);
d.click(pill(&texts, "Relevance"));
for _ in 0..40 {
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts) == ["alpha.md", "mid.md", "zed.md"] {
break;
}
}
assert_eq!(order(&texts), ["alpha.md", "mid.md", "zed.md"], "A to Z");
d.click(pill(&texts, "A to Z"));
for _ in 0..40 {
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts) == ["zed.md", "mid.md", "alpha.md"] {
break;
}
}
assert_eq!(order(&texts), ["zed.md", "mid.md", "alpha.md"], "Z to A");
assert!(texts.iter().any(|p| p.text.contains("lonely")), "the matching lines show");
+183 -6
View File
@@ -31,10 +31,15 @@ struct Lightbox {
/// A vault with a picture and a lightbox over it.
fn lightbox(name: &str, read_only: bool) -> (PathBuf, egui_kittest::Harness<'static, Lightbox>) {
lightbox_of(name, read_only, |path| write_png(path, 64, 48))
}
/// A vault with the picture `make` writes and a lightbox over it.
fn lightbox_of(name: &str, read_only: bool, make: impl FnOnce(&Path)) -> (PathBuf, egui_kittest::Harness<'static, Lightbox>) {
let root = std::env::temp_dir().join(format!("notescraft-image-annotate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("the temp vault is made");
write_png(&root.join("shot.png"), 64, 48);
make(&root.join("shot.png"));
let session =
if read_only { Session::open_read_only(&root).expect("the temp vault opens read-only") } else { Session::open(&root).expect("the temp vault opens") };
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
@@ -116,11 +121,11 @@ fn undo_and_redo_bring_the_mark_back() {
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
wait_for_save(&mut harness);
assert_eq!(mark_count(&root), 1, "the stroke is in the sidecar");
harness.get_by_label("Undo").click();
harness.get_by_label_contains("Undo").click();
harness.run_steps(5);
wait_for_save(&mut harness);
assert!(!sidecar(&root).exists(), "the sidecar of an empty drawing is taken away");
harness.get_by_label("Redo").click();
harness.get_by_label_contains("Redo").click();
harness.run_steps(5);
wait_for_save(&mut harness);
assert_eq!(mark_count(&root), 1, "the mark is back");
@@ -133,7 +138,7 @@ fn the_eraser_takes_the_mark_away() {
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
wait_for_save(&mut harness);
assert_eq!(mark_count(&root), 1, "the stroke is in the sidecar");
harness.get_by_label("Eraser").click();
harness.get_by_label_contains("Eraser").click();
harness.run_steps(5);
harness.drag_at(egui::pos2(360.0, 330.0));
harness.run_steps(5);
@@ -164,7 +169,7 @@ fn a_read_only_vault_shows_the_marks_without_the_tools() {
// The query that does not shout when nothing is found: the tools must be
// gone, and that is what the count says.
assert_eq!(harness.query_all_by_label("Draw").count(), 0, "the draw mode cannot be opened");
assert_eq!(harness.query_all_by_label("Pen").count(), 0, "there are no tools");
assert_eq!(harness.query_all_by_label_contains("Pen").count(), 0, "there are no tools");
let _ = root;
}
@@ -174,10 +179,182 @@ fn the_draw_bar_stays_inside_the_tab_and_every_tool_is_reachable() {
open_draw_mode(&mut harness);
harness.run_steps(6);
for label in ["Pen", "Highlighter", "Line", "Arrow", "Rectangle", "Ellipse", "Text", "Eraser", "Undo", "Redo", "Clear", "Done", "Colour 1", "Width 1"] {
let rect = harness.get_by_label(label).rect();
let rect = harness.get_by_label_contains(label).rect();
assert!(rect.min.y >= 0.0 && rect.max.y <= 600.0 && rect.max.x <= 800.0, "{label} is inside the 800x600 tab: {rect:?}");
}
harness.get_by_label("Done").click();
harness.run_steps(4);
assert_eq!(harness.query_all_by_label("Done").count(), 0, "Done closes the draw bar");
}
/// The marks the sidecar holds, as JSON.
fn marks(root: &Path) -> Vec<serde_json::Value> {
let text = std::fs::read_to_string(sidecar(root)).expect("the sidecar is read");
let document: serde_json::Value = serde_json::from_str(&text).expect("the sidecar is json");
document.get("items").and_then(serde_json::Value::as_array).cloned().unwrap_or_default()
}
/// A pair of numbers of a mark, such as `from`.
fn pair(mark: &serde_json::Value, key: &str) -> (f64, f64) {
let values = mark.get(key).and_then(serde_json::Value::as_array).expect("the mark has the pair");
(values[0].as_f64().expect("a number"), values[1].as_f64().expect("a number"))
}
#[test]
fn a_line_runs_from_the_press_to_the_release_inside_the_picture() {
let (root, mut harness) = lightbox("line", false);
open_draw_mode(&mut harness);
// The hotkey picks the tool; the drag ends far right of the 64 x 48 picture.
harness.key_press(egui::Key::L);
harness.run_steps(3);
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(760.0, 300.0));
wait_for_save(&mut harness);
let items = marks(&root);
assert_eq!(items.len(), 1);
assert_eq!(items[0]["type"], "line");
assert_eq!(items[0]["arrow"], false);
let (from, to) = (pair(&items[0], "from"), pair(&items[0], "to"));
assert!(from.0 > 0.0 && from.0 < 64.0 && from.1 > 0.0 && from.1 < 48.0, "the start is a point of the picture: {from:?}");
assert!((to.0 - 64.0).abs() < 1e-3, "the end is held at the right edge: {to:?}");
assert!((to.1 - from.1).abs() < 0.5, "a level drag makes a level line: {from:?} {to:?}");
}
#[test]
fn the_arrow_tool_makes_a_line_with_a_head_and_a_click_makes_nothing() {
let (root, mut harness) = lightbox("arrow", false);
open_draw_mode(&mut harness);
harness.get_by_label_contains("Arrow").click();
harness.run_steps(3);
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(400.0, 340.0));
wait_for_save(&mut harness);
let items = marks(&root);
assert_eq!((items.len(), &items[0]["arrow"]), (1, &serde_json::Value::Bool(true)));
// A press and release on the same point makes no second mark.
draw_stroke(&mut harness, egui::pos2(450.0, 330.0), egui::pos2(450.0, 330.0));
wait_for_save(&mut harness);
assert_eq!(mark_count(&root), 1, "a click without a drag makes nothing");
}
#[test]
fn marks_are_painted_inside_the_picture_only() {
let (_root, mut harness) = lightbox("clip", false);
open_draw_mode(&mut harness);
harness.get_by_label_contains("Arrow").click();
harness.run_steps(3);
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(760.0, 40.0));
harness.run_steps(3);
let mut marks_seen = 0;
for clipped in &harness.output().shapes {
let egui::Shape::Vec(parts) = &clipped.shape else { continue };
if !matches!(parts.first(), Some(egui::Shape::LineSegment { .. })) {
continue;
}
marks_seen += 1;
let clip = clipped.clip_rect;
assert!(clip.width() < 800.0 && clip.height() < 600.0, "the mark is clipped to the picture, not the tab: {clip:?}");
// The picture is 4:3 and the canvas may cut its bottom, so the clip is at least as wide as 4:3.
assert!(clip.width() / clip.height() >= 64.0 / 48.0 - 0.02, "the clip is the picture, not the tab: {clip:?}");
}
assert_eq!(marks_seen, 1, "the arrow is one item");
}
#[test]
fn the_text_tool_types_in_place_and_enter_keeps_the_label() {
let (root, mut harness) = lightbox("text", false);
open_draw_mode(&mut harness);
harness.get_by_label_contains("Text").click();
harness.run_steps(3);
let click = egui::pos2(300.0, 300.0);
harness.drag_at(click);
harness.run_steps(2);
harness.drop_at(click);
harness.run_steps(4);
let field = harness.get_by_role(egui::accesskit::Role::TextInput);
let rect = field.rect();
assert!((rect.min - click).length() < 12.0, "the field stands on the click: {rect:?}");
assert_eq!(harness.query_all_by_label("Add").count(), 0, "there is no Add button");
field.type_text("Hi 你好");
harness.run_steps(3);
harness.key_press(egui::Key::Enter);
harness.run_steps(4);
assert_eq!(harness.query_all_by_role(egui::accesskit::Role::TextInput).count(), 0, "Enter closes the field");
assert_eq!(harness.query_all_by_label("Done").count(), 1, "Enter that closes a label leaves the draw mode on");
wait_for_save(&mut harness);
let items = marks(&root);
assert_eq!((items.len(), items[0]["type"].as_str(), items[0]["text"].as_str()), (1, Some("text"), Some("Hi 你好")));
let at = pair(&items[0], "at");
assert!(at.0 > 0.0 && at.0 < 64.0 && at.1 > 0.0 && at.1 < 48.0, "the label is anchored on a point of the picture: {at:?}");
}
#[test]
fn escape_and_an_empty_label_make_nothing() {
let (root, mut harness) = lightbox("text-cancel", false);
open_draw_mode(&mut harness);
harness.get_by_label_contains("Text").click();
harness.run_steps(3);
for typed in ["gone", ""] {
let click = egui::pos2(300.0, 300.0);
harness.drag_at(click);
harness.run_steps(2);
harness.drop_at(click);
harness.run_steps(4);
if typed.is_empty() {
harness.key_press(egui::Key::Enter);
} else {
harness.get_by_role(egui::accesskit::Role::TextInput).type_text(typed);
harness.run_steps(2);
harness.key_press(egui::Key::Escape);
}
harness.run_steps(4);
assert_eq!(harness.query_all_by_role(egui::accesskit::Role::TextInput).count(), 0);
}
wait_for_save(&mut harness);
assert!(!sidecar(&root).exists(), "no mark was made");
}
/// Renders the draw mode over a transparent picture with every kind of mark, to the PNG named by
/// `NOTESCRAFT_ANNOTATE_PNG`; without the variable there is nothing to render.
#[test]
fn the_draw_mode_renders_every_kind_of_mark() {
let Ok(out) = std::env::var("NOTESCRAFT_ANNOTATE_PNG") else { return };
let (_root, mut harness) = lightbox_of("render", false, |path| {
let mut picture = image::RgbaImage::from_pixel(200, 150, image::Rgba([0, 0, 0, 0]));
for (x, y, pixel) in picture.enumerate_pixels_mut() {
let (dx, dy) = (f64::from(x) - 100.0, f64::from(y) - 75.0);
if dx * dx / 3600.0 + dy * dy / 2500.0 < 1.0 {
*pixel = image::Rgba([150, 90, 220, 255]);
}
}
picture.save_with_format(path, image::ImageFormat::Png).expect("the png is written");
});
// The picture loads on a worker thread: step until it is there before the first settled run.
for _ in 0..50 {
harness.run_steps(4);
std::thread::sleep(Duration::from_millis(40));
}
open_draw_mode(&mut harness);
let drag = |harness: &mut egui_kittest::Harness<'_, Lightbox>, tool: &str, from: (f32, f32), to: (f32, f32)| {
harness.get_by_label_contains(tool).click();
harness.run_steps(3);
draw_stroke(harness, egui::pos2(from.0, from.1), egui::pos2(to.0, to.1));
wait_for_save(harness);
};
drag(&mut harness, "Rectangle", (230.0, 120.0), (330.0, 200.0));
drag(&mut harness, "Ellipse", (350.0, 120.0), (450.0, 200.0));
drag(&mut harness, "Line", (230.0, 240.0), (330.0, 310.0));
drag(&mut harness, "Arrow", (350.0, 240.0), (480.0, 310.0));
harness.get_by_label_contains("Text").click();
harness.run_steps(3);
let click = egui::pos2(500.0, 140.0);
harness.drag_at(click);
harness.run_steps(2);
harness.drop_at(click);
harness.run_steps(4);
harness.get_by_role(egui::accesskit::Role::TextInput).type_text("Hello 你好");
harness.run_steps(3);
harness.key_press(egui::Key::Enter);
harness.run_steps(6);
harness.run_steps(6);
let image = harness.render().expect("the frame renders");
image.save(out).expect("the png is saved");
}
+2 -2
View File
@@ -266,8 +266,8 @@ fn invalid_math_is_danger_monospace_with_parser_tooltip() -> Result<(), String>
_ => None,
})
.ok_or("error source missing")?;
assert!(source.galley.job.sections.iter().all(|section| section.format.font_id.family == egui::FontFamily::Monospace
&& section.format.color == notescraft_ui_egui::theme::color(tokens.theme.colors.danger)));
// An unknown command is one danger-coloured atom inside the formula (the rest of a formula still draws).
assert!(source.galley.job.sections.iter().all(|section| section.format.color == notescraft_ui_egui::theme::color(tokens.theme.colors.danger)));
let message = notescraft_math::parse(r"\foo").err().ok_or("parser accepted unknown command")?.to_string();
assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Text(text) if text.galley.text().contains(&message))), "{:?}", text_labels(&output));
Ok(())
+2 -2
View File
@@ -1,4 +1,4 @@
//! Menu mnemonics, keyboard focus and Vim motions, driven with real key events through egui_kittest frames.
//! Menu mnemonics, keyboard focus and Vim motions, driven with real key events through `egui_kittest` frames.
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::kittest::{NodeT, Queryable};
use notescraft_ui_egui::{fonts, menu_keys, motion, tabs, theme::Tokens};
@@ -80,7 +80,7 @@ fn items_are_menu_items_with_their_key_as_description() {
let rename = harness.get_by_label("Rename");
assert_eq!(rename.accesskit_node().role(), egui::accesskit::Role::MenuItem);
assert_eq!(rename.accesskit_node().description().as_deref(), Some("Key R"));
assert_eq!(rename.accesskit_node().data().keyboard_shortcut().as_deref(), Some("R"));
assert_eq!(rename.accesskit_node().data().keyboard_shortcut(), Some("R"));
assert!(harness.get_by_label("Duplicate").accesskit_node().is_disabled(), "disabled rows are announced as disabled");
}
+104
View File
@@ -0,0 +1,104 @@
//! The language controls of a source file's tab, driven headless: the hint banner for a file type no
//! grammar fits, Ignore, the chip and its picker, and that a choice is remembered and highlights.
use egui_kittest::kittest::Queryable;
use notescraft_themes::builtin;
use notescraft_ui_egui::code_view::language::{self, Action, Prefs};
use notescraft_ui_egui::theme::Tokens;
/// What the tab draws and what the user did.
struct Tab {
path: &'static str,
tokens: Tokens,
prefs: Prefs,
warmed: bool,
}
/// The header chip and, when the type needs a hint, the banner, as the tab draws them.
fn tab(path: &'static str) -> egui_kittest::Harness<'static, Tab> {
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
let state = Tab { path, tokens, prefs: Prefs::default(), warmed: false };
egui_kittest::Harness::builder().with_size(egui::Vec2::new(900.0, 500.0)).with_max_steps(200).build_ui_state(
|ui, state: &mut Tab| {
if !state.warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
state.warmed = true;
return;
}
if let Some(action) = language::take_action(ui.ctx()) {
language::apply(&mut state.prefs, action);
}
let resolved = language::resolve(state.path, "", &state.prefs.chosen);
language::set_current(ui.ctx(), state.path, resolved.as_deref());
let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(220.0, 28.0));
ui.allocate_rect(row, egui::Sense::hover());
notescraft_ui_egui::code_view::chip(ui, &state.tokens, row, state.path);
if language::needs_hint(state.path, resolved.as_deref(), &state.prefs) {
notescraft_ui_egui::code_view::banner(ui, &state.tokens, state.path);
}
},
state,
)
}
#[test]
fn the_banner_shows_for_an_unknown_type_and_not_for_a_known_one() {
let mut unknown = tab("data/file.xyz");
unknown.run();
unknown.run();
assert!(unknown.query_by_label("No syntax highlighting found for .xyz").is_some());
assert!(unknown.query_by_label("Install a grammar").is_some());
assert!(unknown.query_by_label("Use another language").is_some());
assert!(unknown.query_by_label("Language: Plain text").is_some(), "the chip is always there");
let mut known = tab("ar/publish/publish.css");
known.run();
known.run();
assert!(known.query_by_label_contains("No syntax highlighting").is_none());
assert!(known.query_by_label_contains("Language: CSS").is_some());
}
#[test]
fn ignore_is_remembered_and_the_chip_stays() {
let mut harness = tab("data/file.xyz");
harness.run();
harness.run();
harness.get_by_label("Ignore").click();
harness.run();
harness.run();
assert!(harness.state().prefs.ignored.contains(&"xyz".to_owned()));
assert!(harness.query_by_label_contains("No syntax highlighting").is_none());
assert!(harness.query_by_label("Language: Plain text").is_some());
}
#[test]
fn the_chip_picks_a_language_that_is_remembered_and_highlights() {
let mut harness = tab("data/file.xyz");
harness.run();
harness.run();
harness.get_by_label("Language: Plain text").click();
harness.run();
harness.run();
harness.event(egui::Event::Text("rust".to_owned()));
harness.run();
harness.run();
harness.get_by_label("Rust").click();
for _ in 0..4 {
harness.run();
}
assert_eq!(harness.state().prefs.chosen.get("xyz").map(String::as_str), Some("Rust"));
assert!(harness.query_by_label_contains("No syntax highlighting").is_none());
assert!(harness.query_by_label("Language: Rust").is_some());
let chosen = &harness.state().prefs.chosen;
assert!(language::resolve("other/b.xyz", "", chosen).is_some(), "the type is remembered for every file of it");
// The remembered grammar colours the text.
assert!(notescraft_syntax::Highlighter::new("Rust", "fn main() {}\n").is_some());
}
#[test]
fn a_choice_changes_with_the_chip_at_any_time() {
let mut prefs = Prefs::default();
language::apply(&mut prefs, Action::Choose { key: "css".to_owned(), language: "Python".to_owned() });
assert_eq!(language::resolve("a.css", "", &prefs.chosen).as_deref(), Some("Python"));
language::apply(&mut prefs, Action::Choose { key: "css".to_owned(), language: "CSS".to_owned() });
assert_eq!(language::resolve("a.css", "", &prefs.chosen).as_deref(), Some("CSS"));
}
+1 -1
View File
@@ -178,7 +178,7 @@ With `NOTESCRAFT_DEV_DIR=<folder>` set, `shell/app/dev_hook.rs` polls `<folder>/
### Obsidian basics and the graph view (specs/obsidian-basics)
Engine. `vault/find/` holds `search`, `content_search`, `switcher` (`switcher.search`: fuzzy notes and aliases, `note#heading`, `note^block`; `Session::switcher`) and `random_unique` (`note.random`, `note.unique`). `FilesConfig::link_text(from, target, alias)` writes a link by `link_format` and `link_path`; `session.files.deleted_files = permanent` makes `note.delete` and `folder.delete` call `Vault::delete_permanently` instead of the vault trash. `backlinks::link_mention_as` links a mention with caller-written text.
UI. `shell/app/vault/{switcher,link_preview,history}.rs` (switcher rows and jump, 400 ms link hover card, back/forward), `widgets/tabs.rs` (`Tabs::{toggle_pin, move_tab}`, pinned tabs persist in `UiState.pinned_tabs`), `panels/tree.rs` (`move_of`: drag a row onto a folder or the empty area to move it through `note.rename` / `folder.rename`), `views/note_view/list_fold.rs` (list item folding), line numbers in `views/center/source.rs`. Settings rows: default view, font size, readable line length, line numbers, new-note folder, link format, link path, deleted files. PDF embeds draw the page as an image: `Session::pdf_preview(path, page)` runs the system tool (PDFKit through `osascript` on macOS, with `sips` as the first-page fallback; `pdftoppm` elsewhere), caches the PNG in `.notescraft/cache/` keyed by size and modified time, and `Images::load` hands it to the note view; without a tool the embed falls back to the file card. Audio embeds get Play/Stop (`shell/app/player.rs`: `afplay`, `ffplay`/`paplay`/`aplay`, or PowerShell, one sound at a time, killed on Stop and on exit). Video embeds draw a still (`Session::video_poster`: `qlmanage` on macOS, `ffmpeg` elsewhere) in the file card; with `ffmpeg` installed Play decodes raw RGBA frames on a reader thread (`shell/app/video.rs`, 640 px wide, 24 fps, `-re` pacing, two frames of queue), paints the newest one as a texture and plays the sound through `ffplay`; without it the card keeps Open in default app. Split view: `shell/app/split.rs` keeps a `Split { tabs, main_on_right, texts, docs, folds }`; the centre card is cut into two halves (`halves`), the second pane draws through `Views::show_note` in the Visual view without an editor until it is clicked, and its tab clicks, closes and links come back through `handle_split`; a press inside the pane swaps the two tab lists (`focus_split`, `Split.main_on_right`), so the pane that was clicked is the one with the editor. Spelling: `lint::vocabulary(text)` per note, the engine counts in how many notes each word occurs (`lint.word_notes`), words in two or more notes are the known set handed to the `spelling` rule; suggestions are the closest known words (edit distance 2). Strict line breaks (`editor.strict_line_breaks`, `blocks/paragraph.rs`) join the lines of a paragraph into one wrapped row; off, the default, keeps every line break like Obsidian. Show hidden files (`session.files.show_hidden_files`, `Vault::list_with_hidden`) lists dot folders and notes in the Explorer except `.notescraft` and `.git`.
UI. `shell/app/vault/{switcher,link_preview,history}.rs` (switcher rows and jump, 400 ms link hover card, back/forward), `widgets/tabs.rs` (`Tabs::{toggle_pin, move_tab}`, pinned tabs persist in `UiState.pinned_tabs`), `panels/tree.rs` (`move_of`: drag a row onto a folder or the empty area to move it through `note.rename` / `folder.rename`), `views/note_view/list_fold.rs` (list item folding), line numbers in `views/center/source.rs`. Settings rows: default view, font size, readable line length, line numbers, new-note folder, link format, link path, deleted files. PDF embeds draw the page as an image: `Session::pdf_preview(path, page)` runs the system tool (PDFKit through `osascript` on macOS, with `sips` as the first-page fallback; `pdftoppm` elsewhere), caches the PNG in `.notescraft/cache/` keyed by size and modified time, and `Images::load` hands it to the note view; without a tool the embed falls back to the file card. Audio plays in the app (`views/media_view/audio/`): the tab (`mod.rs`) and the note embed card (`embed.rs`, `![[song.mp3]]` and `ogg`, `wav`, `flac`, `m4a`, `aac`, `opus`, `3gp`, `weba`) drive one `AudioPlayer` (`player.rs`, ticked from `vault/explorer.rs`; commands and state in `bus.rs`): `ffplay -nodisp -ss -af atempo -volume` makes the sound (started on a worker thread, pause kills and restarts at the kept position, the position is a wall clock times the speed minus a 0.2 s start lag), one sound at a time, and a video that starts stops it. The waveform (`waveform.rs`, `peaks.rs`) decodes the whole file once at 8 kHz mono (at most 30 minutes, 100 min/max buckets per second, 1500 columns, a cache of 16 files, 2 decodes at once) and seeks on click or drag. The spectrum (`spectrum.rs`, `ring.rs`, `fft.rs`) decodes a second 22 kHz mono stream throttled to the speed into an 8 s ring, takes the 2048-sample Hann window at the clock, runs an own radix-2 FFT and draws 48 log bands (60 Hz to 10 kHz) with attack/decay and peak hold; a toggle remembers waveform only or both. Without `ffplay` the tab offers Open with the system. The old `shell/app/player.rs` (system player) is no longer used by embeds. Video embeds draw a still (`Session::video_poster`: `qlmanage` on macOS, `ffmpeg` elsewhere) in the file card; with `ffmpeg` installed Play decodes raw RGBA frames on a reader thread (`shell/app/video.rs`, 640 px wide, 24 fps, `-re` pacing, two frames of queue), paints the newest one as a texture and plays the sound through `ffplay`; without it the card keeps Open in default app. Split view: `shell/app/split.rs` keeps a `Split { tabs, main_on_right, texts, docs, folds }`; the centre card is cut into two halves (`halves`), the second pane draws through `Views::show_note` in the Visual view without an editor until it is clicked, and its tab clicks, closes and links come back through `handle_split`; a press inside the pane swaps the two tab lists (`focus_split`, `Split.main_on_right`), so the pane that was clicked is the one with the editor. Spelling: `lint::vocabulary(text)` per note, the engine counts in how many notes each word occurs (`lint.word_notes`), words in two or more notes are the known set handed to the `spelling` rule; suggestions are the closest known words (edit distance 2). Strict line breaks (`editor.strict_line_breaks`, `blocks/paragraph.rs`) join the lines of a paragraph into one wrapped row; off, the default, keeps every line break like Obsidian. Show hidden files (`session.files.show_hidden_files`, `Vault::list_with_hidden`) lists dot folders and notes in the Explorer except `.notescraft` and `.git`.
Graph view. `views/graph_view/`: caps 50,000 notes, 250,000 edges, 2,048 ghosts (over the ghost cap the ghosts are left out, the notes stay); node radius grows with the square root of the degree and shrinks with zoom; labels fade in with zoom; the hovered node's edges light up; the camera follows the layout until the user pans or zooms and fits without the outermost 1% of nodes. `forces.rs`: four sliders (centre, repel, link, distance) applied when released, and up to five colour groups (`path` or `tag:name`), kept for the session. Measured (release, 6,566-note Obsidian Hub vault, owner's Mac): eager CLI open and `info` 2.8 s, `search` 3.0 s and `switcher.search` 3.0 s including that open; the app opens lazily.
### `notescraft-acp` (L0)
+2 -2
View File
@@ -28,10 +28,10 @@ Engine tests: `crates/engine/tests/*.rs`, fixtures in `tests/common/mod.rs` (`Te
| Want | Open |
|---|---|
| A tab/view kind (file type -> view) | `widgets/tabs/kind.rs` (`TabKind::of`, tables, `icon()`), the dispatch in `shell/app/window/views.rs::show` (one arm per kind), the view in `views/<name>/` |
| An existing view | `views/`: `note_view`, `canvas_view`, `diagram_view`, `whiteboard_view`, `graph_view`, `calendar_view`, `settings_view`, `image_view` (+ `annotate/`), `media_view` (`video`, `pdf`, `bar.rs` = shared bar card/buttons), `code_view`, `file_card.rs` (info card), `folder_view.rs`, `share_dialog/` (members, invites, `publish.rs`), `versions_view/`, `trash_view/` |
| An existing view | `views/`: `note_view`, `canvas_view`, `diagram_view`, `whiteboard_view`, `graph_view`, `calendar_view`, `settings_view`, `image_view` (+ `annotate/`), `media_view` (`video`, `pdf`, `audio/` = tab, embed card, waveform, live spectrum, own FFT, `ffplay` player; `bar.rs` = shared bar card/buttons), `code_view`, `file_card.rs` (info card), `folder_view.rs`, `share_dialog/` (members, invites, `publish.rs`), `versions_view/`, `trash_view/` |
| Side panels | `panels/` (`tree*` = Explorer, `sidebar*`, `right*`, `search_panel`, `sync_*`) |
| Shared widgets | `widgets/` (`tabs/`, `context_menu.rs`, `palette.rs`, `bottom_sheet.rs`...) |
| App state and frame loop | `shell/app/` (`window/frame.rs` frame, `window/commands.rs` UI commands and `engine_command(id, &params)`, `window/views.rs` view state, `window/menus.rs`, `vault/` open/index, `sync.rs`, `live/`, `editor/` (paste/drop in `editor/attachments.rs`), `video.rs` ffmpeg player) |
| App state and frame loop | `shell/app/` (`window/frame.rs` frame, `window/commands.rs` UI commands and `engine_command(id, &params)`, `window/views.rs` view state, `window/menus.rs`, `vault/` open/index, `sync.rs`, `live/`, `editor/` (paste/drop in `editor/attachments.rs`), `video.rs` ffmpeg player, audio player ticks from `vault/explorer.rs`) |
| Keyboard shortcuts, tab/panel focus hotkeys, shortcut text in tooltips | `shell/hotkeys/`: one table `defaults.rs` (add a chord there), `dispatch.rs` (consumes it once per frame), `commands.rs` (bodies, palette entries), `focus.rs` (panel landmarks); tooltips call `hotkeys::on_hover_hotkey(response, label, id)` / `hotkeys::tooltip`; `cargo test -p notescraft-ui-egui --lib hotkeys` checks the table for conflicts |
| Attachment paste and drop | `shell/attachment_drop/` (`collect` returns `Request`s; the app runs the engine command, no vault IO while painting) |
| Compact (phone) layout | `shell/compact/`, tokens in `design/style/compact*` |