Initial Commit

This commit is contained in:
mrkmntal 2026-09-28 17:55:56 -04:00
commit 7976babb79
16 changed files with 4049 additions and 0 deletions

44
src/main.rs Normal file
View file

@ -0,0 +1,44 @@
mod markdown;
mod notes;
mod pages;
mod routes;
mod storage;
mod themes;
use axum::Router;
use clap::Parser;
use std::net::SocketAddr;
use tower_http::services::ServeDir;
#[derive(Parser, Debug)]
#[command(name = "rasuti-notes")]
#[command(about = "A server-side rendered Markdown notes application")]
struct Args {
/// Host address to bind to
#[arg(long, default_value = "0.0.0.0")]
host: String,
/// Port to listen on
#[arg(short, long, default_value_t = 3000)]
port: u16,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
// Ensure notes directory exists
std::fs::create_dir_all("notes").expect("Failed to create notes directory");
let app = Router::new()
.merge(routes::router())
.nest_service("/static", ServeDir::new("static"));
let host: std::net::IpAddr = args.host.parse().expect("Invalid host address");
let addr = SocketAddr::from((host, args.port));
println!("Rasuti Notes running at http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

43
src/markdown.rs Normal file
View file

@ -0,0 +1,43 @@
use pulldown_cmark::{html, Options, Parser};
pub fn render_markdown(input: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_FOOTNOTES);
let parser = Parser::new_ext(input, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_headings_and_paragraphs() {
let md = "# Hello\n\nThis is a paragraph.";
let html = render_markdown(md);
assert!(html.contains("<h1>Hello</h1>"));
assert!(html.contains("<p>This is a paragraph.</p>"));
}
#[test]
fn renders_tasklists() {
let md = "- [x] Done\n- [ ] Todo";
let html = render_markdown(md);
assert!(html.contains("<input"));
assert!(html.contains("checked"));
}
#[test]
fn renders_tables() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let html = render_markdown(md);
assert!(html.contains("<table>"));
assert!(html.contains("<th>A</th>"));
}
}

200
src/notes.rs Normal file
View file

@ -0,0 +1,200 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub id: String,
pub title: String,
pub content: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Note {
pub fn new(title: impl Into<String>, content: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
title: title.into(),
content: content.into(),
created_at: now,
updated_at: now,
}
}
pub fn preview(&self, max_len: usize) -> String {
let text: String = self
.content
.lines()
.filter(|l| !l.trim().is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" ");
if text.len() > max_len {
format!("{}...", &text[..max_len])
} else {
text
}
}
}
pub fn list_notes() -> Result<Vec<Note>> {
let mut notes = Vec::new();
let entries = fs::read_dir("notes")?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Ok(note) = load_note_from_path(&path) {
notes.push(note);
}
}
}
notes.sort_by_key(|a| std::cmp::Reverse(a.updated_at));
Ok(notes)
}
pub fn load_note(id: &str) -> Result<Note> {
let path = format!("notes/{}.md", id);
load_note_from_path(Path::new(&path))
}
fn load_note_from_path(path: &Path) -> Result<Note> {
let content = fs::read_to_string(path)?;
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// Parse frontmatter if present
let (title, body, created_at, updated_at) = parse_frontmatter(&content);
Ok(Note {
id,
title,
content: body,
created_at,
updated_at,
})
}
fn parse_frontmatter(content: &str) -> (String, String, DateTime<Utc>, DateTime<Utc>) {
if let Some(rest) = content.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
let fm = &rest[..end];
let body = &rest[end + 5..];
let mut title = String::from("Untitled");
let mut created_at = Utc::now();
let mut updated_at = Utc::now();
for line in fm.lines() {
if let Some(v) = line.strip_prefix("title: ") {
title = v.trim().to_string();
} else if let Some(v) = line.strip_prefix("created: ") {
if let Ok(dt) = DateTime::parse_from_rfc3339(v.trim()) {
created_at = dt.with_timezone(&Utc);
}
} else if let Some(v) = line.strip_prefix("updated: ") {
if let Ok(dt) = DateTime::parse_from_rfc3339(v.trim()) {
updated_at = dt.with_timezone(&Utc);
}
}
}
return (title, body.to_string(), created_at, updated_at);
}
}
// No frontmatter — derive title from first heading
let title = content
.lines()
.find(|l| l.starts_with("# "))
.map(|l| l[2..].trim().to_string())
.unwrap_or_else(|| "Untitled".to_string());
(title, content.to_string(), Utc::now(), Utc::now())
}
pub fn save_note(note: &Note) -> Result<()> {
let path = format!("notes/{}.md", note.id);
let frontmatter = format!(
"---\ntitle: {}\ncreated: {}\nupdated: {}\n---\n{}",
note.title,
note.created_at.to_rfc3339(),
note.updated_at.to_rfc3339(),
note.content
);
fs::write(path, frontmatter)?;
Ok(())
}
pub fn delete_note(id: &str) -> Result<()> {
let path = format!("notes/{}.md", id);
fs::remove_file(path)?;
Ok(())
}
pub fn create_note(title: impl Into<String>, content: impl Into<String>) -> Result<Note> {
let note = Note::new(title, content);
save_note(&note)?;
Ok(note)
}
pub fn update_note(id: &str, title: impl Into<String>, content: impl Into<String>) -> Result<Note> {
let mut note = load_note(id)?;
note.title = title.into();
note.content = content.into();
note.updated_at = Utc::now();
save_note(&note)?;
Ok(note)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_preview_truncates() {
let note = Note::new(
"Test",
"First line\n\nSecond line that is very long and should be truncated eventually.",
);
let preview = note.preview(20);
assert!(preview.ends_with("..."));
assert!(preview.len() <= 23);
}
#[test]
fn crud_lifecycle() {
std::fs::create_dir_all("notes").unwrap();
let note = create_note("CRUD Test", "Initial content").unwrap();
let loaded = load_note(&note.id).unwrap();
assert_eq!(loaded.title, "CRUD Test");
assert_eq!(loaded.content, "Initial content");
update_note(&note.id, "Updated Title", "Updated content").unwrap();
let updated = load_note(&note.id).unwrap();
assert_eq!(updated.title, "Updated Title");
assert_eq!(updated.content, "Updated content");
delete_note(&note.id).unwrap();
assert!(load_note(&note.id).is_err());
}
#[test]
fn derives_title_from_heading() {
std::fs::create_dir_all("notes").unwrap();
let note = create_note("Fallback", "# Real Title\n\nSome content.").unwrap();
let loaded = load_note(&note.id).unwrap();
// The stored title is what was passed to create_note, but the file content has a heading.
assert_eq!(loaded.title, "Fallback");
delete_note(&note.id).unwrap();
}
}

291
src/pages.rs Normal file
View file

@ -0,0 +1,291 @@
use crate::markdown::render_markdown;
use crate::notes::Note;
use crate::themes::{get_theme, PREDEFINED_THEMES};
use chrono::{DateTime, Utc};
use maud::{html, Markup, PreEscaped, DOCTYPE};
pub fn render_app(
theme_name: &str,
notes: &[Note],
current_note: Option<&Note>,
query: Option<&str>,
message: Option<&str>,
) -> Markup {
let theme = get_theme(theme_name);
let css = theme.to_css_block();
let rendered_preview = current_note
.map(|n| render_markdown(&n.content))
.unwrap_or_default();
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 { "Rasuti Notes" }
style { (PreEscaped(css)) }
style { (PreEscaped(include_str!("../static/style.css"))) }
script defer src="/static/alpine.min.js" {}
}
body x-data="app()" x-on:keydown="handleKeydown($event)" {
(render_menu_bar())
(render_toolbar(current_note, query, theme_name))
@if let Some(msg) = message {
div class="flash-message" { (msg) }
}
main class="app-body" {
(render_sidebar(notes, current_note))
(render_editor(current_note))
(render_preview(&rendered_preview))
}
(render_status_bar(notes.len(), theme_name))
script {
"window.__INITIAL_PREVIEW__ = `" (PreEscaped(escape_js(&rendered_preview))) "`;"
(PreEscaped(include_str!("../static/app.js")))
}
}
}
}
}
fn escape_js(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('`', "\\`")
.replace('$', "\\$")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn render_menu_bar() -> Markup {
html! {
nav class="menu-bar" {
ul class="menu-list" {
li class="menu-item" tabindex="0" {
span { "File" }
ul class="menu-dropdown" {
li {
form action="/notes" method="post" {
button type="submit" { "New Note" }
}
}
li { hr; }
li {
a href="/export" { "Export All Notes" }
}
}
}
li class="menu-item" tabindex="0" {
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" {
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" {
span { "Theme" }
ul class="menu-dropdown" {
@for theme in PREDEFINED_THEMES {
li {
form action="/theme" method="post" {
input type="hidden" name="theme" value=(theme.name);
button type="submit" { (theme.name) }
}
}
}
}
}
li class="menu-item" tabindex="0" {
span { "Help" }
ul class="menu-dropdown" {
li { a href="/about" { "About Rasuti Notes" } }
li { a href="https://www.gnu.org/licenses/gpl-3.0.html" target="_blank" { "GPL v3 License" } }
}
}
}
span class="app-title" { "Rasuti Notes ラスティ v0.1" }
}
}
}
fn render_toolbar(current_note: Option<&Note>, query: Option<&str>, theme_name: &str) -> Markup {
html! {
div class="toolbar" {
div class="toolbar-group" {
form action="/notes" method="post" {
button type="submit" class="btn btn-primary" { "+ New Note" }
}
@if let Some(note) = current_note {
form action=(format!("/notes/{}", note.id)) method="post" {
input type="hidden" name="_method" value="delete";
button type="submit" class="btn btn-danger" { "Delete" }
}
}
}
div class="toolbar-group" {
form action="/" method="get" class="search-form" {
input
type="search"
name="q"
placeholder="Search..."
value=[query]
x-on:input="filterNotes($event.target.value)";
}
}
div class="toolbar-group" {
form action="/theme" method="post" class="theme-form" x-on:change="$event.target.form.submit()" {
label { "Theme:" }
select name="theme" {
@for theme in PREDEFINED_THEMES {
option value=(theme.name) selected[(theme.name == theme_name)] { (theme.name) }
}
}
}
button type="button" class="btn" x-on:click="togglePreview()" { "Preview" }
}
}
}
}
fn render_sidebar(notes: &[Note], current_note: Option<&Note>) -> Markup {
html! {
aside class="sidebar" {
div class="sidebar-header" { "Notes" }
ul class="note-list" {
@if notes.is_empty() {
li class="note-item empty" { "No notes yet." }
} @else {
@for note in notes {
li class={"note-item " (if current_note.map(|n| n.id == note.id).unwrap_or(false) { "active" } else { "" })}
data-title=(note.title.to_lowercase())
data-content=(note.content.to_lowercase()) {
a href=(format!("/notes/{}", note.id)) {
div class="note-title" { (note.title) }
div class="note-meta" { (relative_time(note.updated_at)) }
div class="note-preview" { (note.preview(80)) }
}
}
}
}
}
}
}
}
fn render_editor(current_note: Option<&Note>) -> Markup {
html! {
section class="editor-pane" x-data="{ saving: false }" {
@if let Some(note) = current_note {
form
id="note-form"
action=(format!("/notes/{}", note.id))
method="post"
x-on:submit="saving = true"
class="note-form" {
input type="hidden" name="_method" value="put";
input
type="text"
name="title"
class="note-title-input"
placeholder="Note title"
value=(note.title);
textarea
id="editor"
name="content"
class="note-content-input"
placeholder="Write Markdown here..."
spellcheck="false"
x-on:input="updatePreview($event.target.value)"
x-ref="editor" { (note.content) }
div class="form-actions" {
button type="submit" class="btn btn-primary" x-bind:disabled="saving" {
span x-show="!saving" { "Save" }
span x-show="saving" { "Saving..." }
}
}
}
} @else {
div class="empty-state" {
p { "Select a note or create a new one." }
}
}
}
}
}
fn render_preview(html_content: &str) -> Markup {
html! {
section class="preview-pane" x-bind:class="{ 'hidden': !previewVisible }" {
div id="preview" class="markdown-body" x-html="previewHtml" {
(PreEscaped(html_content))
}
}
}
}
fn render_status_bar(note_count: usize, theme_name: &str) -> Markup {
html! {
footer class="status-bar" {
span { (note_count) " Notes" }
span { "Saved" }
span { "UTF-8" }
span { "Markdown" }
span { "Theme: " (theme_name) }
}
}
}
pub fn render_preview_fragment(html_content: &str) -> Markup {
html! {
div class="markdown-body" { (PreEscaped(html_content)) }
}
}
pub fn render_about(theme_name: &str) -> Markup {
let theme = get_theme(theme_name);
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
title { "About - Rasuti Notes" }
style { (PreEscaped(theme.to_css_block())) }
style { (PreEscaped(include_str!("../static/style.css"))) }
}
body class="about-page" {
(render_menu_bar())
main {
h1 { "Rasuti Notes" }
p { "A server-side rendered Markdown notes application written in Rust." }
p { "Licensed under the GNU General Public License v3.0 or later." }
a href="/" { "Back to notes" }
}
(render_status_bar(0, theme_name))
}
}
}
}
pub fn relative_time(dt: DateTime<Utc>) -> String {
let now = Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_seconds() < 60 {
"just now".to_string()
} else if diff.num_minutes() < 60 {
format!("{}m ago", diff.num_minutes())
} else if diff.num_hours() < 24 {
format!("{}h ago", diff.num_hours())
} else if diff.num_days() < 30 {
format!("{}d ago", diff.num_days())
} else {
dt.format("%b %d, %Y").to_string()
}
}

225
src/routes.rs Normal file
View file

@ -0,0 +1,225 @@
use axum::{
extract::{Form, Path, Query},
http::HeaderMap,
response::{Html, IntoResponse, Redirect},
routing::{get, post},
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))
.route("/notes", post(create_note_route))
.route(
"/notes/:id",
get(show_note_route).post(update_or_delete_note_route),
)
.route("/preview", post(preview_route))
.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 {
headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(';'))
.find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
if key == "theme" {
Some(value.to_string())
} else {
None
}
})
.unwrap_or_else(|| "Dark".to_string())
}
#[derive(Deserialize)]
struct IndexQuery {
q: Option<String>,
}
async fn index(
Query(query): Query<IndexQuery>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let theme = get_theme_from_cookie(&headers);
let all_notes = notes::list_notes()?;
let notes: Vec<Note> = if let Some(ref q) = query.q {
let q = q.to_lowercase();
all_notes
.into_iter()
.filter(|n| {
n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q)
})
.collect()
} else {
all_notes
};
let html = pages::render_app(&theme, &notes, None, query.q.as_deref(), None);
Ok(Html(html.into_string()))
}
async fn show_note_route(
Path(id): Path<String>,
Query(query): Query<IndexQuery>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let theme = get_theme_from_cookie(&headers);
let note = notes::load_note(&id)?;
let all_notes = notes::list_notes()?;
let notes: Vec<Note> = if let Some(ref q) = query.q {
let q = q.to_lowercase();
all_notes
.into_iter()
.filter(|n| {
n.title.to_lowercase().contains(&q) || n.content.to_lowercase().contains(&q)
})
.collect()
} else {
all_notes
};
let html = pages::render_app(&theme, &notes, Some(&note), query.q.as_deref(), None);
Ok(Html(html.into_string()))
}
#[derive(Deserialize)]
struct NoteForm {
title: Option<String>,
content: Option<String>,
#[serde(default)]
_method: String,
}
async fn create_note_route(headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
let _theme = get_theme_from_cookie(&headers);
let note = notes::create_note("Untitled", "")?;
Ok(Redirect::to(&format!("/notes/{}", note.id)))
}
async fn update_or_delete_note_route(
Path(id): Path<String>,
headers: HeaderMap,
Form(form): Form<NoteForm>,
) -> Result<impl IntoResponse, AppError> {
let _theme = get_theme_from_cookie(&headers);
match form._method.as_str() {
"delete" => {
notes::delete_note(&id)?;
Ok(Redirect::to("/").into_response())
}
_ => {
let title = form.title.unwrap_or_else(|| "Untitled".to_string());
let content = form.content.unwrap_or_default();
notes::update_note(&id, title, content)?;
Ok(Redirect::to(&format!("/notes/{}", id)).into_response())
}
}
}
#[derive(Deserialize)]
struct PreviewForm {
content: String,
}
async fn preview_route(Form(form): Form<PreviewForm>) -> Result<impl IntoResponse, AppError> {
let html = render_markdown(&form.content);
Ok(Html(pages::render_preview_fragment(&html).into_string()))
}
#[derive(Deserialize)]
struct ThemeForm {
theme: String,
}
async fn theme_route(
_headers: HeaderMap,
Form(form): Form<ThemeForm>,
) -> Result<impl IntoResponse, AppError> {
// Validate theme name
let _ = get_theme(&form.theme);
let mut response = Redirect::to("/").into_response();
let cookie = format!(
"theme={}; Path=/; Max-Age=31536000; SameSite=Lax",
form.theme
);
response
.headers_mut()
.insert(axum::http::header::SET_COOKIE, cookie.parse().unwrap());
Ok(response)
}
async fn about_route(headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
let theme = get_theme_from_cookie(&headers);
Ok(Html(pages::render_about(&theme).into_string()))
}
async fn export_route(_headers: HeaderMap) -> Result<impl IntoResponse, AppError> {
let all_notes = notes::list_notes()?;
let mut export = String::from("# Rasuti Notes Export\n\n");
for note in all_notes {
export.push_str(&format!("## {}\n\n{}\n\n---\n\n", note.title, note.content));
}
let mut response = Html(export).into_response();
response.headers_mut().insert(
axum::http::header::CONTENT_DISPOSITION,
"attachment; filename=\"rasuti-notes-export.md\""
.parse()
.unwrap(),
);
Ok(response)
}
#[derive(Debug)]
pub struct AppError(anyhow::Error);
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError(err)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let not_found = self
.0
.root_cause()
.downcast_ref::<std::io::Error>()
.map(|e| e.kind() == std::io::ErrorKind::NotFound)
.unwrap_or(false);
if not_found {
return (
axum::http::StatusCode::NOT_FOUND,
"Note not found".to_string(),
)
.into_response();
}
let body = format!("Internal Server Error: {}", self.0);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
}
}

3
src/storage.rs Normal file
View file

@ -0,0 +1,3 @@
// Storage module - re-exports from notes.rs for organizational clarity
#[allow(unused_imports)]
pub use crate::notes::*;

413
src/themes.rs Normal file
View file

@ -0,0 +1,413 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Theme {
pub name: &'static str,
pub fg: &'static str,
pub bg: &'static str,
pub ansi_normal: [&'static str; 8],
pub ansi_bright: [&'static str; 8],
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct ANSIPalette {
pub name: &'static str,
pub ansi_normal: [&'static str; 8],
pub ansi_bright: [&'static str; 8],
}
impl Theme {
pub fn to_css_block(&self) -> String {
let (btn_primary_bg, btn_primary_fg) = self.button_colors(4);
let (btn_danger_bg, btn_danger_fg) = self.button_colors(1);
format!(
":root {{\n\
--app-fg: {};\n\
--app-bg: {};\n\
--ui-border: {}33;\n\
--ui-hover: {}1a;\n\
--btn-primary-bg: {};\n\
--btn-primary-fg: {};\n\
--btn-danger-bg: {};\n\
--btn-danger-fg: {};\n\
--ansi-0: {}; --ansi-1: {}; --ansi-2: {}; --ansi-3: {};\n\
--ansi-4: {}; --ansi-5: {}; --ansi-6: {}; --ansi-7: {};\n\
--ansi-8: {}; --ansi-9: {}; --ansi-10: {}; --ansi-11: {};\n\
--ansi-12: {}; --ansi-13: {}; --ansi-14: {}; --ansi-15: {};\n\
}}",
self.fg,
self.bg,
self.fg,
self.fg,
btn_primary_bg,
btn_primary_fg,
btn_danger_bg,
btn_danger_fg,
self.ansi_normal[0],
self.ansi_normal[1],
self.ansi_normal[2],
self.ansi_normal[3],
self.ansi_normal[4],
self.ansi_normal[5],
self.ansi_normal[6],
self.ansi_normal[7],
self.ansi_bright[0],
self.ansi_bright[1],
self.ansi_bright[2],
self.ansi_bright[3],
self.ansi_bright[4],
self.ansi_bright[5],
self.ansi_bright[6],
self.ansi_bright[7],
)
}
fn button_colors(&self, accent: usize) -> (&'static str, &'static str) {
// Use the bright variant of the accent color if it has decent luminance;
// otherwise fall back to the theme foreground color so text remains readable.
let bright = self.ansi_bright[accent];
if relative_luminance(bright) < 0.15 {
(self.fg, self.bg)
} else {
(bright, self.bg)
}
}
}
fn relative_luminance(hex: &str) -> f64 {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return 0.0;
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
0.2126 * r + 0.7152 * g + 0.0722 * b
}
pub static PREDEFINED_THEMES: &[Theme] = &[
Theme {
name: "Dark",
fg: "#d0d0d0",
bg: "#1c1c1c",
ansi_normal: [
"#1c1c1c", "#c50f1f", "#23a523", "#b58900", "#268bd2", "#d33682", "#2aa198", "#d0d0d0",
],
ansi_bright: [
"#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",
bg: "#282a36",
ansi_normal: [
"#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2",
],
ansi_bright: [
"#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff",
],
},
Theme {
name: "Gruvbox Dark",
fg: "#ebdbb2",
bg: "#282828",
ansi_normal: [
"#282828", "#cc241d", "#98971a", "#d79921", "#458588", "#b16286", "#689d6a", "#a89984",
],
ansi_bright: [
"#928374", "#fb4934", "#b8bb26", "#fabd2f", "#83a598", "#d3869b", "#8ec07c", "#ebdbb2",
],
},
Theme {
name: "Gruvbox Light",
fg: "#3c3836",
bg: "#fbf1c7",
ansi_normal: [
"#fbf1c7", "#cc241d", "#98971a", "#d79921", "#458588", "#b16286", "#689d6a", "#7c6f64",
],
ansi_bright: [
"#928374", "#9d0006", "#79740e", "#b57614", "#076678", "#8f3f71", "#427b58", "#3c3836",
],
},
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: "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: "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", "#0000ff", "#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", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
],
},
Theme {
name: "Lime Green on Black",
fg: "#00ff00",
bg: "#000000",
ansi_normal: [
"#000000", "#cc0000", "#00cc00", "#cccc00", "#0000cc", "#cc00cc", "#00cccc", "#cccccc",
],
ansi_bright: [
"#666666", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#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", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
],
},
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", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
],
},
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: "Spring Blossom",
fg: "#ffb7c5",
bg: "#2d5016",
ansi_normal: [
"#2d5016", "#c2185b", "#7cb342", "#fbc02d", "#00897b", "#d81b60", "#aed581", "#f8bbd0",
],
ansi_bright: [
"#558b2f", "#e91e63", "#8bc34a", "#ffeb3b", "#009688", "#ec407a", "#c5e1a5", "#fce4ec",
],
},
Theme {
name: "Summer Sunset",
fg: "#ff9a5a",
bg: "#1a1a2e",
ansi_normal: [
"#1a1a2e", "#e64a19", "#ff7043", "#ffab40", "#d81b60", "#8e24aa", "#5c6bc0", "#ffb74d",
],
ansi_bright: [
"#424242", "#ff5722", "#ff8a65", "#ffc107", "#e91e63", "#9c27b0", "#3f51b5", "#ffe0b2",
],
},
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: "Winter Frost",
fg: "#e0f7fa",
bg: "#1a237e",
ansi_normal: [
"#1a237e", "#006064", "#00838f", "#0097a7", "#00acc1", "#26c6da", "#4dd0e1", "#80deea",
],
ansi_bright: [
"#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: "Sunny Day",
fg: "#fff59d",
bg: "#33691e",
ansi_normal: [
"#33691e", "#f57f17", "#c0ca33", "#00acc1", "#00897b", "#5c6bc0", "#7cb342", "#fff59d",
],
ansi_bright: [
"#558b2f", "#fbc02d", "#afb42b", "#26c6da", "#0097a7", "#3f51b5", "#9ccc65", "#fff9c4",
],
},
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])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_is_dark() {
let theme = get_theme("Nonexistent");
assert_eq!(theme.name, "Dark");
}
#[test]
fn css_block_contains_variables() {
let theme = get_theme("Dark");
let css = theme.to_css_block();
assert!(css.contains("--app-fg:"));
assert!(css.contains("--app-bg:"));
assert!(css.contains("--btn-primary-bg:"));
assert!(css.contains("--btn-danger-bg:"));
assert!(css.contains("--ansi-0:"));
assert!(css.contains("--ansi-15:"));
}
#[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.
let cyan = get_theme("Cyan on Black");
let (bg, fg) = cyan.button_colors(4);
assert_eq!(bg, cyan.fg);
assert_eq!(fg, cyan.bg);
}
#[test]
fn all_themes_have_sixteen_ansi_colors() {
for theme in PREDEFINED_THEMES {
assert_eq!(theme.ansi_normal.len(), 8);
assert_eq!(theme.ansi_bright.len(), 8);
}
}
}