Touch up the mobile view, add syntax highlighting for markdown

This commit is contained in:
mrkmntal 2026-09-28 18:37:13 -04:00
commit 16450f4c1c
11 changed files with 863 additions and 57 deletions

284
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@ -145,6 +151,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@ -245,6 +266,27 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@ -261,6 +303,23 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -329,6 +388,12 @@ dependencies = [
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
@ -445,6 +510,16 @@ dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@ -474,6 +549,12 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -539,6 +620,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.3"
@ -550,6 +641,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -571,6 +668,28 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onig"
version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -606,6 +725,31 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plist"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
dependencies = [
"base64",
"indexmap",
"quick-xml",
"serde",
"time",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@ -657,6 +801,15 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
@ -685,7 +838,8 @@ dependencies = [
"pulldown-cmark",
"serde",
"serde_json",
"thiserror",
"syntect",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
@ -701,6 +855,12 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustversion"
version = "1.0.23"
@ -713,6 +873,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -801,6 +970,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@ -857,13 +1032,43 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "syntect"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
dependencies = [
"bincode",
"flate2",
"fnv",
"once_cell",
"onig",
"plist",
"regex-syntax",
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.21",
"walkdir",
"yaml-rust",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.21",
]
[[package]]
@ -877,6 +1082,47 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.53.1"
@ -1043,6 +1289,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -1094,6 +1350,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@ -1162,6 +1427,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"

View file

@ -20,3 +20,4 @@ anyhow = "1"
thiserror = "1"
percent-encoding = "2"
clap = { version = "4", features = ["derive"] }
syntect = "5"

View file

@ -14,6 +14,8 @@ GUI applications: a menu bar, split-pane editor, and file-based note storage.
- **File-based storage** — notes are persisted as Markdown files in `notes/`
- **28 built-in themes** — from Dark/Light to Solarized, Dracula, Nord, Monokai, and seasonal palettes
- **Markdown extras** — tables, tasklists, strikethrough, and footnotes via `pulldown-cmark`
- **Syntax highlighting** — code blocks are highlighted using the current theme's ANSI palette via `syntect`
- **Mobile responsive** — collapses to a Notes/Write/Preview tab layout on phones, with 44px touch targets
## Quick Start

View file

@ -3,6 +3,7 @@ mod notes;
mod pages;
mod routes;
mod storage;
mod syntax;
mod themes;
use axum::Router;
@ -30,6 +31,9 @@ async fn main() {
// Ensure notes directory exists
std::fs::create_dir_all("notes").expect("Failed to create notes directory");
// Eagerly initialize syntax highlighting state so the first request isn't slow.
let _ = syntax::syntax_state();
let app = Router::new()
.merge(routes::router())
.nest_service("/static", ServeDir::new("static"));

View file

@ -1,6 +1,9 @@
use pulldown_cmark::{html, Options, Parser};
use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
pub fn render_markdown(input: &str) -> String {
use crate::syntax::{highlight_code, syntax_state};
use crate::themes::Theme;
pub fn render_markdown(input: &str, theme: &Theme) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_TASKLISTS);
@ -8,19 +11,50 @@ pub fn render_markdown(input: &str) -> String {
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(input, options);
let mut events: Vec<Event<'_>> = Vec::new();
let mut parser = parser.peekable();
while let Some(event) = parser.next() {
if let Event::Start(Tag::CodeBlock(kind)) = event {
let lang = match kind {
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
CodeBlockKind::Indented => None,
};
let mut code = String::new();
loop {
match parser.next() {
Some(Event::Text(text)) => code.push_str(&text),
Some(Event::End(TagEnd::CodeBlock)) => break,
Some(other) => {
// Defensive: if we see something unexpected, push it through.
events.push(other);
}
None => break,
}
}
let highlighted = highlight_code(&code, lang.as_deref(), theme, syntax_state());
events.push(Event::Html(highlighted.into()));
} else {
events.push(event);
}
}
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html::push_html(&mut html_output, events.into_iter());
html_output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::themes::get_theme;
#[test]
fn renders_headings_and_paragraphs() {
let md = "# Hello\n\nThis is a paragraph.";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<p>This is a paragraph.</p>"));
}
@ -28,7 +62,7 @@ mod tests {
#[test]
fn renders_tasklists() {
let md = "- [x] Done\n- [ ] Todo";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<input"));
assert!(html.contains("checked"));
}
@ -36,8 +70,27 @@ mod tests {
#[test]
fn renders_tables() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let html = render_markdown(md);
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<table>"));
assert!(html.contains("<th>A</th>"));
}
#[test]
fn highlights_rust_code_blocks() {
let md = "```rust\nfn main() {\n println!(\"hello\");\n}\n```";
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("<pre"));
assert!(html.contains("fn"));
assert!(html.contains("main"));
// Syntect emits inline style spans for highlighted tokens.
assert!(html.contains("style=\"color:"));
}
#[test]
fn falls_back_for_unknown_language() {
let md = "```notareallanguage\nsome code\n```";
let html = render_markdown(md, get_theme("Dark"));
assert!(html.contains("some code"));
assert!(html.contains("<pre"));
}
}

View file

@ -14,15 +14,23 @@ pub fn render_app(
let theme = get_theme(theme_name);
let css = theme.to_css_block();
let rendered_preview = current_note
.map(|n| render_markdown(&n.content))
.map(|n| render_markdown(&n.content, theme))
.unwrap_or_default();
// Mobile lands on the note list when there is no note selected,
// otherwise on the editor. Server-rendered so it works without JS.
let default_tab = if current_note.is_some() {
"write"
} else {
"notes"
};
html! {
(DOCTYPE)
html lang="en" data-theme=(theme_name) {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
title { "Rasuti Notes" }
style { (PreEscaped(css)) }
style { (PreEscaped(include_str!("../static/style.css"))) }
@ -34,10 +42,11 @@ pub fn render_app(
@if let Some(msg) = message {
div class="flash-message" { (msg) }
}
(render_tab_bar(default_tab))
main class="app-body" {
(render_sidebar(notes, current_note))
(render_editor(current_note))
(render_preview(&rendered_preview))
(render_sidebar(notes, current_note, default_tab))
(render_editor(current_note, default_tab))
(render_preview(&rendered_preview, default_tab))
}
(render_status_bar(notes.len(), theme_name))
script {
@ -57,11 +66,44 @@ fn escape_js(s: &str) -> String {
.replace('\r', "\\r")
}
fn render_tab_bar(default_tab: &str) -> Markup {
html! {
nav class="tab-bar" {
button type="button" class="tab-button" data-tab-target="notes"
x-on:click="setTab('notes')"
x-bind:class="{ 'active': tab === 'notes' }" {
span class="tab-icon" { "\u{1F4C3}" }
span { "Notes" }
}
button type="button" class="tab-button" data-tab-target="write"
x-on:click="setTab('write')"
x-bind:class="{ 'active': tab === 'write' }" {
span class="tab-icon" { "\u{270E}" }
span { "Write" }
}
button type="button" class="tab-button" data-tab-target="preview"
x-on:click="setTab('preview')"
x-bind:class="{ 'active': tab === 'preview' }" {
span class="tab-icon" { "\u{1F4C4}" }
span { "Preview" }
}
script {
(PreEscaped(format!(
"window.__DEFAULT_TAB__ = \"{}\";",
default_tab
)))
}
}
}
}
fn render_menu_bar() -> Markup {
html! {
nav class="menu-bar" {
ul class="menu-list" {
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('file')"
x-bind:class="{ 'open': openMenu === 'file' }" {
span { "File" }
ul class="menu-dropdown" {
li {
@ -75,21 +117,27 @@ fn render_menu_bar() -> Markup {
}
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('edit')"
x-bind:class="{ 'open': openMenu === 'edit' }" {
span { "Edit" }
ul class="menu-dropdown" {
li { button x-on:click="focusEditor()" { "Focus Editor" } }
li { button x-on:click="saveCurrentNote()" { "Save Note" } }
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('view')"
x-bind:class="{ 'open': openMenu === 'view' }" {
span { "View" }
ul class="menu-dropdown" {
li { a href="/" { "Show All Notes" } }
li { button x-on:click="togglePreview()" { "Toggle Preview" } }
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('theme')"
x-bind:class="{ 'open': openMenu === 'theme' }" {
span { "Theme" }
ul class="menu-dropdown" {
@for theme in PREDEFINED_THEMES {
@ -102,7 +150,9 @@ fn render_menu_bar() -> Markup {
}
}
}
li class="menu-item" tabindex="0" {
li class="menu-item" tabindex="0"
x-on:click="toggleMenu('help')"
x-bind:class="{ 'open': openMenu === 'help' }" {
span { "Help" }
ul class="menu-dropdown" {
li { a href="/about" { "About Rasuti Notes" } }
@ -154,9 +204,15 @@ fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name:
}
}
fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
fn render_sidebar(notes: &[Note], current_note: Option<&Note>, default_tab: &str) -> Markup {
let active = if default_tab == "notes" {
" mobile-active"
} else {
""
};
html! {
aside class="sidebar" {
aside class={"sidebar" (active)} data-tab-pane="notes"
x-bind:class="{ 'mobile-active': tab === 'notes' }" {
div class="sidebar-header" { "Notes" }
ul class="note-list" {
@if notes.is_empty() {
@ -179,9 +235,16 @@ fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
}
}
fn render_editor(current_note: Option<&Note>) -> Markup {
fn render_editor(current_note: Option<&Note>, default_tab: &str) -> Markup {
let active = if default_tab == "write" {
" mobile-active"
} else {
""
};
html! {
section class="editor-pane" x-data="{ saving: false }" {
section class={"editor-pane" (active)} data-tab-pane="write"
x-data="{ saving: false }"
x-bind:class="{ 'mobile-active': tab === 'write' }" {
@if let Some(note) = current_note {
form
id="note-form"
@ -220,9 +283,15 @@ fn render_editor(current_note: Option<&Note>) -> Markup {
}
}
fn render_preview(html_content: &str) -> Markup {
fn render_preview(html_content: &str, default_tab: &str) -> Markup {
let active = if default_tab == "preview" {
" mobile-active"
} else {
""
};
html! {
section class="preview-pane" x-bind:class="{ 'hidden': !previewVisible }" {
section class={"preview-pane" (active)} data-tab-pane="preview"
x-bind:class="{ 'hidden': !previewVisible, 'mobile-active': tab === 'preview' }" {
div id="preview" class="markdown-body" x-html="previewHtml" {
(PreEscaped(html_content))
}

View file

@ -6,18 +6,12 @@ use axum::{
Router,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::markdown::render_markdown;
use crate::notes::{self, Note};
use crate::pages;
use crate::themes::get_theme;
#[derive(Clone)]
pub struct AppState {
// Could hold shared config in the future
}
pub fn router() -> Router {
Router::new()
.route("/", get(index))
@ -30,7 +24,6 @@ pub fn router() -> Router {
.route("/theme", post(theme_route))
.route("/about", get(about_route))
.route("/export", get(export_route))
.with_state(Arc::new(AppState {}))
}
fn get_theme_from_cookie(headers: &HeaderMap) -> String {
@ -143,8 +136,13 @@ struct PreviewForm {
content: String,
}
async fn preview_route(Form(form): Form<PreviewForm>) -> Result<impl IntoResponse, AppError> {
let html = render_markdown(&form.content);
async fn preview_route(
headers: HeaderMap,
Form(form): Form<PreviewForm>,
) -> Result<impl IntoResponse, AppError> {
let theme_name = get_theme_from_cookie(&headers);
let theme = get_theme(&theme_name);
let html = render_markdown(&form.content, theme);
Ok(Html(pages::render_preview_fragment(&html).into_string()))
}

121
src/syntax.rs Normal file
View file

@ -0,0 +1,121 @@
use crate::themes::{Theme, PREDEFINED_THEMES};
use std::collections::HashMap;
use std::sync::OnceLock;
use syntect::highlighting::{Theme as SyntectTheme, ThemeSet};
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;
pub struct SyntaxState {
pub syntax_set: SyntaxSet,
pub themes: HashMap<String, SyntectTheme>,
}
impl SyntaxState {
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let mut themes = HashMap::new();
for theme in PREDEFINED_THEMES {
let tmtheme = generate_tmtheme(theme);
let syntect_theme = ThemeSet::load_from_reader(&mut std::io::Cursor::new(tmtheme))
.unwrap_or_else(|e| {
eprintln!("Failed to load generated theme for {}: {}", theme.name, e);
ThemeSet::load_defaults().themes["InspiredGitHub"].clone()
});
themes.insert(theme.name.to_string(), syntect_theme);
}
Self { syntax_set, themes }
}
}
pub fn highlight_code(
code: &str,
lang: Option<&str>,
theme: &Theme,
state: &SyntaxState,
) -> String {
let syntect_theme = state
.themes
.get(theme.name)
.expect("Syntect theme should exist for every Rasuti theme");
let syntax = match lang {
Some(lang) => state.syntax_set.find_syntax_by_token(lang),
None => None,
}
.unwrap_or_else(|| state.syntax_set.find_syntax_plain_text());
highlighted_html_for_string(code, &state.syntax_set, syntax, syntect_theme)
.unwrap_or_else(|_| format!("<pre><code>{}</code></pre>", html_escape(code)))
}
static SYNTAX_STATE: OnceLock<SyntaxState> = OnceLock::new();
pub fn syntax_state() -> &'static SyntaxState {
SYNTAX_STATE.get_or_init(SyntaxState::new)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn generate_tmtheme(theme: &crate::themes::Theme) -> String {
let scopes = [
("comment", theme.ansi_bright[0]),
("string", theme.ansi_bright[2]),
("constant.numeric", theme.ansi_bright[3]),
("constant", theme.ansi_bright[3]),
("keyword", theme.ansi_bright[5]),
("storage", theme.ansi_bright[5]),
("entity.name.function", theme.ansi_bright[4]),
("support.function", theme.ansi_bright[4]),
("entity.name.type", theme.ansi_bright[6]),
("variable", theme.ansi_bright[1]),
("invalid", theme.ansi_bright[1]),
];
let mut scope_entries = String::new();
for (scope, color) in scopes {
scope_entries.push_str(&format!(
" <dict>\n\
<key>name</key>\n\
<string>{scope}</string>\n\
<key>scope</key>\n\
<string>{scope}</string>\n\
<key>settings</key>\n\
<dict>\n\
<key>foreground</key>\n\
<string>{color}</string>\n\
</dict>\n\
</dict>\n"
));
}
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
<key>name</key>\n\
<string>Rasuti {}</string>\n\
<key>settings</key>\n\
<array>\n\
<dict>\n\
<key>settings</key>\n\
<dict>\n\
<key>background</key>\n\
<string>{}</string>\n\
<key>foreground</key>\n\
<string>{}</string>\n\
</dict>\n\
</dict>\n\
{}\
</array>\n\
</dict>\n\
</plist>\n",
theme.name, theme.bg, theme.fg, scope_entries
)
}

View file

@ -227,7 +227,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -238,7 +238,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -249,7 +249,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -260,7 +260,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -271,7 +271,7 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
@ -395,11 +395,13 @@ mod tests {
#[test]
fn dark_themes_use_readable_button_colors() {
// Themes with very dark bright-blue colors should fall back to the
// theme foreground color so primary buttons remain readable.
// Bright accent colors should be luminous enough to use as button
// backgrounds; very dark accents fall back to the theme foreground.
let cyan = get_theme("Cyan on Black");
let (bg, fg) = cyan.button_colors(4);
assert_eq!(bg, cyan.fg);
// After replacing #0000ff with #a9a9f9, the bright blue is readable
// and no longer falls back to the foreground color.
assert_eq!(bg, cyan.ansi_bright[4]);
assert_eq!(fg, cyan.bg);
}

View file

@ -2,8 +2,47 @@ function app() {
return {
previewHtml: window.__INITIAL_PREVIEW__ || '',
previewVisible: true,
tab: window.__DEFAULT_TAB__ || 'notes',
// Only one menu can be open at a time. Keeping a single name here (rather
// than a set of booleans) makes mutual exclusion automatic: opening Theme
// while Help is open reassigns this value, so Help's dropdown is hidden.
openMenu: null,
_previewTimeout: null,
init() {
// Close menus on any click outside the menu bar. Alpine's `.outside`
// modifier cannot be used here because Maud treats `.` in attribute
// names as CSS class shorthand, so we bind it manually.
this._onDocClick = (event) => {
const menuBar = document.querySelector('.menu-bar');
if (this.openMenu && menuBar && !menuBar.contains(event.target)) {
this.closeMenus();
}
};
document.addEventListener('click', this._onDocClick);
},
destroy() {
if (this._onDocClick) {
document.removeEventListener('click', this._onDocClick);
}
},
toggleMenu(name) {
this.openMenu = this.openMenu === name ? null : name;
},
closeMenus() {
this.openMenu = null;
},
setTab(name) {
this.tab = name;
// Selecting the Preview tab should always reveal the preview, even if
// the toolbar toggle had hidden it on desktop.
if (name === 'preview') this.previewVisible = true;
},
updatePreview(content) {
if (this._previewTimeout) {
clearTimeout(this._previewTimeout);
@ -49,6 +88,10 @@ function app() {
},
handleKeydown(event) {
if (event.key === 'Escape' && this.openMenu) {
this.closeMenus();
return;
}
if (event.ctrlKey || event.metaKey) {
if (event.key.toLowerCase() === 's') {
event.preventDefault();

View file

@ -14,6 +14,13 @@ html, body {
transition: background-color 0.15s ease, color 0.15s ease;
}
body {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
}
a {
color: var(--ansi-4);
text-decoration: none;
@ -38,6 +45,7 @@ button, input, select, textarea {
background-color: var(--app-bg);
border-bottom: 1px solid var(--ui-border);
user-select: none;
flex-shrink: 0;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
@ -58,14 +66,17 @@ button, input, select, textarea {
font-weight: 500;
}
/* Menus are opened by click (see `openMenu` in app.js), not CSS hover, so
that only one can be open at a time. `.open` both highlights the trigger
and reveals its dropdown. */
.menu-item:hover,
.menu-item:focus {
.menu-item:focus,
.menu-item.open {
background-color: var(--ui-hover);
outline: none;
}
.menu-item:hover .menu-dropdown,
.menu-item:focus-within .menu-dropdown {
.menu-item.open .menu-dropdown {
display: block;
}
@ -75,6 +86,12 @@ button, input, select, textarea {
top: calc(100% - 4px);
left: 0;
min-width: 180px;
/* The Theme menu has 28 entries; cap the height so it cannot run off
the bottom of the viewport (body has overflow: hidden). */
max-height: calc(100dvh - 48px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background-color: var(--app-bg);
border: 1px solid var(--ui-border);
border-radius: 6px;
@ -131,6 +148,7 @@ button, input, select, textarea {
background-color: var(--app-bg);
border-bottom: 1px solid var(--ui-border);
gap: 12px;
flex-shrink: 0;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
@ -153,7 +171,7 @@ button, input, select, textarea {
}
.btn:hover {
background-color: var(--ui-hover);
background-color: var(--ansi-12);
border-color: var(--app-fg);
}
@ -164,6 +182,7 @@ button, input, select, textarea {
}
.btn-primary:hover {
background-color: var(--btn-primary-bg);
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.12);
}
@ -174,6 +193,7 @@ button, input, select, textarea {
}
.btn-danger:hover {
background-color: var(--btn-danger-bg);
box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.12);
}
@ -207,13 +227,15 @@ button, input, select, textarea {
color: var(--app-bg);
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
}
/* App Body */
.app-body {
display: grid;
grid-template-columns: 280px 1fr 1fr;
height: calc(100vh - 32px - 44px - 24px);
flex: 1;
min-height: 0;
overflow: hidden;
}
@ -482,6 +504,7 @@ button, input, select, textarea {
border-top: 1px solid var(--ui-border);
font-size: 11px;
opacity: 0.8;
flex-shrink: 0;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
@ -489,7 +512,7 @@ button, input, select, textarea {
.about-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
}
.about-page main {
@ -498,27 +521,237 @@ button, input, select, textarea {
max-width: 720px;
}
/* Mobile Tab Bar — hidden on desktop */
.tab-bar {
display: none;
}
/* Responsive */
@media (max-width: 900px) {
.app-body {
grid-template-columns: 220px 1fr 1fr;
grid-template-columns: 240px 1fr 1fr;
}
}
@media (max-width: 700px) {
/* Mobile: <= 600px (covers iPhone 11 at 390px CSS width) */
@media (max-width: 600px) {
html, body {
height: 100%;
overscroll-behavior: none;
}
body {
height: 100dvh;
}
/* Menu bar wraps to a second row.
NOTE: do not use `overflow: auto` here — it would create a new stacking
context and trap the absolutely-positioned `.menu-dropdown` underneath
the toolbar and content panes. */
.menu-bar {
height: auto;
min-height: 40px;
padding: 0 8px;
flex-shrink: 0;
}
.menu-list {
flex-wrap: wrap;
overflow: visible;
}
.menu-item {
padding: 8px 10px;
min-height: 40px;
display: flex;
align-items: center;
white-space: nowrap;
font-size: 14px;
}
/* Constrain the dropdown to the viewport width and a conservative height
on phones. Safe to use `overflow` here: it only clips the dropdown's own
contents, it does not trap the dropdown inside an ancestor's context. */
.menu-dropdown {
max-width: calc(100vw - 16px);
max-height: 55dvh;
}
.app-title {
display: none;
}
/* Toolbar wraps instead of stretching off-screen */
.toolbar {
flex-wrap: wrap;
height: auto;
padding: 8px;
gap: 8px;
flex-shrink: 0;
}
.toolbar-group {
flex: 1 1 auto;
min-width: 0;
}
.search-form {
flex: 1 1 100%;
min-width: 0;
display: flex;
}
.search-form input {
flex: 1;
width: 100%;
min-width: 0;
}
.theme-form {
flex: 1 1 auto;
min-width: 0;
}
.theme-form select {
flex: 1;
min-width: 0;
}
/* Apple HIG minimum 44px touch targets */
.btn {
min-height: 44px;
padding: 10px 14px;
font-size: 15px;
flex-shrink: 0;
}
.search-form input,
.theme-form select {
min-height: 44px;
padding: 10px 12px;
font-size: 16px; /* prevents iOS zoom-on-focus */
}
/* Tab bar replaces the 3-column layout on mobile */
.tab-bar {
display: flex;
flex-shrink: 0;
background-color: var(--app-bg);
border-bottom: 1px solid var(--ui-border);
padding: 4px 8px;
gap: 4px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.tab-button {
flex: 1;
min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 4px;
background: none;
border: none;
border-radius: 8px;
color: var(--app-fg);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.tab-button.active {
background-color: var(--ui-hover);
}
.tab-icon {
font-size: 18px;
line-height: 1;
}
/* Single-pane view: only the active tab's pane is displayed */
.app-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
display: block;
flex: 1;
min-height: 0;
overflow: hidden;
}
.sidebar {
.app-body > * {
display: none;
height: 100%;
border-right: none;
border-bottom: 1px solid var(--ui-border);
max-height: 200px;
border-bottom: none;
-webkit-overflow-scrolling: touch;
}
.editor-pane {
border-right: none;
border-bottom: 1px solid var(--ui-border);
.app-body > .mobile-active {
display: flex;
flex-direction: column;
}
/* Sidebar fills the pane on mobile */
.sidebar-header {
padding: 12px 16px;
}
.note-list {
padding: 8px;
}
.note-item a {
padding: 14px 12px;
}
/* Editor fills the pane on mobile */
.note-title-input {
font-size: 18px;
padding: 14px 16px;
flex-shrink: 0;
}
.note-content-input {
font-size: 16px; /* prevents iOS zoom-on-focus */
padding: 16px;
}
.form-actions {
padding: 12px 16px;
flex-shrink: 0;
}
.form-actions .btn {
width: 100%;
}
/* Preview fills the pane on mobile */
.markdown-body {
padding: 20px 16px;
font-size: 16px;
}
/* Status bar stays compact */
.status-bar {
height: 28px;
flex-shrink: 0;
gap: 10px;
font-size: 11px;
overflow-x: auto;
scrollbar-width: none;
}
.status-bar::-webkit-scrollbar {
display: none;
}
/* Prevent horizontal page scroll */
.flash-message {
flex-shrink: 0;
}
.about-page main {
padding: 24px 16px;
}
}