Touch up the mobile view, add syntax highlighting for markdown
This commit is contained in:
parent
7976babb79
commit
16450f4c1c
11 changed files with 863 additions and 57 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
src/pages.rs
101
src/pages.rs
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
121
src/syntax.rs
Normal 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('&', "&")
|
||||
.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!(
|
||||
" <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
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue