Add note export options: PDF, markdown and HTML. Also add more themes
This commit is contained in:
parent
e6519d42a4
commit
0e0dcdd2c0
8 changed files with 710 additions and 109 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -85,7 +85,7 @@ eternalibre-notes/
|
|||
│ ├── notes.rs # Note model + file-system persistence
|
||||
│ ├── markdown.rs # Markdown → HTML with code block interception
|
||||
│ ├── syntax.rs # syntect engine + per-theme tmTheme generation
|
||||
│ ├── themes.rs # 24 predefined themes + CSS variable engine
|
||||
│ ├── themes.rs # 34 predefined themes + CSS variable engine
|
||||
│ └── storage.rs # Storage re-export module
|
||||
├── static/
|
||||
│ ├── alpine.min.js # Vendored Alpine.js
|
||||
|
|
@ -121,7 +121,7 @@ pub struct ANSIPalette {
|
|||
}
|
||||
```
|
||||
|
||||
`PREDEFINED_THEMES` contains 24 themes: Dark, Light, Solarized Dark, Solarized Light, Dracula, Gruvbox Dark, Gruvbox Light, Nord, Monokai, Tokyo Night, One Dark, Cyberpunk 2077, Cyan on Black, Red on Black, Lime Green on Black, Chartreuse on Black, Amber on Black, Blueberry on Black, Spring Blossom, Summer Sunset, Autumn Forest, Winter Frost, Stormy Night, and Foggy Morning.
|
||||
`PREDEFINED_THEMES` contains 34 alphabetically ordered themes: Amber on Black, Autumn Forest, Blueberry on Black, Breeze, Breeze Dark, Chartreuse on Black, Classic Windows 95, Cyan on Black, Cyberpunk 2077, Dark, Dracula, Foggy Morning, Gruvbox Dark, Gruvbox Light, Light, Lilac Paper, Lime Green on Black, Monokai, Nord, One Dark, Orchid Noir, Platinum Desktop, Red on Black, Rose Quartz, Solarized Dark, Solarized Light, Spring Blossom, Stormy Night, Summer Sunset, Tokyo Night, Velvet Dusk, Vista Aero, Windows XP, and Winter Frost.
|
||||
|
||||
"Sunny Day" was removed: a mid-green background with a pale yellow foreground left almost nothing legible on it, and no amount of syntax tuning rescues a palette that bad.
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ Where no candidate clears the floor, the palette is at fault, not the code. **So
|
|||
|
||||
Comments also carry `fontStyle: italic` in the tmTheme, which syntect renders as `font-style:italic` on the span. Color alone is not a reliable signal, since a comment that falls back to the foreground is by definition the same color as the prose.
|
||||
|
||||
The three failure modes are each pinned by a test, and each test was verified to fail when the corresponding bug is reintroduced: `comments_are_readable_in_every_theme`, `no_theme_uses_its_background_for_comments`, and `comments_stay_distinct_from_the_foreground_when_they_can`. `syntax_color_always_picks_the_more_readable_variant` additionally pins the selection rule itself across all 24 themes and all 8 slots.
|
||||
The three failure modes are each pinned by a test, and each test was verified to fail when the corresponding bug is reintroduced: `comments_are_readable_in_every_theme`, `no_theme_uses_its_background_for_comments`, and `comments_stay_distinct_from_the_foreground_when_they_can`. `syntax_color_always_picks_the_more_readable_variant` additionally pins the selection rule itself across all 34 themes and all 8 slots.
|
||||
|
||||
### The Light theme has a data ceiling, not a logic bug
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ Implementation notes:
|
|||
|
||||
> **Do not reintroduce `overflow`, `transform`, `filter`, or `opacity` on `.menu-bar`, `.menu-list`, or `.menu-item`.** `overflow` on the dropdown *itself* is safe (it only clips its own contents), but on an ancestor it traps the dropdown.
|
||||
|
||||
The 24-entry Theme menu can exceed the viewport height, so `.menu-dropdown` itself carries `max-height` plus `overflow-y: auto`. This is the safe location for that property.
|
||||
The 34-entry Theme menu can exceed the viewport height, so its `.menu-dropdown` carries `max-height` plus `overflow-y: auto`. The short File dropdown is the exception: `.file-menu-dropdown` uses `overflow: visible` so the Export Note As flyout can escape to the right; scrolling is confined to the flyout itself. Keep overflow off the menu-bar ancestors.
|
||||
|
||||
### Every screen needs a viewport meta, or the mobile CSS is dead
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ The trap is that the CSS is *correct and present*. You can add a complete mobile
|
|||
|
||||
**Its version and theme counts are derived, never typed.** The version comes from `env!("CARGO_PKG_VERSION")`, and the same macro drives the `.app-title` in the menu bar — which previously carried a hand-written `"v0.1"` that had already drifted from the crate's `0.1.0`. The theme line is `PREDEFINED_THEMES.len()` split by `Theme::is_dark()`, so adding or removing a palette needs no edit here. Both are pinned by `about_page_reports_the_manifest_version` and `about_page_theme_counts_come_from_the_palette`.
|
||||
|
||||
`Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 24 themes today, so the current answer is not a close call.
|
||||
`Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 34 themes today, so the current answer is not a close call.
|
||||
|
||||
**Every outbound link carries `rel="noopener noreferrer"`.** These are the only links that leave the machine, and the app advertises that it never talks to a third party. `about_page_external_links_are_hardened` walks every `<a href="https://` in the rendered page, so a link added later without the attributes fails rather than shipping quietly. It caught the Help menu's GPL link, which had `target="_blank"` and no `rel`.
|
||||
|
||||
|
|
@ -313,6 +313,9 @@ Outside-click detection is a manual `document` listener in an `init()` hook with
|
|||
| `POST` | `/logout` | Drop the session server-side and clear the cookie |
|
||||
| `GET` | `/` | App shell with note list (accepts `?q=` for search) |
|
||||
| `GET` | `/notes/:id` | App shell with a note open in the editor |
|
||||
| `GET` | `/notes/:id/export/markdown` | Download a saved note as Markdown |
|
||||
| `GET` | `/notes/:id/export/html` | Download a self-contained HTML rendering in the selected theme |
|
||||
| `GET` | `/notes/:id/export/pdf` | Open a theme-aware print view and invoke the browser print dialog |
|
||||
| `POST` | `/notes` | Create a new note, redirect to it |
|
||||
| `POST` | `/notes/:id` | Update or delete based on the `_method` form field |
|
||||
| `POST` | `/preview` | Returns rendered HTML for live preview |
|
||||
|
|
@ -321,6 +324,8 @@ Outside-click detection is a manual `document` listener in an `init()` hook with
|
|||
| `GET` | `/export` | Downloads all notes as a single Markdown file |
|
||||
| `*` | `/static/*` | Vendored Alpine.js, app.js, and style.css |
|
||||
|
||||
Single-note exports are authentication-gated and always use the saved note on disk; save the editor first to include pending edits. Markdown downloads include the note title as a heading. HTML exports inline both the selected theme variables and application stylesheet, so they remain styled when moved elsewhere. PDF export is a print-ready HTML document that opens the browser's print dialog (choose “Save as PDF”); `print-color-adjust: exact` requests themed backgrounds, though browser print settings may still omit them. No PDF generation dependency is required.
|
||||
|
||||
Notes are stored as `<uuid>.md` files in `notes/` with YAML frontmatter carrying `title`, `created`, and `updated` timestamps. When no frontmatter is present, the title is derived from the first `# ` heading. The directory is overridable via the `--notes-dir` flag or the `ETERNALIBRE_NOTES_DIR` env var, which is how the integration tests stay out of real user data.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ authenticator app. No telemetry, no accounts, no third-party services.
|
|||
- **Works without JavaScript** — note editing, search, theming, and the login form are plain HTML; the menus, mobile tabs, and live preview are the JS-dependent parts
|
||||
- **Alpine.js enhancements** — live Markdown preview, instant search, keyboard shortcuts
|
||||
- **File-based storage** — notes are persisted as Markdown files in `notes/`
|
||||
- **24 built-in themes** — from Dark/Light to Solarized, Dracula, Nord, Monokai, and seasonal palettes
|
||||
- **Per-note exports** — download Markdown or self-contained themed HTML, or print/save the themed note as PDF
|
||||
- **34 built-in themes** — including KDE Breeze, classic desktop homages, lavender and rose palettes, Solarized, Dracula, and more
|
||||
- **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
|
||||
|
|
@ -123,7 +124,7 @@ cargo fmt
|
|||
| `src/notes.rs` | Note model and file-system persistence |
|
||||
| `src/markdown.rs` | Markdown to HTML with syntax highlighting |
|
||||
| `src/syntax.rs` | syntect integration and per-theme syntax color scheme generation |
|
||||
| `src/themes.rs` | 24 predefined themes and CSS variable engine |
|
||||
| `src/themes.rs` | 34 predefined themes and CSS variable engine |
|
||||
| `static/style.css` | Full application stylesheet |
|
||||
| `static/app.js` | Alpine.js component: preview, search, menus, tabs |
|
||||
| `tests/auth_gating.rs` | End-to-end tests for the TOTP auth gate |
|
||||
|
|
@ -134,4 +135,3 @@ This program is free software: you can redistribute it and/or modify it under
|
|||
the terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later
|
||||
version. See `LICENSE` for the full text.
|
||||
|
||||
|
|
|
|||
105
src/pages.rs
105
src/pages.rs
|
|
@ -68,7 +68,7 @@ pub fn render_app(
|
|||
script defer src=(base.url("/static/alpine.min.js")) {}
|
||||
}
|
||||
body x-data="app()" x-on:keydown="handleKeydown($event)" {
|
||||
(render_menu_bar(base))
|
||||
(render_menu_bar(base, current_note))
|
||||
(render_toolbar(current_note, query, theme_name, base))
|
||||
@if let Some(msg) = message {
|
||||
div class="flash-message" { (msg) }
|
||||
|
|
@ -130,7 +130,7 @@ fn render_tab_bar(default_tab: &str) -> Markup {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_menu_bar(base: &Base<'_>) -> Markup {
|
||||
fn render_menu_bar(base: &Base<'_>, current_note: Option<&Note>) -> Markup {
|
||||
html! {
|
||||
nav class="menu-bar" {
|
||||
ul class="menu-list" {
|
||||
|
|
@ -138,7 +138,7 @@ fn render_menu_bar(base: &Base<'_>) -> Markup {
|
|||
x-on:click="toggleMenu('file')"
|
||||
x-bind:class="{ 'open': openMenu === 'file' }" {
|
||||
span { "File" }
|
||||
ul class="menu-dropdown" {
|
||||
ul class="menu-dropdown file-menu-dropdown" {
|
||||
li {
|
||||
form action=(base.url("/notes")) method="post" {
|
||||
button type="submit" { "New Note" }
|
||||
|
|
@ -148,6 +148,37 @@ fn render_menu_bar(base: &Base<'_>) -> Markup {
|
|||
li {
|
||||
a href=(base.url("/export")) { "Export All Notes" }
|
||||
}
|
||||
li class="menu-submenu" {
|
||||
@if let Some(note) = current_note {
|
||||
button type="button" class="menu-submenu-trigger"
|
||||
x-on:click="toggleSubmenu('noteExport', $event)"
|
||||
x-bind:aria-expanded="openSubmenu === 'noteExport'" {
|
||||
"Export Note As…"
|
||||
span class="submenu-chevron" aria-hidden="true" { "›" }
|
||||
}
|
||||
ul class="menu-submenu-list"
|
||||
x-bind:class="{ 'open': openSubmenu === 'noteExport' }" {
|
||||
li {
|
||||
a href=(base.url(&format!("/notes/{}/export/pdf", note.id))) {
|
||||
"Print/PDF"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a href=(base.url(&format!("/notes/{}/export/markdown", note.id))) {
|
||||
"Markdown"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a href=(base.url(&format!("/notes/{}/export/html", note.id))) {
|
||||
"HTML"
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
button type="button" class="menu-submenu-trigger" disabled
|
||||
aria-disabled="true" { "Export Note As…" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
li class="menu-item" tabindex="0"
|
||||
|
|
@ -387,6 +418,67 @@ pub fn render_preview_fragment(html_content: &str) -> Markup {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders a self-contained, theme-aware note document for HTML download or
|
||||
/// the browser's print-to-PDF flow.
|
||||
pub fn render_note_export(
|
||||
note: &Note,
|
||||
theme: &crate::themes::Theme,
|
||||
print_on_load: bool,
|
||||
) -> Markup {
|
||||
let rendered = render_markdown(¬e.content, theme);
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" data-theme=(theme.name) {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
title { (note.title) }
|
||||
style { (PreEscaped(theme.to_css_block())) }
|
||||
style { (PreEscaped(include_str!("../static/style.css"))) }
|
||||
style {
|
||||
(PreEscaped(r#"
|
||||
html, body { height: auto; min-height: 100%; }
|
||||
body { display: block; height: auto; min-height: 100vh; overflow: auto; }
|
||||
.note-export { max-width: 900px; margin: 0 auto; padding: 48px; }
|
||||
.note-export-title { margin: 0 0 28px; overflow-wrap: anywhere; }
|
||||
@media print {
|
||||
@page { margin: 18mm; }
|
||||
html, body {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
background-color: var(--app-bg) !important;
|
||||
color: var(--app-fg) !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
body { display: block; overflow: visible; }
|
||||
.note-export { max-width: none; margin: 0; padding: 0; }
|
||||
.note-export-title { break-after: avoid-page; }
|
||||
.markdown-body pre {
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown-body pre, .markdown-body blockquote,
|
||||
.markdown-body table { break-inside: avoid; }
|
||||
.markdown-body a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
"#))
|
||||
}
|
||||
}
|
||||
body class="note-export-page" {
|
||||
main class="note-export" {
|
||||
h1 class="note-export-title" { (note.title) }
|
||||
div class="markdown-body" { (PreEscaped(rendered)) }
|
||||
}
|
||||
@if print_on_load {
|
||||
script { "window.addEventListener('load', () => window.print());" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the `/login` page.
|
||||
///
|
||||
/// TOTP enrolment lives in the terminal (`setup::run_interactive_setup`) and
|
||||
|
|
@ -462,7 +554,7 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
|
|||
style { (PreEscaped(include_str!("../static/style.css"))) }
|
||||
}
|
||||
body class="about-page" {
|
||||
(render_menu_bar(base))
|
||||
(render_menu_bar(base, None))
|
||||
main {
|
||||
h1 { "EternaLibre Notes" }
|
||||
p class="tagline" { "Free notes, forever." }
|
||||
|
|
@ -480,11 +572,11 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
|
|||
// Counted from the palette itself, and split by
|
||||
// background luminance. This number used to be
|
||||
// hardcoded and had drifted to "28" for what was
|
||||
// really 25 themes.
|
||||
// really 24 themes.
|
||||
li {
|
||||
(format!(
|
||||
"{} built-in themes ({} dark, {} light) with a full \
|
||||
ANSI palette — Solarized, Dracula, Nord, Monokai, and more",
|
||||
ANSI palette — Solarized, Dracula, KDE Breeze, Lilac Paper, and more",
|
||||
themes.len(),
|
||||
dark_themes,
|
||||
themes.len() - dark_themes,
|
||||
|
|
@ -494,6 +586,7 @@ pub fn render_about(theme_name: &str, base: &Base<'_>) -> Markup {
|
|||
li { "Notes stored as plain Markdown files on disk" }
|
||||
li { "TOTP-locked, with no accounts or third-party services" }
|
||||
li { "Note editing, search, and theming work without JavaScript" }
|
||||
li { "Export saved notes as Markdown, themed HTML, or PDF via the browser print dialog" }
|
||||
}
|
||||
h2 { "Author" }
|
||||
ul class="about-links" {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ pub fn router(state: AppState) -> Router {
|
|||
.route("/theme", post(theme_route))
|
||||
.route("/about", get(about_route))
|
||||
.route("/export", get(export_route))
|
||||
.route(
|
||||
"/notes/:id/export/markdown",
|
||||
get(export_note_markdown_route),
|
||||
)
|
||||
.route("/notes/:id/export/html", get(export_note_html_route))
|
||||
.route("/notes/:id/export/pdf", get(export_note_pdf_route))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth));
|
||||
|
||||
public.merge(protected).with_state(state)
|
||||
|
|
@ -471,6 +477,92 @@ async fn export_route(_headers: HeaderMap) -> Result<impl IntoResponse, AppError
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
async fn export_note_markdown_route(Path(id): Path<String>) -> Result<impl IntoResponse, AppError> {
|
||||
let note = notes::load_note(&id)?;
|
||||
let markdown = format!("# {}\n\n{}", note.title.replace('\n', " "), note.content);
|
||||
let filename = note_export_filename(¬e, "md");
|
||||
Ok(note_export_response(
|
||||
markdown.into_response(),
|
||||
"text/markdown; charset=utf-8",
|
||||
&filename,
|
||||
"attachment",
|
||||
))
|
||||
}
|
||||
|
||||
async fn export_note_html_route(
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let note = notes::load_note(&id)?;
|
||||
let theme = get_theme(&get_theme_from_cookie(&headers));
|
||||
let html = pages::render_note_export(¬e, theme, false).into_string();
|
||||
let filename = note_export_filename(¬e, "html");
|
||||
Ok(note_export_response(
|
||||
Html(html).into_response(),
|
||||
"text/html; charset=utf-8",
|
||||
&filename,
|
||||
"attachment",
|
||||
))
|
||||
}
|
||||
|
||||
async fn export_note_pdf_route(
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let note = notes::load_note(&id)?;
|
||||
let theme = get_theme(&get_theme_from_cookie(&headers));
|
||||
// The browser's print dialog can save this styled document as PDF without
|
||||
// a separate renderer, while preserving the selected theme and CSS.
|
||||
let html = pages::render_note_export(¬e, theme, true).into_string();
|
||||
let filename = note_export_filename(¬e, "html");
|
||||
Ok(note_export_response(
|
||||
Html(html).into_response(),
|
||||
"text/html; charset=utf-8",
|
||||
&filename,
|
||||
"inline",
|
||||
))
|
||||
}
|
||||
|
||||
fn note_export_filename(note: &Note, extension: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut separator = false;
|
||||
for ch in note.title.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
if separator && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
}
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
separator = false;
|
||||
} else {
|
||||
separator = true;
|
||||
}
|
||||
if slug.len() >= 80 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_end_matches('-');
|
||||
let stem = if slug.is_empty() { "note" } else { slug };
|
||||
format!("{stem}.{extension}")
|
||||
}
|
||||
|
||||
fn note_export_response(
|
||||
mut response: Response,
|
||||
content_type: &str,
|
||||
filename: &str,
|
||||
disposition: &str,
|
||||
) -> Response {
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("{disposition}; filename=\"{filename}\"")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(header::CACHE_CONTROL, "private, no-store".parse().unwrap());
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppError(anyhow::Error);
|
||||
|
||||
|
|
|
|||
430
src/themes.rs
430
src/themes.rs
|
|
@ -70,7 +70,7 @@ impl Theme {
|
|||
/// any threshold in that gap returns the same answer. That margin is why
|
||||
/// this can use the local gamma-encoded `relative_luminance` rather than
|
||||
/// the linearised one in `syntax.rs` — for a binary split the two agree
|
||||
/// on all 24 themes, and adding a third implementation would cost more
|
||||
/// on all 34 themes, and adding a third implementation would cost more
|
||||
/// than the precision is worth.
|
||||
pub fn is_dark(&self) -> bool {
|
||||
relative_luminance(self.bg) < 0.5
|
||||
|
|
@ -100,6 +100,105 @@ fn relative_luminance(hex: &str) -> f64 {
|
|||
}
|
||||
|
||||
pub static PREDEFINED_THEMES: &[Theme] = &[
|
||||
Theme {
|
||||
name: "Amber on Black",
|
||||
fg: "#ffbf00",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Autumn Forest",
|
||||
fg: "#d4a373",
|
||||
bg: "#3e2723",
|
||||
ansi_normal: [
|
||||
"#3e2723", "#bf360c", "#e65100", "#8d6e63", "#5d4037", "#33691e", "#9e9d24", "#a1887f",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#6d4c41", "#e64a19", "#f57c00", "#a1887f", "#795548", "#558b2f", "#c0ca33", "#d7ccc8",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Blueberry on Black",
|
||||
fg: "#8ec5fc",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#0d47a1", "#1565c0", "#1976d2", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#37474f", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9", "#bbdefb", "#e3f2fd", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Breeze",
|
||||
fg: "#232629",
|
||||
bg: "#eff0f1",
|
||||
ansi_normal: [
|
||||
"#eff0f1", "#b00020", "#2e7d32", "#8a5a00", "#1d5f91", "#8e44ad", "#007f7f", "#232629",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#7f8c8d", "#a31515", "#256d2a", "#805400", "#0067c0", "#7b2e9a", "#006b6b", "#111111",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Breeze Dark",
|
||||
fg: "#eff0f1",
|
||||
bg: "#232629",
|
||||
ansi_normal: [
|
||||
"#232629", "#f07178", "#8bd49c", "#e5c07b", "#3daee9", "#c792ea", "#56b6c2", "#d8dee9",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#697078", "#ff7a85", "#a3e6b1", "#f2cc8f", "#61c5f0", "#d6a8f5", "#70d0db", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Chartreuse on Black",
|
||||
fg: "#7fff00",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Classic Windows 95",
|
||||
fg: "#111111",
|
||||
bg: "#c0c0c0",
|
||||
ansi_normal: [
|
||||
"#c0c0c0", "#800000", "#006400", "#806000", "#000080", "#800080", "#008080", "#111111",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#808080", "#c00000", "#008000", "#9a6700", "#004e98", "#9a4e9a", "#008b8b", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Cyan on Black",
|
||||
fg: "#00ffff",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Cyberpunk 2077",
|
||||
fg: "#f9f227",
|
||||
bg: "#0d0d0d",
|
||||
ansi_normal: [
|
||||
"#0d0d0d", "#ff0055", "#00ff41", "#f9f227", "#00b8ff", "#ff00cc", "#00ffff", "#f9f227",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#333333", "#ff3377", "#33ff66", "#ffff33", "#33ccff", "#ff33ff", "#33ffff", "#ffff66",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Dark",
|
||||
fg: "#d0d0d0",
|
||||
|
|
@ -111,39 +210,6 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#666666", "#ff6666", "#66ff66", "#ffff66", "#6666ff", "#ff66ff", "#66ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Light",
|
||||
fg: "#1c1c1c",
|
||||
bg: "#d0d0d0",
|
||||
ansi_normal: [
|
||||
"#1c1c1c", "#c50f1f", "#23a523", "#b58900", "#268bd2", "#d33682", "#2aa198", "#d0d0d0",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff6666", "#66ff66", "#ffff66", "#6666ff", "#ff66ff", "#66ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Solarized Dark",
|
||||
fg: "#839496",
|
||||
bg: "#002b36",
|
||||
ansi_normal: [
|
||||
"#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Solarized Light",
|
||||
fg: "#657b83",
|
||||
bg: "#fdf6e3",
|
||||
ansi_normal: [
|
||||
"#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Dracula",
|
||||
fg: "#f8f8f2",
|
||||
|
|
@ -155,6 +221,17 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Foggy Morning",
|
||||
fg: "#cfd8dc",
|
||||
bg: "#455a64",
|
||||
ansi_normal: [
|
||||
"#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#f5f5f5", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Gruvbox Dark",
|
||||
fg: "#ebdbb2",
|
||||
|
|
@ -178,80 +255,25 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Nord",
|
||||
fg: "#d8dee9",
|
||||
bg: "#2e3440",
|
||||
name: "Light",
|
||||
fg: "#1c1c1c",
|
||||
bg: "#d0d0d0",
|
||||
ansi_normal: [
|
||||
"#3b4252", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#88c0d0", "#e5e9f0",
|
||||
"#1c1c1c", "#c50f1f", "#23a523", "#b58900", "#268bd2", "#d33682", "#2aa198", "#d0d0d0",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#4c566a", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#8fbcbb", "#eceff4",
|
||||
"#666666", "#ff6666", "#66ff66", "#ffff66", "#6666ff", "#ff66ff", "#66ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Monokai",
|
||||
fg: "#f8f8f2",
|
||||
bg: "#272822",
|
||||
name: "Lilac Paper",
|
||||
fg: "#302b3d",
|
||||
bg: "#f4f0fa",
|
||||
ansi_normal: [
|
||||
"#272822", "#f92672", "#a6e22e", "#f4bf75", "#66d9ef", "#ae81ff", "#a1efe4", "#f8f8f2",
|
||||
"#f4f0fa", "#a33a58", "#3d7553", "#856000", "#584c9d", "#814f91", "#287781", "#302b3d",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#75715e", "#f92672", "#a6e22e", "#e6db74", "#66d9ef", "#ae81ff", "#a1efe4", "#f9f8f5",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Tokyo Night",
|
||||
fg: "#c0caf5",
|
||||
bg: "#1a1b26",
|
||||
ansi_normal: [
|
||||
"#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#414868", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "One Dark",
|
||||
fg: "#abb2bf",
|
||||
bg: "#282c34",
|
||||
ansi_normal: [
|
||||
"#282c34", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#abb2bf",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#5c6370", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Cyberpunk 2077",
|
||||
fg: "#f9f227",
|
||||
bg: "#0d0d0d",
|
||||
ansi_normal: [
|
||||
"#0d0d0d", "#ff0055", "#00ff41", "#f9f227", "#00b8ff", "#ff00cc", "#00ffff", "#f9f227",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#333333", "#ff3377", "#33ff66", "#ffff33", "#33ccff", "#ff33ff", "#33ffff", "#ffff66",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Cyan on Black",
|
||||
fg: "#00ffff",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Red on Black",
|
||||
fg: "#ff0000",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#89829a", "#96334f", "#2d704a", "#755500", "#5d468f", "#713f83", "#176c74", "#211a2a",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -266,8 +288,63 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Chartreuse on Black",
|
||||
fg: "#7fff00",
|
||||
name: "Monokai",
|
||||
fg: "#f8f8f2",
|
||||
bg: "#272822",
|
||||
ansi_normal: [
|
||||
"#272822", "#f92672", "#a6e22e", "#f4bf75", "#66d9ef", "#ae81ff", "#a1efe4", "#f8f8f2",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#75715e", "#f92672", "#a6e22e", "#e6db74", "#66d9ef", "#ae81ff", "#a1efe4", "#f9f8f5",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Nord",
|
||||
fg: "#d8dee9",
|
||||
bg: "#2e3440",
|
||||
ansi_normal: [
|
||||
"#3b4252", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#88c0d0", "#e5e9f0",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#4c566a", "#bf616a", "#a3be8c", "#ebcb8b", "#81a1c1", "#b48ead", "#8fbcbb", "#eceff4",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "One Dark",
|
||||
fg: "#abb2bf",
|
||||
bg: "#282c34",
|
||||
ansi_normal: [
|
||||
"#282c34", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#abb2bf",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#5c6370", "#e06c75", "#98c379", "#e5c07b", "#61afef", "#c678dd", "#56b6c2", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Orchid Noir",
|
||||
fg: "#f3eaf8",
|
||||
bg: "#110b18",
|
||||
ansi_normal: [
|
||||
"#110b18", "#ff6b9b", "#8fe0b0", "#f4cf78", "#b775f2", "#ff66b3", "#65d7dc", "#f3eaf8",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#685d70", "#ff8ab0", "#a7ecc1", "#ffdf96", "#c78df7", "#ff86c3", "#82e7eb", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Platinum Desktop",
|
||||
fg: "#242424",
|
||||
bg: "#d6d6d6",
|
||||
ansi_normal: [
|
||||
"#d6d6d6", "#a33a3a", "#3d6b45", "#8a6200", "#336699", "#78508a", "#287d78", "#242424",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#777777", "#8f1d1d", "#2f6a38", "#765000", "#225a91", "#6b3f7e", "#176963", "#101010",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Red on Black",
|
||||
fg: "#ff0000",
|
||||
bg: "#000000",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
|
|
@ -277,25 +354,36 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Amber on Black",
|
||||
fg: "#ffbf00",
|
||||
bg: "#000000",
|
||||
name: "Rose Quartz",
|
||||
fg: "#3a2630",
|
||||
bg: "#fff1f4",
|
||||
ansi_normal: [
|
||||
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
|
||||
"#fff1f4", "#a52a4e", "#39724a", "#856000", "#b83f68", "#853f78", "#176f76", "#3a2630",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#666666", "#ff0000", "#00ff00", "#ffff00", "#a9a9f9", "#ff00ff", "#00ffff", "#ffffff",
|
||||
"#8b7b84", "#922243", "#2e653e", "#795400", "#9b3157", "#713365", "#0d646b", "#25171d",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Blueberry on Black",
|
||||
fg: "#8ec5fc",
|
||||
bg: "#000000",
|
||||
name: "Solarized Dark",
|
||||
fg: "#839496",
|
||||
bg: "#002b36",
|
||||
ansi_normal: [
|
||||
"#000000", "#0d47a1", "#1565c0", "#1976d2", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9",
|
||||
"#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#37474f", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9", "#bbdefb", "#e3f2fd", "#ffffff",
|
||||
"#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Solarized Light",
|
||||
fg: "#657b83",
|
||||
bg: "#fdf6e3",
|
||||
ansi_normal: [
|
||||
"#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -309,6 +397,17 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#558b2f", "#e91e63", "#8bc34a", "#ffeb3b", "#009688", "#ec407a", "#c5e1a5", "#fce4ec",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Stormy Night",
|
||||
fg: "#b0bec5",
|
||||
bg: "#263238",
|
||||
ansi_normal: [
|
||||
"#263238", "#37474f", "#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#455a64", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Summer Sunset",
|
||||
fg: "#ff9a5a",
|
||||
|
|
@ -321,14 +420,47 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Autumn Forest",
|
||||
fg: "#d4a373",
|
||||
bg: "#3e2723",
|
||||
name: "Tokyo Night",
|
||||
fg: "#c0caf5",
|
||||
bg: "#1a1b26",
|
||||
ansi_normal: [
|
||||
"#3e2723", "#bf360c", "#e65100", "#8d6e63", "#5d4037", "#33691e", "#9e9d24", "#a1887f",
|
||||
"#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#a9b1d6",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#6d4c41", "#e64a19", "#f57c00", "#a1887f", "#795548", "#558b2f", "#c0ca33", "#d7ccc8",
|
||||
"#414868", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Velvet Dusk",
|
||||
fg: "#eae1f2",
|
||||
bg: "#1c1422",
|
||||
ansi_normal: [
|
||||
"#1c1422", "#f0718a", "#91d39b", "#e8c27a", "#ab82d3", "#d08acb", "#70c9c4", "#eae1f2",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#63586b", "#ff8299", "#a6e6af", "#f2d08c", "#c19ae8", "#e1a0dc", "#83ddd6", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Vista Aero",
|
||||
fg: "#eaf4fb",
|
||||
bg: "#14263a",
|
||||
ansi_normal: [
|
||||
"#14263a", "#ff6b7a", "#8fe3b2", "#ffd27a", "#53b8f2", "#ce9eff", "#67dce3", "#eaf4fb",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#697f96", "#ff8793", "#a4eac1", "#ffdda0", "#62c8ff", "#d9b4ff", "#7ae8ed", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Windows XP",
|
||||
fg: "#1c2a3a",
|
||||
bg: "#eaf2fa",
|
||||
ansi_normal: [
|
||||
"#eaf2fa", "#a52222", "#247a35", "#886000", "#245edc", "#873b80", "#007984", "#1c2a3a",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#7b8794", "#8f1d1d", "#216b2f", "#765000", "#245edc", "#71316b", "#006b74", "#111820",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
|
|
@ -342,35 +474,18 @@ pub static PREDEFINED_THEMES: &[Theme] = &[
|
|||
"#283593", "#00897b", "#00acc1", "#26c6da", "#4dd0e1", "#80deea", "#b2ebf2", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Stormy Night",
|
||||
fg: "#b0bec5",
|
||||
bg: "#263238",
|
||||
ansi_normal: [
|
||||
"#263238", "#37474f", "#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#455a64", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#ffffff",
|
||||
],
|
||||
},
|
||||
Theme {
|
||||
name: "Foggy Morning",
|
||||
fg: "#cfd8dc",
|
||||
bg: "#455a64",
|
||||
ansi_normal: [
|
||||
"#455a64", "#546e7a", "#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1",
|
||||
],
|
||||
ansi_bright: [
|
||||
"#607d8b", "#78909c", "#90a4ae", "#b0bec5", "#cfd8dc", "#eceff1", "#f5f5f5", "#ffffff",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
pub fn get_theme(name: &str) -> &'static Theme {
|
||||
PREDEFINED_THEMES
|
||||
.iter()
|
||||
.find(|t| t.name == name)
|
||||
.unwrap_or(&PREDEFINED_THEMES[0])
|
||||
.unwrap_or_else(|| {
|
||||
PREDEFINED_THEMES
|
||||
.iter()
|
||||
.find(|theme| theme.name == "Dark")
|
||||
.expect("the default Dark theme must be present")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -426,8 +541,41 @@ mod tests {
|
|||
// Dropping a palette shrinks the Theme menu, so the docs and the
|
||||
// about page count have to move with it. This assertion caught the
|
||||
// count being wrong in the docs before this change: they claimed 28
|
||||
// for what was really 25.
|
||||
assert_eq!(PREDEFINED_THEMES.len(), 24);
|
||||
// for what was really 24.
|
||||
assert_eq!(PREDEFINED_THEMES.len(), 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breeze_variants_are_a_light_dark_pair() {
|
||||
let light = get_theme("Breeze");
|
||||
let dark = get_theme("Breeze Dark");
|
||||
|
||||
assert!(!light.is_dark());
|
||||
assert!(dark.is_dark());
|
||||
assert_eq!(light.ansi_bright[4], "#0067c0");
|
||||
assert_eq!(dark.ansi_normal[4], "#3daee9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lavender_and_rose_themes_cover_light_and_dark_modes() {
|
||||
assert!(!get_theme("Lilac Paper").is_dark());
|
||||
assert!(!get_theme("Rose Quartz").is_dark());
|
||||
assert!(get_theme("Velvet Dusk").is_dark());
|
||||
assert!(get_theme("Orchid Noir").is_dark());
|
||||
assert_eq!(get_theme("Rose Quartz").ansi_bright[4], "#9b3157");
|
||||
assert_eq!(get_theme("Orchid Noir").ansi_bright[5], "#ff86c3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_names_are_alphabetical() {
|
||||
let names: Vec<String> = PREDEFINED_THEMES
|
||||
.iter()
|
||||
.map(|theme| theme.name.to_lowercase())
|
||||
.collect();
|
||||
assert!(
|
||||
names.windows(2).all(|pair| pair[0] <= pair[1]),
|
||||
"theme names should be alphabetized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function app() {
|
|||
// 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,
|
||||
openSubmenu: null,
|
||||
_previewTimeout: null,
|
||||
|
||||
init() {
|
||||
|
|
@ -31,10 +32,19 @@ function app() {
|
|||
|
||||
toggleMenu(name) {
|
||||
this.openMenu = this.openMenu === name ? null : name;
|
||||
this.openSubmenu = null;
|
||||
},
|
||||
|
||||
toggleSubmenu(name, event) {
|
||||
// Stop this click before it reaches the File menu's trigger, which would
|
||||
// otherwise close the whole menu as the submenu opens.
|
||||
event.stopPropagation();
|
||||
this.openSubmenu = this.openSubmenu === name ? null : name;
|
||||
},
|
||||
|
||||
closeMenus() {
|
||||
this.openMenu = null;
|
||||
this.openSubmenu = null;
|
||||
},
|
||||
|
||||
setTab(name) {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ button, input, select, textarea {
|
|||
top: calc(100% - 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
/* The Theme menu has 24 entries; cap the height so it cannot run off
|
||||
/* The Theme menu has 34 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;
|
||||
|
|
@ -102,6 +102,14 @@ button, input, select, textarea {
|
|||
z-index: 100;
|
||||
}
|
||||
|
||||
/* The File menu is short, so let its flyout escape horizontally instead of
|
||||
letting the generic scroll container clip it. The nested flyout handles its
|
||||
own vertical scrolling near the bottom edge of the viewport. */
|
||||
.menu-dropdown.file-menu-dropdown {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.menu-dropdown li {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -139,6 +147,61 @@ button, input, select, textarea {
|
|||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.menu-submenu-trigger {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.menu-submenu-trigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-dropdown .menu-submenu-trigger:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.submenu-chevron {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.menu-submenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-submenu-list {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% - 2px);
|
||||
z-index: 101;
|
||||
min-width: 180px;
|
||||
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;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.menu-submenu-list.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-dropdown .menu-submenu-list a {
|
||||
padding-left: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
|
@ -856,6 +919,13 @@ button, input, select, textarea {
|
|||
max-height: 55dvh;
|
||||
}
|
||||
|
||||
.menu-submenu-list {
|
||||
width: calc(100vw - 205px);
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
max-height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.app-title {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,19 +151,33 @@ impl Fixture {
|
|||
|
||||
/// GET carrying the session cookie.
|
||||
async fn get_authed(&self, uri: &str) -> (StatusCode, String) {
|
||||
let res = self
|
||||
.app
|
||||
finish(self.get_authed_response(uri, None).await).await
|
||||
}
|
||||
|
||||
async fn get_authed_with_theme(&self, uri: &str, theme: &str) -> axum::response::Response {
|
||||
self.get_authed_response(uri, Some(theme)).await
|
||||
}
|
||||
|
||||
async fn get_authed_response(
|
||||
&self,
|
||||
uri: &str,
|
||||
theme: Option<&str>,
|
||||
) -> axum::response::Response {
|
||||
let mut cookie = self.cookie_header();
|
||||
if let Some(theme) = theme {
|
||||
cookie.push_str(&format!("; theme={theme}"));
|
||||
}
|
||||
self.app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, self.cookie_header())
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
finish(res).await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// POST as an anonymous visitor.
|
||||
|
|
@ -448,6 +462,158 @@ async fn export_requires_authentication() {
|
|||
assert!(!body.contains("EternaLibre Notes Export"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_note_export_menu_is_disabled_without_a_selected_note() {
|
||||
let f = Fixture::provisioned("note-export-menu");
|
||||
let (status, body) = f.get_authed("/").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("aria-disabled=\"true\""));
|
||||
assert!(!body.contains("/export/pdf"));
|
||||
assert!(!body.contains("/export/markdown"));
|
||||
assert!(!body.contains("/export/html"));
|
||||
|
||||
let id = f.create_note().await;
|
||||
let (status, selected) = f.get_authed(&format!("/notes/{id}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(selected.contains("Print/PDF"));
|
||||
assert!(selected.contains("menu-submenu-list"));
|
||||
for format in ["pdf", "markdown", "html"] {
|
||||
assert!(
|
||||
selected.contains(&format!("href=\"/notes/{id}/export/{format}\"")),
|
||||
"selected note should expose the {format} export"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_note_exports_require_authentication() {
|
||||
let f = Fixture::provisioned("note-export-gate");
|
||||
let id = f.create_note().await;
|
||||
for format in ["pdf", "markdown", "html"] {
|
||||
let (status, body) = f.get(&format!("/notes/{id}/export/{format}")).await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
assert!(!body.contains("Export Secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn markdown_export_downloads_the_saved_note() {
|
||||
let f = Fixture::provisioned("markdown-export");
|
||||
let id = f.create_note().await;
|
||||
let (status, _, _) = f
|
||||
.post_authed(
|
||||
&format!("/notes/{id}"),
|
||||
"_method=put&title=Saved+Version&content=First+paragraph",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let response = f
|
||||
.get_authed_response(&format!("/notes/{id}/export/markdown"), None)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/markdown; charset=utf-8"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_DISPOSITION],
|
||||
"attachment; filename=\"saved-version.md\""
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, no-store"
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&body),
|
||||
"# Saved Version\n\nFirst paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn html_export_is_standalone_and_uses_the_selected_theme() {
|
||||
let f = Fixture::provisioned("html-export");
|
||||
let id = f.create_note().await;
|
||||
let (status, _, _) = f
|
||||
.post_authed(
|
||||
&format!("/notes/{id}"),
|
||||
"_method=put&title=HTML+Note&content=%23+Heading%0A%0ARendered+body",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let response = f
|
||||
.get_authed_with_theme(&format!("/notes/{id}/export/html"), "Dracula")
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_DISPOSITION],
|
||||
"attachment; filename=\"html-note.html\""
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(body.contains("data-theme=\"Dracula\""));
|
||||
assert!(body.contains("--app-bg: #282a36;"));
|
||||
assert!(body.contains("<h1 class=\"note-export-title\">HTML Note</h1>"));
|
||||
assert!(body.contains("<h1>Heading</h1>"));
|
||||
assert!(body.contains("<p>Rendered body</p>"));
|
||||
assert!(
|
||||
body.contains(".markdown-body pre"),
|
||||
"application styles are inlined"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pdf_export_opens_a_theme_aware_print_document() {
|
||||
let f = Fixture::provisioned("pdf-export");
|
||||
let id = f.create_note().await;
|
||||
let (status, _, _) = f
|
||||
.post_authed(
|
||||
&format!("/notes/{id}"),
|
||||
"_method=put&title=Print+Note&content=Printed+body",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let response = f
|
||||
.get_authed_with_theme(&format!("/notes/{id}/export/pdf"), "Breeze Dark")
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_DISPOSITION],
|
||||
"inline; filename=\"print-note.html\""
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(body.contains("data-theme=\"Breeze Dark\""));
|
||||
assert!(body.contains("--app-bg: #232629;"));
|
||||
assert!(body.contains("print-color-adjust: exact;"));
|
||||
assert!(body.contains("window.print()"));
|
||||
assert!(body.contains("Printed body"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exporting_a_missing_note_returns_not_found() {
|
||||
let f = Fixture::provisioned("missing-export");
|
||||
for format in ["pdf", "markdown", "html"] {
|
||||
let (status, _) = f
|
||||
.get_authed(&format!("/notes/does-not-exist/export/{format}"))
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_routes_require_authentication() {
|
||||
let f = Fixture::provisioned("write-gate");
|
||||
|
|
@ -895,6 +1061,23 @@ async fn base_path_prefixes_generated_links() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_path_prefixes_single_note_export_links() {
|
||||
let f = Fixture::with_base_path("base-note-export", "/forgejo")
|
||||
.login_over_http()
|
||||
.await;
|
||||
let id = f.create_note().await;
|
||||
let (status, body) = f.get_authed(&format!("/notes/{id}")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
for format in ["pdf", "markdown", "html"] {
|
||||
assert!(
|
||||
body.contains(&format!("href=\"/forgejo/notes/{id}/export/{format}\"")),
|
||||
"{format} export link should include the base path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_path_prefixes_redirects() {
|
||||
let f = Fixture::with_base_path("base-redirect", "/forgejo")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue