Initial Commit
This commit is contained in:
commit
7976babb79
16 changed files with 4049 additions and 0 deletions
44
src/main.rs
Normal file
44
src/main.rs
Normal 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
43
src/markdown.rs
Normal 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
200
src/notes.rs
Normal 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(¬e)?;
|
||||
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(¬e)?;
|
||||
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(¬e.id).unwrap();
|
||||
assert_eq!(loaded.title, "CRUD Test");
|
||||
assert_eq!(loaded.content, "Initial content");
|
||||
|
||||
update_note(¬e.id, "Updated Title", "Updated content").unwrap();
|
||||
let updated = load_note(¬e.id).unwrap();
|
||||
assert_eq!(updated.title, "Updated Title");
|
||||
assert_eq!(updated.content, "Updated content");
|
||||
|
||||
delete_note(¬e.id).unwrap();
|
||||
assert!(load_note(¬e.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(¬e.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(¬e.id).unwrap();
|
||||
}
|
||||
}
|
||||
291
src/pages.rs
Normal file
291
src/pages.rs
Normal 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
225
src/routes.rs
Normal 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, ¬es, 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, ¬es, Some(¬e), 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
3
src/storage.rs
Normal 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
413
src/themes.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue