diff --git a/Cargo.lock b/Cargo.lock index 63763b9..617962e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d31c534..10dbdb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ anyhow = "1" thiserror = "1" percent-encoding = "2" clap = { version = "4", features = ["derive"] } +syntect = "5" diff --git a/README.md b/README.md index ef6472e..5745242 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main.rs b/src/main.rs index a16fbb2..c52c08e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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")); diff --git a/src/markdown.rs b/src/markdown.rs index 1296028..9c6a5f1 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -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> = 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("

Hello

")); assert!(html.contains("

This is a paragraph.

")); } @@ -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("")); assert!(html.contains("A")); } + + #[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(" 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)) } diff --git a/src/routes.rs b/src/routes.rs index a143484..82b35a5 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -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) -> Result { - let html = render_markdown(&form.content); +async fn preview_route( + headers: HeaderMap, + Form(form): Form, +) -> Result { + 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())) } diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 0000000..7519e08 --- /dev/null +++ b/src/syntax.rs @@ -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, +} + +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!("
{}
", html_escape(code))) +} + +static SYNTAX_STATE: OnceLock = OnceLock::new(); + +pub fn syntax_state() -> &'static SyntaxState { + SYNTAX_STATE.get_or_init(SyntaxState::new) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +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!( + " \n\ + name\n\ + {scope}\n\ + scope\n\ + {scope}\n\ + settings\n\ + \n\ + foreground\n\ + {color}\n\ + \n\ + \n" + )); + } + + format!( + "\n\ + \n\ + \n\ + \n\ + name\n\ + Rasuti {}\n\ + settings\n\ + \n\ + \n\ + settings\n\ + \n\ + background\n\ + {}\n\ + foreground\n\ + {}\n\ + \n\ + \n\ + {}\ + \n\ + \n\ + \n", + theme.name, theme.bg, theme.fg, scope_entries + ) +} diff --git a/src/themes.rs b/src/themes.rs index 78dad82..5cd7be8 100644 --- a/src/themes.rs +++ b/src/themes.rs @@ -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); } diff --git a/static/app.js b/static/app.js index 786379f..4ef927e 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); diff --git a/static/style.css b/static/style.css index 70d789c..2fe7cce 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } }