Clean up project structure

This commit is contained in:
2026-03-27 15:53:17 +01:00
parent d4872a1a04
commit cdcc119e41
26 changed files with 952 additions and 30 deletions

2143
brittle-ui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
brittle-ui/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "brittle-ui"
version = "0.1.0"
edition = "2021"
[dependencies]
brittle-model = { path = "../brittle-model" }
brittle-keymap = { path = "../brittle-keymap" }
uuid = { version = "1", features = ["v7", "js"] }
js-sys = "0.3"
leptos = { version = "0.7", features = ["csr"] }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["DataTransfer", "DragEvent", "HtmlIFrameElement", "KeyboardEvent", "MessageEvent", "Window"] }
[dev-dependencies]
serde_json = "1"

7
brittle-ui/Trunk.toml Normal file
View File

@@ -0,0 +1,7 @@
[build]
target = "index.html"
dist = "../dist"
[serve]
port = 1420
open = false

11
brittle-ui/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brittle</title>
<link data-trunk rel="css" href="style.css" />
<link data-trunk rel="rust" data-wasm-opt="z" />
</head>
<body></body>
</html>

View File

@@ -0,0 +1,147 @@
//! The command / search bar shown at the bottom of the screen.
//!
//! Visible only in [`AppMode::Command`] and [`AppMode::Search`] modes.
//! Handles its own keyboard events (Enter, Escape) and stops propagation
//! so the global keymap does not also process those keys.
use leptos::prelude::*;
use web_sys::KeyboardEvent;
use crate::{commands::{self, CommandEffect}, mode::AppMode, ThemeContext};
/// Shared search query, provided in context so other components can read it.
///
/// Set by the search bar when the user commits a search (`<Enter>`).
/// Cleared when the user cancels with `<Esc>`.
#[derive(Clone, Copy)]
pub struct SearchQuery(pub RwSignal<String>);
pub fn provide_search_query() -> RwSignal<String> {
let sig = RwSignal::new(String::new());
provide_context(SearchQuery(sig));
sig
}
/// The command/search bar component.
///
/// Pass the application mode signal; the bar appears/disappears reactively.
#[component]
pub fn CommandBar(mode: RwSignal<AppMode>) -> impl IntoView {
let input_ref = NodeRef::<leptos::html::Input>::new();
let (input_val, set_input_val) = signal(String::new());
let (status_msg, set_status_msg) = signal(Option::<String>::None);
let search_query = use_context::<SearchQuery>().map(|sq| sq.0);
let theme = use_context::<ThemeContext>().map(|tc| tc.0);
let reload_trigger = use_context::<crate::ReloadTrigger>().map(|r| r.0);
// Clear input and status when the mode changes; autofocus on open.
Effect::new(move |_| {
let m = mode.get();
set_input_val.set(String::new());
set_status_msg.set(None);
if m != AppMode::Normal {
if let Some(el) = input_ref.get() {
let _ = el.focus();
}
}
});
let prefix = move || match mode.get() {
AppMode::Normal => "",
AppMode::Command => ":",
AppMode::Search => "/",
};
let on_keydown = move |ev: KeyboardEvent| {
match ev.key().as_str() {
"Escape" => {
ev.prevent_default();
ev.stop_propagation();
// Cancel: clear search query and return to normal.
if let Some(sq) = search_query {
sq.set(String::new());
}
mode.set(AppMode::Normal);
}
"Enter" => {
ev.prevent_default();
ev.stop_propagation();
let val = input_val.get_untracked();
match mode.get_untracked() {
AppMode::Command => {
let outcome = commands::dispatch(&val);
if let Some(msg) = outcome.message {
// Show error; stay in command mode.
set_status_msg.set(Some(msg));
return;
}
if let Some(effect) = outcome.effect {
match effect {
CommandEffect::SetTheme(t) => {
if let Some(sig) = theme {
sig.set(t.clone());
}
leptos::task::spawn_local(async move {
let _ = crate::tauri::set_theme(&t).await;
});
}
CommandEffect::OpenRepository(path) => {
let reload = reload_trigger;
leptos::task::spawn_local(async move {
match crate::tauri::open_repository(&path).await {
Ok(()) => {
if let Some(t) = reload {
t.update(|n| *n += 1);
}
mode.set(AppMode::Normal);
}
Err(e) => set_status_msg.set(Some(e)),
}
});
return; // stay open until async resolves
}
}
}
mode.set(AppMode::Normal);
}
AppMode::Search => {
// Commit the search query and return to normal.
if let Some(sq) = search_query {
sq.set(val);
}
mode.set(AppMode::Normal);
}
AppMode::Normal => {}
}
}
_ => {}
}
};
let on_input = move |ev: web_sys::Event| {
set_input_val.set(event_target_value(&ev));
set_status_msg.set(None);
};
view! {
<Show when=move || mode.get() != AppMode::Normal>
<div class="command-bar">
<span class="command-prefix">{prefix}</span>
<input
node_ref=input_ref
type="text"
class="command-input"
prop:value=input_val
on:input=on_input
on:keydown=on_keydown
autocomplete="off"
spellcheck="false"
/>
{move || status_msg.get().map(|m| view! {
<span class="command-status">""{m}</span>
})}
</div>
</Show>
}
}

128
brittle-ui/src/commands.rs Normal file
View File

@@ -0,0 +1,128 @@
//! Command dispatch for command mode (the `:` prompt).
//!
//! Each command returns a [`DispatchOutcome`] containing an optional status
//! message and an optional side-effect for the UI layer to perform.
//! A `None` message means the command succeeded silently; `Some(msg)` is shown
//! in the command bar as an error or confirmation.
//!
//! Commands are simple strings; arguments follow a space: `:theme dark`.
/// A side-effect that the UI layer must perform after a successful command.
pub enum CommandEffect {
/// Apply and persist the given theme (`"dark"` or `"light"`).
SetTheme(String),
/// Open the repository at the given filesystem path.
OpenRepository(String),
}
/// Result of dispatching a command.
pub struct DispatchOutcome {
/// Message to show in the command bar; `None` = silent success.
pub message: Option<String>,
/// Side-effect for the UI layer to carry out.
pub effect: Option<CommandEffect>,
}
impl DispatchOutcome {
fn ok() -> Self {
Self { message: None, effect: None }
}
fn err(msg: impl Into<String>) -> Self {
Self { message: Some(msg.into()), effect: None }
}
fn with_effect(effect: CommandEffect) -> Self {
Self { message: None, effect: Some(effect) }
}
}
/// Execute a command entered in command mode.
pub fn dispatch(input: &str) -> DispatchOutcome {
let input = input.trim();
if input.is_empty() {
return DispatchOutcome::ok();
}
let (cmd, args) = input
.split_once(' ')
.map(|(c, a)| (c, a.trim()))
.unwrap_or((input, ""));
match cmd {
// ── Lifecycle ────────────────────────────────────────────────────────
"q" | "quit" => {
// TODO Phase 8: call tauri::window::close()
DispatchOutcome::ok()
}
// ── Theme ────────────────────────────────────────────────────────────
"theme" => match args {
"dark" | "light" => {
DispatchOutcome::with_effect(CommandEffect::SetTheme(args.to_string()))
}
"" => DispatchOutcome::err("usage: theme <dark|light>"),
a => DispatchOutcome::err(format!("unknown theme '{a}'")),
},
// ── Repository ───────────────────────────────────────────────────────
"open" => {
if args.is_empty() {
DispatchOutcome::err("usage: open <path>")
} else {
DispatchOutcome::with_effect(CommandEffect::OpenRepository(args.to_string()))
}
}
_ => DispatchOutcome::err(format!("unknown command '{cmd}'")),
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_silent() {
assert!(dispatch("").message.is_none());
assert!(dispatch(" ").message.is_none());
}
#[test]
fn quit_is_silent() {
assert!(dispatch("q").message.is_none());
assert!(dispatch("quit").message.is_none());
}
#[test]
fn valid_theme_produces_effect() {
let dark = dispatch("theme dark");
assert!(dark.message.is_none());
assert!(matches!(&dark.effect, Some(CommandEffect::SetTheme(t)) if t == "dark"));
let light = dispatch("theme light");
assert!(light.message.is_none());
assert!(matches!(&light.effect, Some(CommandEffect::SetTheme(t)) if t == "light"));
}
#[test]
fn invalid_theme_returns_error() {
let msg = dispatch("theme solarized").message;
assert!(msg.is_some());
assert!(msg.unwrap().contains("solarized"));
}
#[test]
fn theme_without_args_returns_usage() {
let msg = dispatch("theme").message;
assert!(msg.is_some());
assert!(msg.unwrap().contains("usage"));
}
#[test]
fn unknown_command_returns_error() {
let msg = dispatch("frobnicate").message;
assert!(msg.is_some());
assert!(msg.unwrap().contains("frobnicate"));
}
}

604
brittle-ui/src/lib_tab.rs Normal file
View File

@@ -0,0 +1,604 @@
//! The Library tab: three-pane layout with tree, list, and detail panes.
use std::collections::{HashMap, HashSet};
use brittle_keymap::actions;
use leptos::prelude::*;
use leptos::task::spawn_local;
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
use crate::{
command_bar::SearchQuery,
lib_tree::{flatten_tree, LibraryTree, TreeRow},
pub_detail::PubDetail,
pub_list::PubList,
ActionEvent,
};
// ── Pane enum ─────────────────────────────────────────────────────────────────
/// Which of the three panes currently has keyboard focus.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pane {
Tree,
List,
Detail,
}
impl Pane {
pub fn next(self) -> Self {
match self {
Pane::Tree => Pane::List,
Pane::List => Pane::Detail,
Pane::Detail => Pane::Tree,
}
}
pub fn prev(self) -> Self {
match self {
Pane::Tree => Pane::Detail,
Pane::List => Pane::Tree,
Pane::Detail => Pane::List,
}
}
}
// ── Component ─────────────────────────────────────────────────────────────────
/// Root component for the Library tab.
///
/// Owns all UI state, wires keymap actions, and drives async data loading.
#[component]
pub fn LibTab() -> impl IntoView {
// ── Context signals ───────────────────────────────────────────────────────
let keymap_action = use_context::<crate::KeymapAction>()
.expect("KeymapAction context missing")
.0;
let search_query = use_context::<SearchQuery>()
.map(|sq| sq.0)
.unwrap_or_else(|| RwSignal::new(String::new()));
// ── UI state ──────────────────────────────────────────────────────────────
let focused = RwSignal::new(Pane::Tree);
// Tree state
let root_libs = RwSignal::new(Vec::<Library>::new());
let children_cache = RwSignal::new(HashMap::<String, Vec<Library>>::new());
let expanded = RwSignal::new(HashSet::<String>::new());
let tree_cursor = RwSignal::new(0usize);
// List state
let list_items = RwSignal::new(Vec::<ReferenceSummary>::new());
let list_cursor = RwSignal::new(0usize);
// Detail state
let detail_ref = RwSignal::new(Option::<Reference>::None);
// ── Derived / computed ────────────────────────────────────────────────────
// Flattened visible tree rows (recomputed when tree data or expand set changes).
let tree_rows = Memo::new(move |_| {
flatten_tree(&root_libs.get(), &children_cache.get(), &expanded.get(), 0)
});
// The library selected by the tree cursor.
let selected_library = Memo::new(move |_| {
let pos = tree_cursor.get();
tree_rows.with(|rows| {
rows.get(pos).and_then(|r| {
uuid::Uuid::parse_str(&r.id).ok().map(LibraryId)
})
})
});
// The reference ID selected by the list cursor.
let selected_ref_id = Memo::new(move |_| {
let pos = list_cursor.get();
list_items.with(|items| items.get(pos).map(|r| r.id.clone()))
});
// ── Initial data load (and reload on repository change) ───────────────────
let reload_trigger = use_context::<crate::ReloadTrigger>().map(|r| r.0);
Effect::new(move |_| {
if let Some(t) = reload_trigger { t.get(); } // track the trigger
spawn_local(async move {
match crate::tauri::list_root_libraries().await {
Ok(libs) => root_libs.set(libs),
Err(e) => leptos::logging::log!("load libraries: {e}"),
}
});
});
// ── Reactive data loads ───────────────────────────────────────────────────
// Reload list whenever the selected library or search query changes.
Effect::new(move |_| {
let lib_id = selected_library.get();
let query = search_query.get();
spawn_local(async move {
let result = match (&lib_id, query.is_empty()) {
(Some(id), true) => crate::tauri::list_library_references_recursive(id).await,
(Some(id), false) => crate::tauri::search_library_references(id, &query).await,
(None, true) => crate::tauri::list_references().await,
(None, false) => crate::tauri::search_references(&query).await,
};
match result {
Ok(items) => list_items.set(items),
Err(e) => {
leptos::logging::error!("load publications: {e}");
list_items.set(vec![]);
}
}
list_cursor.set(0);
});
});
// Load full reference when list cursor moves.
Effect::new(move |_| {
let ref_id: Option<ReferenceId> = selected_ref_id.get();
spawn_local(async move {
let loaded = match ref_id {
Some(id) => crate::tauri::get_reference(&id).await.ok(),
None => None,
};
detail_ref.set(loaded);
});
});
// ── Keymap wiring ─────────────────────────────────────────────────────────
Effect::new(move |_| {
let Some(ev) = keymap_action.get() else { return };
// ACTION_OPEN in the List or Detail pane opens a PDF tab for the selected reference.
if ev.name == actions::ACTION_OPEN
&& matches!(focused.get_untracked(), Pane::List | Pane::Detail)
{
if let Some(ref_id) = selected_ref_id.get_untracked() {
let title = detail_ref.with_untracked(|r| {
r.as_ref()
.map(|r| r.cite_key.clone())
.unwrap_or_else(|| ref_id.to_string())
});
if let Some(ctx) = use_context::<crate::OpenPdfContext>() {
ctx.0.set(Some(crate::PdfOpenRequest {
ref_id: ref_id.to_string(),
title,
}));
}
}
return;
}
handle_action(
&ev,
focused,
TreeState { cursor: tree_cursor, rows: tree_rows, expanded },
ListState { cursor: list_cursor, items: list_items },
);
});
// ── View ──────────────────────────────────────────────────────────────────
view! {
<div class="lib-tab">
<div class="pane pane-left">
<LibraryTree
root_libs=root_libs
children_cache=children_cache
expanded=expanded
cursor=tree_cursor
focused=focused
/>
</div>
<div class="pane pane-center">
<PubList
items=list_items
cursor=list_cursor
focused=focused
/>
</div>
<div class="pane pane-right" class:pane-focused=move || focused.get() == Pane::Detail>
<PubDetail reference=detail_ref />
</div>
</div>
}
}
// ── Action handler ────────────────────────────────────────────────────────────
struct TreeState {
cursor: RwSignal<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
}
struct ListState {
cursor: RwSignal<usize>,
items: RwSignal<Vec<ReferenceSummary>>,
}
fn handle_action(
ev: &ActionEvent,
focused: RwSignal<Pane>,
tree: TreeState,
list: ListState,
) {
let count = (ev.count as usize).max(1);
let cur = focused.get_untracked();
match ev.name.as_str() {
// ── Focus switching ───────────────────────────────────────────────────
actions::FOCUS_LEFT => focused.set(Pane::Tree),
actions::FOCUS_CENTER => focused.set(Pane::List),
actions::FOCUS_RIGHT => focused.set(Pane::Detail),
actions::FOCUS_NEXT => focused.update(|p| *p = p.next()),
actions::FOCUS_PREV => focused.update(|p| *p = p.prev()),
// ── Tree navigation ───────────────────────────────────────────────────
actions::NAV_DOWN if cur == Pane::Tree => {
let len = tree.rows.with_untracked(Vec::len);
if len > 0 {
tree.cursor.update(|c| *c = (*c + count).min(len - 1));
}
}
actions::NAV_UP if cur == Pane::Tree => {
tree.cursor.update(|c| *c = c.saturating_sub(count));
}
actions::NAV_TOP if cur == Pane::Tree => tree.cursor.set(0),
actions::NAV_BOTTOM if cur == Pane::Tree => {
let len = tree.rows.with_untracked(Vec::len);
if len > 0 {
tree.cursor.set(len - 1);
}
}
actions::NAV_PAGE_DOWN if cur == Pane::Tree => {
let len = tree.rows.with_untracked(Vec::len);
if len > 0 {
tree.cursor.update(|c| *c = (*c + 10 * count).min(len - 1));
}
}
actions::NAV_PAGE_UP if cur == Pane::Tree => {
tree.cursor.update(|c| *c = c.saturating_sub(10 * count));
}
// ── Tree expand / collapse ────────────────────────────────────────────
n if (n == actions::TREE_EXPAND || n == actions::ACTION_OPEN) && cur == Pane::Tree => {
tree_expand(tree.cursor, tree.rows, tree.expanded);
}
actions::TREE_COLLAPSE if cur == Pane::Tree => {
tree_collapse(tree.cursor, tree.rows, tree.expanded);
}
actions::TREE_TOGGLE if cur == Pane::Tree => {
let id = tree.rows.with_untracked(|rows| {
rows.get(tree.cursor.get_untracked()).map(|r| r.id.clone())
});
if let Some(id) = id {
let is_open = tree.expanded.with_untracked(|e| e.contains(&id));
if is_open {
tree.expanded.update(|e| { e.remove(&id); });
} else {
tree.expanded.update(|e| { e.insert(id.clone()); });
// Child loading is handled reactively by LibraryTree's Effect.
}
}
}
// ── List navigation ───────────────────────────────────────────────────
actions::NAV_DOWN if cur == Pane::List => {
let len = list.items.with_untracked(Vec::len);
if len > 0 {
list.cursor.update(|c| *c = (*c + count).min(len - 1));
}
}
actions::NAV_UP if cur == Pane::List => {
list.cursor.update(|c| *c = c.saturating_sub(count));
}
actions::NAV_TOP if cur == Pane::List => list.cursor.set(0),
actions::NAV_BOTTOM if cur == Pane::List => {
let len = list.items.with_untracked(Vec::len);
if len > 0 {
list.cursor.set(len - 1);
}
}
actions::NAV_PAGE_DOWN if cur == Pane::List => {
let len = list.items.with_untracked(Vec::len);
if len > 0 {
list.cursor.update(|c| *c = (*c + 10 * count).min(len - 1));
}
}
actions::NAV_PAGE_UP if cur == Pane::List => {
list.cursor.update(|c| *c = c.saturating_sub(10 * count));
}
_ => {}
}
}
// ── Tree helpers ──────────────────────────────────────────────────────────────
fn tree_expand(
cursor: RwSignal<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
) {
let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone()));
if let Some(id) = id {
expanded.update(|e| { e.insert(id.clone()); });
// Child loading is handled reactively by LibraryTree's Effect.
}
}
fn tree_collapse(
cursor: RwSignal<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
) {
let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone()));
if let Some(id) = id {
expanded.update(|e| { e.remove(&id); });
}
}
// ── Tests ──────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use brittle_keymap::actions;
use leptos::prelude::*;
fn ev(name: &str) -> ActionEvent {
ActionEvent { name: name.to_owned(), count: 1, seq: 0 }
}
fn ev_count(name: &str, count: u32) -> ActionEvent {
ActionEvent { name: name.to_owned(), count, seq: 0 }
}
fn make_tree_rows(n: usize) -> Vec<TreeRow> {
(0..n)
.map(|i| TreeRow {
id: format!("row-{i}"),
name: format!("Row {i}"),
depth: 0,
expanded: false,
may_have_children: false,
})
.collect()
}
fn make_ref_summaries(n: usize) -> Vec<ReferenceSummary> {
use brittle_model::{EntryType, ReferenceId};
(0..n)
.map(|_| ReferenceSummary {
id: ReferenceId::new(),
cite_key: "x".into(),
entry_type: EntryType::Misc,
title: None,
authors: vec![],
year: None,
})
.collect()
}
// Set up signals and call handle_action in a Leptos reactive owner.
// Returns the signal values after the action.
fn run<F>(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F)
where
F: FnOnce(RwSignal<Pane>, RwSignal<usize>, RwSignal<usize>, RwSignal<HashSet<String>>),
{
let owner = Owner::new();
owner.with(|| {
let focused = RwSignal::new(focused_init);
let tree_cursor = RwSignal::new(0usize);
let list_cursor = RwSignal::new(0usize);
let expanded = RwSignal::new(HashSet::<String>::new());
let rows_data = make_tree_rows(tree_len);
let rows = Memo::new(move |_| rows_data.clone());
let list_items = RwSignal::new(make_ref_summaries(list_len));
handle_action(
ev,
focused,
TreeState { cursor: tree_cursor, rows, expanded },
ListState { cursor: list_cursor, items: list_items },
);
check(focused, tree_cursor, list_cursor, expanded);
});
}
// ── Focus switching ────────────────────────────────────────────────────────
#[test]
fn focus_left_sets_tree() {
run(Pane::List, 0, 0, &ev(actions::FOCUS_LEFT), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::Tree);
});
}
#[test]
fn focus_center_sets_list() {
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_CENTER), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::List);
});
}
#[test]
fn focus_right_sets_detail() {
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_RIGHT), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::Detail);
});
}
#[test]
fn focus_next_cycles_forward() {
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::List);
});
run(Pane::List, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::Detail);
});
run(Pane::Detail, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::Tree);
});
}
#[test]
fn focus_prev_cycles_backward() {
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_PREV), |f, _, _, _| {
assert_eq!(f.get_untracked(), Pane::Detail);
});
}
// ── Tree navigation ────────────────────────────────────────────────────────
#[test]
fn nav_down_advances_tree_cursor() {
run(Pane::Tree, 5, 0, &ev(actions::NAV_DOWN), |_, tc, _, _| {
assert_eq!(tc.get_untracked(), 1);
});
}
#[test]
fn nav_down_clamps_at_tree_end() {
let owner = Owner::new();
owner.with(|| {
let focused = RwSignal::new(Pane::Tree);
let tree_cursor = RwSignal::new(4usize); // already at last row
let expanded = RwSignal::new(HashSet::<String>::new());
let rows_data = make_tree_rows(5);
let rows = Memo::new(move |_| rows_data.clone());
let list_cursor = RwSignal::new(0usize);
let list_items = RwSignal::new(vec![]);
handle_action(
&ev(actions::NAV_DOWN),
focused,
TreeState { cursor: tree_cursor, rows, expanded },
ListState { cursor: list_cursor, items: list_items },
);
assert_eq!(tree_cursor.get_untracked(), 4); // stayed at 4
});
}
#[test]
fn nav_up_decrements_tree_cursor() {
let owner = Owner::new();
owner.with(|| {
let focused = RwSignal::new(Pane::Tree);
let tree_cursor = RwSignal::new(3usize);
let expanded = RwSignal::new(HashSet::<String>::new());
let rows_data = make_tree_rows(5);
let rows = Memo::new(move |_| rows_data.clone());
handle_action(
&ev(actions::NAV_UP),
focused,
TreeState { cursor: tree_cursor, rows, expanded },
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
);
assert_eq!(tree_cursor.get_untracked(), 2);
});
}
#[test]
fn nav_up_clamps_at_zero() {
run(Pane::Tree, 5, 0, &ev(actions::NAV_UP), |_, tc, _, _| {
assert_eq!(tc.get_untracked(), 0); // already 0, stays 0
});
}
#[test]
fn nav_top_jumps_tree_to_start() {
let owner = Owner::new();
owner.with(|| {
let focused = RwSignal::new(Pane::Tree);
let tree_cursor = RwSignal::new(4usize);
let expanded = RwSignal::new(HashSet::<String>::new());
let rows_data = make_tree_rows(5);
let rows = Memo::new(move |_| rows_data.clone());
handle_action(
&ev(actions::NAV_TOP),
focused,
TreeState { cursor: tree_cursor, rows, expanded },
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
);
assert_eq!(tree_cursor.get_untracked(), 0);
});
}
#[test]
fn nav_bottom_jumps_tree_to_end() {
run(Pane::Tree, 5, 0, &ev(actions::NAV_BOTTOM), |_, tc, _, _| {
assert_eq!(tc.get_untracked(), 4);
});
}
#[test]
fn nav_down_with_count_advances_multiple() {
run(Pane::Tree, 10, 0, &ev_count(actions::NAV_DOWN, 3), |_, tc, _, _| {
assert_eq!(tc.get_untracked(), 3);
});
}
// ── List navigation ────────────────────────────────────────────────────────
#[test]
fn nav_down_advances_list_cursor_when_focused() {
run(Pane::List, 0, 5, &ev(actions::NAV_DOWN), |_, _, lc, _| {
assert_eq!(lc.get_untracked(), 1);
});
}
#[test]
fn nav_down_on_tree_does_not_advance_list_cursor() {
run(Pane::Tree, 3, 3, &ev(actions::NAV_DOWN), |_, tc, lc, _| {
assert_eq!(tc.get_untracked(), 1); // tree moved
assert_eq!(lc.get_untracked(), 0); // list unchanged
});
}
#[test]
fn nav_bottom_jumps_list_to_last_item() {
run(Pane::List, 0, 5, &ev(actions::NAV_BOTTOM), |_, _, lc, _| {
assert_eq!(lc.get_untracked(), 4);
});
}
// ── Tree expand/collapse ───────────────────────────────────────────────────
#[test]
fn tree_expand_action_inserts_id_into_expanded() {
run(Pane::Tree, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
assert!(exp.with_untracked(|e| e.contains("row-0")));
});
}
#[test]
fn tree_collapse_action_removes_id_from_expanded() {
let owner = Owner::new();
owner.with(|| {
let focused = RwSignal::new(Pane::Tree);
let tree_cursor = RwSignal::new(0usize);
let expanded = RwSignal::new(HashSet::from_iter(["row-0".to_string()]));
let rows_data = make_tree_rows(3);
let rows = Memo::new(move |_| rows_data.clone());
handle_action(
&ev(actions::TREE_COLLAPSE),
focused,
TreeState { cursor: tree_cursor, rows, expanded },
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
);
assert!(!expanded.with_untracked(|e| e.contains("row-0")));
});
}
#[test]
fn tree_actions_are_no_ops_when_list_focused() {
run(Pane::List, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
assert!(exp.with_untracked(|e| e.is_empty()));
});
}
}

332
brittle-ui/src/lib_tree.rs Normal file
View File

@@ -0,0 +1,332 @@
//! Library tree: flattening logic and the left-pane component.
use std::collections::{HashMap, HashSet};
use leptos::prelude::*;
use leptos::task::spawn_local;
use web_sys::DragEvent;
use brittle_model::{Library, LibraryId, ReferenceId};
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Load `id`'s children from the backend and cache them, unless already cached.
pub fn load_children_if_needed(id: String, children_cache: RwSignal<HashMap<String, Vec<Library>>>) {
let already = children_cache.with_untracked(|c| c.contains_key(&id));
if !already {
spawn_local(async move {
let lib_id = match uuid::Uuid::parse_str(&id) {
Ok(u) => LibraryId(u),
Err(_) => return,
};
match crate::tauri::list_child_libraries(&lib_id).await {
Ok(children) => children_cache.update(|c| { c.insert(id, children); }),
Err(e) => leptos::logging::error!("load children ({id}): {e}"),
}
});
}
}
// ── Tree logic ────────────────────────────────────────────────────────────────
/// A single visible row in the rendered library tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeRow {
/// Library ID string (UUID).
pub id: String,
/// Display name.
pub name: String,
/// Nesting depth (0 = root).
pub depth: usize,
/// Whether this node is currently expanded.
pub expanded: bool,
/// `true` if children have not been loaded yet, or the node has children.
/// `false` only when children were loaded and the result was empty.
pub may_have_children: bool,
}
/// Flatten the visible portion of the library hierarchy into an ordered list.
///
/// Only nodes whose ancestors are all expanded appear in the output.
/// Children are ordered as returned by `children_cache`.
pub fn flatten_tree(
libs: &[Library],
children_cache: &HashMap<String, Vec<Library>>,
expanded: &HashSet<String>,
depth: usize,
) -> Vec<TreeRow> {
let mut rows = Vec::new();
for lib in libs {
let id = lib.id.to_string();
let is_expanded = expanded.contains(&id);
let children = children_cache.get(&id);
let may_have_children = children.is_none_or(|c| !c.is_empty());
rows.push(TreeRow {
id: id.clone(),
name: lib.name.clone(),
depth,
expanded: is_expanded,
may_have_children,
});
if is_expanded {
if let Some(child_libs) = children {
rows.extend(flatten_tree(child_libs, children_cache, expanded, depth + 1));
}
}
}
rows
}
// ── Component ─────────────────────────────────────────────────────────────────
/// Left pane: renders the library tree.
///
/// Navigation (j/k, expand/collapse) is driven entirely from the parent via
/// `cursor` and `expanded`. Click on a row updates `cursor`.
///
/// Each tree row is also a drag-drop target: dropping a publication item onto a
/// row calls `add_to_library`, adding the reference to that library.
#[component]
pub fn LibraryTree(
root_libs: RwSignal<Vec<Library>>,
children_cache: RwSignal<HashMap<String, Vec<Library>>>,
expanded: RwSignal<HashSet<String>>,
cursor: RwSignal<usize>,
focused: RwSignal<crate::lib_tab::Pane>,
) -> impl IntoView {
use crate::lib_tab::Pane;
use leptos::either::Either;
let rows = Memo::new(move |_| {
flatten_tree(&root_libs.get(), &children_cache.get(), &expanded.get(), 0)
});
// Reactively load children for any newly-expanded node.
// Using an Effect guarantees this runs in a proper reactive owner context,
// which makes `spawn_local` and signal updates flush correctly.
Effect::new(move |_| {
let exp = expanded.get(); // subscribe to expansion changes
for id in exp {
load_children_if_needed(id, children_cache);
}
});
// Which tree-row ID (if any) is the current drag-over target.
let drag_over_id: RwSignal<Option<String>> = RwSignal::new(None);
view! {
<div class="tree-pane" class:pane-focused=move || focused.get() == Pane::Tree>
{move || {
let row_list = rows.get();
if row_list.is_empty() {
Either::Left(view! {
<div class="empty-state">"Open a repository to see libraries"</div>
})
} else {
let cursor_pos = cursor.get();
Either::Right(view! {
<ul class="tree-list">
{row_list.into_iter().enumerate().map(|(i, row)| {
let indent_px = row.depth * 16;
let icon = if row.may_have_children {
if row.expanded { "" } else { "" }
} else {
" "
};
let is_cursor = i == cursor_pos;
let row_may_have_children = row.may_have_children;
// Clone row.id for each closure that captures it.
let row_id_click = row.id.clone();
let row_id_class = row.id.clone();
let row_id_over = row.id.clone();
let row_id_drop = row.id.clone();
view! {
<li
class="tree-item"
class:tree-cursor=is_cursor
class:tree-drop-target=move || {
drag_over_id.get().as_deref() == Some(row_id_class.as_str())
}
style=format!("padding-left: {indent_px}px")
on:click=move |_| {
cursor.set(i);
}
on:dblclick=move |_| {
if row_may_have_children {
let is_open = expanded.with_untracked(|e| e.contains(&row_id_click));
if is_open {
expanded.update(|e| { e.remove(&row_id_click); });
} else {
expanded.update(|e| { e.insert(row_id_click.clone()); });
}
}
}
on:dragover=move |ev: DragEvent| {
ev.prevent_default();
drag_over_id.set(Some(row_id_over.clone()));
}
on:dragleave=move |_: DragEvent| {
drag_over_id.set(None);
}
on:drop=move |ev: DragEvent| {
ev.prevent_default();
drag_over_id.set(None);
let Some(dt) = ev.data_transfer() else { return };
let Ok(ref_id_str) = dt.get_data("application/brittle-ref-id") else { return };
if ref_id_str.is_empty() { return }
let Ok(lib_uuid) = uuid::Uuid::parse_str(&row_id_drop) else { return };
let Ok(ref_uuid) = uuid::Uuid::parse_str(&ref_id_str) else { return };
let lib_id = LibraryId(lib_uuid);
let ref_id = ReferenceId(ref_uuid);
spawn_local(async move {
if let Err(e) = crate::tauri::add_to_library(&lib_id, &ref_id).await {
leptos::logging::warn!("add_to_library: {e}");
}
});
}
>
<span class="tree-icon">{icon}</span>
<span class="tree-name">{row.name.clone()}</span>
</li>
}
}).collect::<Vec<_>>()}
</ul>
})
}
}}
</div>
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Create a Library with a deterministic UUID from a small integer.
/// `n = 1` → `00000000-0000-0000-0000-000000000001`, etc.
fn test_lib(n: u128, name: &str, parent: Option<u128>) -> Library {
let mut lib = Library::new(name, parent.map(|p| LibraryId(uuid::Uuid::from_u128(p))));
lib.id = LibraryId(uuid::Uuid::from_u128(n));
lib
}
fn id_str(n: u128) -> String {
uuid::Uuid::from_u128(n).to_string()
}
#[test]
fn empty_root_produces_empty_rows() {
let rows = flatten_tree(&[], &Default::default(), &Default::default(), 0);
assert!(rows.is_empty());
}
#[test]
fn single_root_unloaded_children() {
let root = test_lib(1, "Root", None);
let rows = flatten_tree(&[root], &Default::default(), &Default::default(), 0);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "Root");
assert_eq!(rows[0].depth, 0);
assert!(!rows[0].expanded);
// Unknown children → may_have_children = true
assert!(rows[0].may_have_children);
}
#[test]
fn collapsed_node_hides_children() {
let parent = test_lib(1, "Parent", None);
let child = test_lib(2, "Child", Some(1));
let mut cache = HashMap::new();
cache.insert(id_str(1), vec![child]);
// Not in expanded set → children hidden
let rows = flatten_tree(&[parent], &cache, &Default::default(), 0);
assert_eq!(rows.len(), 1);
assert!(rows[0].may_have_children);
}
#[test]
fn expanded_node_shows_children() {
let parent = test_lib(1, "Parent", None);
let child = test_lib(2, "Child", Some(1));
let mut cache = HashMap::new();
cache.insert(id_str(1), vec![child]);
let mut expanded = HashSet::new();
expanded.insert(id_str(1));
let rows = flatten_tree(&[parent], &cache, &expanded, 0);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "Parent");
assert!(rows[0].expanded);
assert_eq!(rows[1].name, "Child");
assert_eq!(rows[1].depth, 1);
}
#[test]
fn loaded_empty_children_marks_leaf() {
let node = test_lib(1, "Node", None);
let mut cache = HashMap::new();
cache.insert(id_str(1), vec![]); // explicitly empty
let rows = flatten_tree(&[node], &cache, &Default::default(), 0);
assert_eq!(rows.len(), 1);
assert!(!rows[0].may_have_children);
}
#[test]
fn multi_level_nesting() {
let root = test_lib(1, "Root", None);
let mid = test_lib(2, "Mid", Some(1));
let leaf = test_lib(3, "Leaf", Some(2));
let mut cache = HashMap::new();
cache.insert(id_str(1), vec![mid]);
cache.insert(id_str(2), vec![leaf]);
let mut expanded = HashSet::new();
expanded.insert(id_str(1));
expanded.insert(id_str(2));
let rows = flatten_tree(&[root], &cache, &expanded, 0);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].depth, 0);
assert_eq!(rows[1].depth, 1);
assert_eq!(rows[2].depth, 2);
assert_eq!(rows[2].name, "Leaf");
}
#[test]
fn multiple_roots_ordered() {
let a = test_lib(1, "Alpha", None);
let b = test_lib(2, "Beta", None);
let rows = flatten_tree(&[a, b], &Default::default(), &Default::default(), 0);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "Alpha");
assert_eq!(rows[1].name, "Beta");
}
#[test]
fn only_expanded_subtrees_included() {
// Parent expanded, but sibling collapsed
let p = test_lib(1, "P", None);
let s = test_lib(2, "S", None); // sibling root, also collapsed
let c = test_lib(3, "C", Some(1));
let sc = test_lib(4, "SC", Some(2));
let mut cache = HashMap::new();
cache.insert(id_str(1), vec![c]);
cache.insert(id_str(2), vec![sc]);
let mut expanded = HashSet::new();
expanded.insert(id_str(1)); // expand only P
let rows = flatten_tree(&[p, s], &cache, &expanded, 0);
assert_eq!(rows.len(), 3); // P, C, S (SC hidden)
assert_eq!(rows[0].name, "P");
assert_eq!(rows[1].name, "C");
assert_eq!(rows[2].name, "S");
}
}

568
brittle-ui/src/main.rs Normal file
View File

@@ -0,0 +1,568 @@
mod command_bar;
mod commands;
mod lib_tab;
mod lib_tree;
mod mode;
mod pdf_viewer;
mod pub_detail;
mod pub_list;
mod tab_bar;
mod tauri;
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use brittle_keymap::{actions, Key, KeyCode, KeymapState, Outcome, default_bindings};
use command_bar::{CommandBar, provide_search_query};
use leptos::prelude::*;
use lib_tab::LibTab;
use mode::{AppMode, provide_mode};
use pdf_viewer::PdfViewer;
use tab_bar::TabBar;
use web_sys::KeyboardEvent;
fn main() {
leptos::mount::mount_to_body(App);
}
// ── Action event ──────────────────────────────────────────────────────────────
/// A dispatched keymap action.
///
/// The `seq` field increments monotonically so that firing the same action
/// twice in succession produces a distinct signal value and triggers
/// reactive updates both times.
#[derive(Clone, PartialEq, Eq)]
pub struct ActionEvent {
pub name: String,
pub count: u32,
seq: u32,
}
thread_local! {
static ACTION_SEQ: Cell<u32> = const { Cell::new(0) };
}
pub fn next_seq() -> u32 {
ACTION_SEQ.with(|s| {
let n = s.get();
s.set(n.wrapping_add(1));
n
})
}
/// Context handle giving components read access to the last dispatched action.
#[derive(Clone, Copy)]
pub struct KeymapAction(pub ReadSignal<Option<ActionEvent>>);
// ── Tab types ─────────────────────────────────────────────────────────────────
/// A single open tab in the application.
#[derive(Clone, PartialEq, Eq)]
pub enum AppTab {
/// The persistent library / reference browser tab (always index 0).
Library,
/// A PDF viewer tab for a specific reference.
Pdf {
/// UUID string of the reference whose PDF is being viewed.
ref_id: String,
/// Short display name shown in the tab (typically the cite key).
title: String,
},
}
// ── Theme context ─────────────────────────────────────────────────────────────
/// Context handle for the current colour theme (`"dark"` or `"light"`).
///
/// Writable so `CommandBar` can update it when the user types `:theme …`.
#[derive(Clone, Copy)]
pub struct ThemeContext(pub RwSignal<String>);
fn provide_theme() {
let theme = RwSignal::new("dark".to_string());
provide_context(ThemeContext(theme));
// Reactively apply the `data-theme` attribute to `<html>`.
Effect::new(move |_| {
let t = theme.get();
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(el) = doc.document_element() {
let _ = el.set_attribute("data-theme", &t);
}
}
});
// Load the persisted theme from backend config; replace the default if
// found. Runs after the effect is live, so the DOM is updated immediately
// once the IPC call returns.
leptos::task::spawn_local(async move {
if let Ok(t) = crate::tauri::get_theme().await {
theme.set(t);
}
});
}
// ── Reload trigger ────────────────────────────────────────────────────────────
/// Incrementing counter that components watch to know when to reload their data.
///
/// Incremented whenever the open repository changes (`:open <path>`).
#[derive(Clone, Copy)]
pub struct ReloadTrigger(pub RwSignal<u32>);
// ── PDF open context ──────────────────────────────────────────────────────────
/// Request to open (or switch to) a PDF tab, posted by child components.
#[derive(Clone, PartialEq, Eq)]
pub struct PdfOpenRequest {
pub ref_id: String,
pub title: String,
}
/// Context handle that child components write to when they want to open a PDF tab.
#[derive(Clone, Copy)]
pub struct OpenPdfContext(pub RwSignal<Option<PdfOpenRequest>>);
// ── Keymap provider ────────────────────────────────────────────────────────────
/// Keydown message forwarded from a child iframe (e.g. the PDF viewer).
#[derive(serde::Deserialize)]
struct IframeKeyMsg {
#[serde(rename = "type")]
kind: String,
key: String,
#[serde(rename = "ctrlKey", default)] ctrl: bool,
#[serde(rename = "shiftKey", default)] shift: bool,
#[serde(rename = "altKey", default)] alt: bool,
#[serde(rename = "metaKey", default)] meta: bool,
}
fn provide_keymap() {
let state: Rc<RefCell<KeymapState>> =
Rc::new(RefCell::new(KeymapState::new(default_bindings())));
let (action_read, action_write) = signal::<Option<ActionEvent>>(None);
provide_context(KeymapAction(action_read));
let state_for_listener = state.clone();
let listener = window_event_listener(leptos::ev::keydown, move |ev: KeyboardEvent| {
if is_input_focused() {
return;
}
if let Some(key) = key_from_event(&ev) {
ev.prevent_default();
let outcome = state_for_listener.borrow_mut().process(key);
if let Outcome::Action { name, count } = outcome {
action_write.set(Some(ActionEvent { name, count, seq: next_seq() }));
}
}
});
// When a child iframe (PDF viewer) has focus, its keydown events don't
// bubble to the outer window. The PDF viewer forwards them via postMessage
// so global keybindings continue to work.
let state_for_msg = state.clone();
{
use wasm_bindgen::{closure::Closure, JsCast as _};
let cb: Closure<dyn Fn(web_sys::MessageEvent)> =
Closure::new(move |ev: web_sys::MessageEvent| {
let Ok(msg) =
serde_wasm_bindgen::from_value::<IframeKeyMsg>(ev.data())
else {
return;
};
if msg.kind != "brittle:keydown" {
return;
}
if let Some(key) =
key_from_parts(&msg.key, msg.ctrl, msg.shift, msg.alt, msg.meta)
{
let outcome = state_for_msg.borrow_mut().process(key);
if let Outcome::Action { name, count } = outcome {
action_write.set(Some(ActionEvent {
name,
count,
seq: next_seq(),
}));
}
}
});
if let Some(win) = web_sys::window() {
let _ = win
.add_event_listener_with_callback("message", cb.as_ref().unchecked_ref());
}
cb.forget(); // listener lives for the lifetime of the app
}
on_cleanup(move || listener.remove());
// Asynchronously load user keybinding overrides and hot-swap the state.
// Runs after the listener is already live, so the app is usable with
// defaults while the async IPC call is in flight.
let state_for_overrides = state.clone();
leptos::task::spawn_local(async move {
let overrides = match crate::tauri::get_keybindings().await {
Ok(map) if !map.is_empty() => map,
_ => return, // no config or error — keep defaults
};
let mut bindings = default_bindings();
// Config keys use snake_case; action names use dot.notation.
let pairs: Vec<(String, String)> = overrides
.into_iter()
.map(|(k, v)| (action_key_to_name(&k), v))
.collect();
bindings.apply_overrides(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())));
*state_for_overrides.borrow_mut() = KeymapState::new(bindings);
});
}
/// Convert a config keybinding key (snake_case) to an action name (dot.notation).
///
/// ```
/// # use brittle_ui::action_key_to_name; // won't compile as-is, just illustrating
/// assert_eq!(action_key_to_name("tab_next"), "tab.next");
/// assert_eq!(action_key_to_name("nav_page_down"), "nav.page.down");
/// ```
fn action_key_to_name(key: &str) -> String {
key.replace('_', ".")
}
// ── Helpers ────────────────────────────────────────────────────────────────────
fn is_input_focused() -> bool {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return false;
};
let Some(el) = doc.active_element() else {
return false;
};
let tag = el.tag_name().to_uppercase();
if tag == "INPUT" || tag == "TEXTAREA" {
return true;
}
el.get_attribute("contenteditable")
.map(|v| v != "false")
.unwrap_or(false)
}
fn key_from_event(ev: &KeyboardEvent) -> Option<Key> {
key_from_parts(&ev.key(), ev.ctrl_key(), ev.shift_key(), ev.alt_key(), ev.meta_key())
}
fn key_from_parts(key_str: &str, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Option<Key> {
let code = match key_str {
"Enter" => KeyCode::Enter,
"Escape" => KeyCode::Escape,
"Tab" => KeyCode::Tab,
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
" " => KeyCode::Space,
"ArrowUp" => KeyCode::ArrowUp,
"ArrowDown" => KeyCode::ArrowDown,
"ArrowLeft" => KeyCode::ArrowLeft,
"ArrowRight" => KeyCode::ArrowRight,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"PageUp" => KeyCode::PageUp,
"PageDown" => KeyCode::PageDown,
s if s.starts_with('F') && s.len() > 1 => KeyCode::F(s[1..].parse::<u8>().ok()?),
s if s.chars().count() == 1 => KeyCode::Char(s.chars().next().unwrap()),
_ => return None,
};
// For Char keys the character already encodes the shift state:
// ':' is the shifted ';', 'G' is the shifted 'g', etc.
// Including shift: true would produce <S-:> which never matches a binding.
// For special keys (Tab, arrows, …) shift must be preserved (<S-Tab>).
let shift = match code {
KeyCode::Char(_) => false,
_ => shift,
};
Some(Key { code, ctrl, shift, alt, meta })
}
// ── Tab provider ───────────────────────────────────────────────────────────────
/// Set up tab state and related contexts, following the same pattern as
/// `provide_keymap()` and `provide_theme()`.
///
/// Provides: [`OpenPdfContext`], [`ReloadTrigger`].
/// Returns: `(tabs, active_tab)` signals for the view layer.
fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
// The Library tab is always present at index 0 and cannot be closed.
let tabs = RwSignal::new(vec![AppTab::Library]);
let active_tab = RwSignal::new(0usize);
// Provide the open-PDF context so LibTab can post open requests.
let open_pdf_req = RwSignal::new(Option::<PdfOpenRequest>::None);
provide_context(OpenPdfContext(open_pdf_req));
// Reload trigger: increment when a repository is opened.
let reload_trigger = RwSignal::new(0u32);
provide_context(ReloadTrigger(reload_trigger));
// Auto-open the last repository from the previous session.
leptos::task::spawn_local(async move {
if let Ok(Some(path)) = crate::tauri::get_last_project().await {
if crate::tauri::open_repository(&path).await.is_ok() {
reload_trigger.update(|n| *n += 1);
}
}
});
// Tab keymap effects (cycling and closing).
let keymap_action = use_context::<KeymapAction>().unwrap().0;
Effect::new(move |_| {
let Some(ev) = keymap_action.get() else { return };
match ev.name.as_str() {
actions::TAB_NEXT => {
let len = tabs.with_untracked(Vec::len);
if len > 1 {
active_tab.update(|i| *i = (*i + 1) % len);
}
}
actions::TAB_PREV => {
let len = tabs.with_untracked(Vec::len);
if len > 1 {
active_tab.update(|i| *i = if *i == 0 { len - 1 } else { *i - 1 });
}
}
// Close the active tab (Library tab is immortal).
actions::TAB_CLOSE => {
let idx = active_tab.get_untracked();
if idx > 0 {
tabs.update(|t| { t.remove(idx); });
active_tab.update(|i| *i = i.saturating_sub(1));
}
}
_ => {}
}
});
// Open-PDF request handler: watches for requests from LibTab and opens or
// activates the appropriate PDF tab.
Effect::new(move |_| {
let Some(req) = open_pdf_req.get() else { return };
let existing = tabs.with_untracked(|t| {
t.iter().position(|tab| {
matches!(tab, AppTab::Pdf { ref_id: r, .. } if *r == req.ref_id)
})
});
if let Some(idx) = existing {
active_tab.set(idx);
} else {
let new_idx = tabs.with_untracked(Vec::len);
tabs.update(|t| t.push(AppTab::Pdf {
ref_id: req.ref_id.clone(),
title: req.title.clone(),
}));
active_tab.set(new_idx);
}
// Clear so the same ref_id can re-trigger (e.g. switch away, then re-open).
open_pdf_req.set(None);
});
(tabs, active_tab)
}
// ── Root component ─────────────────────────────────────────────────────────────
#[component]
fn App() -> impl IntoView {
provide_keymap();
provide_theme();
let mode = provide_mode();
provide_search_query();
let (tabs, active_tab) = provide_tabs();
// Mode-switching keymap effect (tab actions are handled inside provide_tabs).
let keymap_action = use_context::<KeymapAction>().unwrap().0;
Effect::new(move |_| {
let Some(ev) = keymap_action.get() else { return };
match ev.name.as_str() {
actions::MODE_COMMAND => mode.set(AppMode::Command),
actions::MODE_SEARCH => mode.set(AppMode::Search),
actions::MODE_NORMAL => mode.set(AppMode::Normal),
_ => {}
}
});
// ── View ──────────────────────────────────────────────────────────────────
view! {
<div class="app">
<TabBar tabs=tabs active_tab=active_tab />
<div class="app-body">
// Library tab — always mounted; hidden when a PDF tab is active.
<div style=move || if active_tab.get() == 0 { "height:100%" } else { "display:none" }>
<LibTab />
</div>
// PDF tabs — each mounted once (keyed by ref_id) and hidden
// when not active, so iframe state is preserved across switches.
<For
each=move || {
tabs.get()
.into_iter()
.enumerate()
.filter_map(|(i, tab)| match tab {
AppTab::Pdf { ref_id, .. } => Some((i, ref_id)),
AppTab::Library => None,
})
.collect::<Vec<_>>()
}
key=|(_, ref_id)| ref_id.clone()
children=move |(i, ref_id)| {
// Derive visibility reactively: look up the live tabs
// so the style updates correctly after tab close/reorder.
let ref_id_vis = ref_id.clone();
let is_visible = Memo::new(move |_| {
let active = active_tab.get();
tabs.with(|t| {
t.get(active)
.map(|tab| matches!(
tab,
AppTab::Pdf { ref_id: r, .. } if *r == ref_id_vis
))
.unwrap_or(false)
})
});
// `i` at creation time is used as a fallback for the
// close button in TabBar; here we only need visibility.
let _ = i;
view! {
<div style=move || if is_visible.get() { "height:100%" } else { "display:none" }>
<PdfViewer
ref_id=ref_id
is_active=Signal::derive(move || is_visible.get())
/>
</div>
}
}
/>
</div>
<CommandBar mode />
</div>
}
}
// ── Tests ──────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::{action_key_to_name, key_from_parts};
use brittle_keymap::{Key, KeyCode};
#[test]
fn single_segment_unchanged() {
assert_eq!(action_key_to_name("quit"), "quit");
}
#[test]
fn two_segment_conversion() {
assert_eq!(action_key_to_name("tab_next"), "tab.next");
assert_eq!(action_key_to_name("tab_prev"), "tab.prev");
assert_eq!(action_key_to_name("tab_close"), "tab.close");
}
#[test]
fn three_segment_conversion() {
assert_eq!(action_key_to_name("nav_page_down"), "nav.page.down");
assert_eq!(action_key_to_name("nav_page_up"), "nav.page.up");
}
#[test]
fn focus_actions() {
assert_eq!(action_key_to_name("focus_left"), "focus.left");
assert_eq!(action_key_to_name("focus_center"), "focus.center");
assert_eq!(action_key_to_name("focus_right"), "focus.right");
}
#[test]
fn mode_actions() {
assert_eq!(action_key_to_name("mode_command"), "mode.command");
assert_eq!(action_key_to_name("mode_normal"), "mode.normal");
}
// ── key_from_parts ───────────────────────────────────────────────────────
#[test]
fn char_key_basic() {
let k = key_from_parts("g", false, false, false, false).unwrap();
assert_eq!(k, Key { code: KeyCode::Char('g'), ctrl: false, shift: false, alt: false, meta: false });
}
#[test]
fn char_key_shift_suppressed() {
// 'G' already encodes the shift; shift flag must be suppressed to avoid <S-G>
let k = key_from_parts("G", false, true, false, false).unwrap();
assert_eq!(k.code, KeyCode::Char('G'));
assert!(!k.shift, "shift must be false for Char keys");
}
#[test]
fn char_key_colon_shift_suppressed() {
// ':' is the shifted ';' on many keyboards
let k = key_from_parts(":", false, true, false, false).unwrap();
assert_eq!(k.code, KeyCode::Char(':'));
assert!(!k.shift);
}
#[test]
fn char_key_ctrl_preserved() {
let k = key_from_parts("a", true, false, false, false).unwrap();
assert_eq!(k.code, KeyCode::Char('a'));
assert!(k.ctrl);
assert!(!k.shift);
}
#[test]
fn special_key_shift_preserved() {
let k = key_from_parts("Tab", false, true, false, false).unwrap();
assert_eq!(k.code, KeyCode::Tab);
assert!(k.shift, "shift must be preserved for special keys like Tab");
}
#[test]
fn arrow_keys_shift_preserved() {
let k = key_from_parts("ArrowUp", false, true, false, false).unwrap();
assert_eq!(k.code, KeyCode::ArrowUp);
assert!(k.shift);
let k = key_from_parts("ArrowDown", false, true, false, false).unwrap();
assert_eq!(k.code, KeyCode::ArrowDown);
assert!(k.shift);
}
#[test]
fn enter_escape_backspace() {
assert_eq!(key_from_parts("Enter", false, false, false, false).unwrap().code, KeyCode::Enter);
assert_eq!(key_from_parts("Escape", false, false, false, false).unwrap().code, KeyCode::Escape);
assert_eq!(key_from_parts("Backspace", false, false, false, false).unwrap().code, KeyCode::Backspace);
}
#[test]
fn function_keys() {
assert_eq!(key_from_parts("F1", false, false, false, false).unwrap().code, KeyCode::F(1));
assert_eq!(key_from_parts("F12", false, false, false, false).unwrap().code, KeyCode::F(12));
}
#[test]
fn unknown_key_returns_none() {
assert!(key_from_parts("Dead", false, false, false, false).is_none());
assert!(key_from_parts("Unidentified", false, false, false, false).is_none());
assert!(key_from_parts("AudioVolumeUp", false, false, false, false).is_none());
}
#[test]
fn space_key() {
let k = key_from_parts(" ", false, false, false, false).unwrap();
assert_eq!(k.code, KeyCode::Space);
}
}

24
brittle-ui/src/mode.rs Normal file
View File

@@ -0,0 +1,24 @@
//! Application mode state.
//!
//! Brittle has three modes:
//! - **Normal** — keyboard shortcuts are active; the default state.
//! - **Command** — the `:` prompt is open; user types a command.
//! - **Search** — the `/` prompt is open; user types a search/filter query.
use leptos::prelude::*;
/// The current input mode of the application.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AppMode {
Normal,
Command,
Search,
}
/// Create the application mode signal.
///
/// Returns the [`RwSignal`] which should be passed to components that need it.
/// Call once from the root component.
pub fn provide_mode() -> RwSignal<AppMode> {
RwSignal::new(AppMode::Normal)
}

View File

@@ -0,0 +1,55 @@
//! PDF viewer tab: embeds the Tauri-served PDF viewer in an iframe.
//!
//! The custom `brittle://` URI scheme serves:
//! - `brittle://app/viewer?ref_id=<uuid>` — the viewer HTML page (PDF.js)
//! - `brittle://app/pdf?ref_id=<uuid>` — the raw PDF bytes
//!
//! Using an `<iframe>` keeps the viewer alive when the tab is hidden (via
//! `display:none`), so scrolling position and zoom are preserved across
//! tab switches.
use brittle_keymap::actions;
use leptos::prelude::*;
use wasm_bindgen::JsValue;
/// Renders the PDF viewer for a single reference.
///
/// `ref_id` must be the UUID string of a reference that has an attached PDF.
/// `is_active` indicates whether this is the currently visible PDF tab; when
/// `true`, keymap actions for PDF page navigation are forwarded into the iframe.
#[component]
pub fn PdfViewer(ref_id: String, is_active: Signal<bool>) -> impl IntoView {
let url = format!("brittle://app/viewer?ref_id={ref_id}");
let iframe_ref = NodeRef::<leptos::html::Iframe>::new();
let keymap_action = use_context::<crate::KeymapAction>()
.expect("KeymapAction context missing")
.0;
Effect::new(move |_| {
let Some(ev) = keymap_action.get() else { return };
if !is_active.get_untracked() { return }
let cmd = match ev.name.as_str() {
actions::PDF_PAGE_NEXT => actions::PDF_PAGE_NEXT,
actions::PDF_PAGE_PREV => actions::PDF_PAGE_PREV,
_ => return,
};
let Some(iframe) = iframe_ref.get() else { return };
if let Some(win) = iframe.content_window() {
let _ = win.post_message(&JsValue::from_str(cmd), "*");
}
});
view! {
<iframe
node_ref=iframe_ref
class="pdf-frame"
src=url
// Intentionally no `sandbox` attribute — the brittle:// protocol
// and PDF.js require unrestricted access within the webview.
/>
}
}

View File

@@ -0,0 +1,108 @@
//! Publication detail: right pane showing full reference fields.
use leptos::prelude::*;
use brittle_model::Reference;
/// Right pane: displays the fields of the currently selected reference.
///
/// When `reference` is `None`, a placeholder is shown.
#[component]
pub fn PubDetail(reference: RwSignal<Option<Reference>>) -> impl IntoView {
use leptos::either::Either;
view! {
<div class="pub-detail-pane">
{move || match reference.get() {
None => Either::Left(view! {
<div class="empty-state">"Select a publication to see details"</div>
}),
Some(r) => Either::Right(view! {
<div class="detail-content">
<h2 class="detail-title">
{r.fields.get("title").cloned().unwrap_or_else(|| "[no title]".into())}
</h2>
<dl class="detail-fields">
// Entry type + cite key
<dt>"Type"</dt>
<dd>{r.entry_type.to_string()}</dd>
<dt>"Cite key"</dt>
<dd class="mono">{r.cite_key.clone()}</dd>
// Authors
{if r.authors.is_empty() {
None
} else {
let names = r.authors.iter()
.map(|p| p.display_name())
.collect::<Vec<_>>()
.join("; ");
Some(view! {
<dt>"Authors"</dt>
<dd>{names}</dd>
})
}}
// Editors
{if r.editors.is_empty() {
None
} else {
let names = r.editors.iter()
.map(|p| p.display_name())
.collect::<Vec<_>>()
.join("; ");
Some(view! {
<dt>"Editors"</dt>
<dd>{names}</dd>
})
}}
// Prioritised well-known fields
{PRIORITY_FIELDS.iter().filter_map(|&key| {
r.fields.get(key).map(|val| view! {
<dt>{field_label(key)}</dt>
<dd>{val.clone()}</dd>
})
}).collect::<Vec<_>>()}
// Remaining fields in alphabetical order
{r.fields.iter()
.filter(|(k, _)| !PRIORITY_FIELDS.contains(&k.as_str())
&& *k != "title")
.map(|(k, v)| view! {
<dt>{field_label(k)}</dt>
<dd>{v.clone()}</dd>
})
.collect::<Vec<_>>()
}
</dl>
<p class="detail-timestamps">
"Modified: "{r.modified_at.format("%Y-%m-%d %H:%M UTC").to_string()}
</p>
</div>
}),
}}
</div>
}
}
/// Fields shown before the alphabetical remainder (excluding "title").
const PRIORITY_FIELDS: &[&str] = &["year", "journal", "booktitle", "volume", "doi", "abstract"];
/// Pretty-print a BibTeX field key.
fn field_label(key: &str) -> String {
match key {
"doi" => "DOI".into(),
"isbn" => "ISBN".into(),
"issn" => "ISSN".into(),
"url" => "URL".into(),
_ => {
let mut s = key.replace('_', " ");
if let Some(c) = s.get_mut(0..1) {
c.make_ascii_uppercase();
}
s
}
}
}

View File

@@ -0,0 +1,82 @@
//! Publication list: centre pane showing filtered references.
use leptos::prelude::*;
use brittle_model::ReferenceSummary;
use crate::lib_tab::Pane;
/// Centre pane: a scrollable list of publication summaries.
///
/// `cursor` tracks which row is selected; clicking a row updates it.
/// Items are draggable — dropping onto a library tree node calls
/// `add_to_library` via the Tauri IPC.
#[component]
pub fn PubList(
items: RwSignal<Vec<ReferenceSummary>>,
cursor: RwSignal<usize>,
focused: RwSignal<Pane>,
) -> impl IntoView {
use leptos::either::Either;
let open_pdf_ctx = use_context::<crate::OpenPdfContext>();
view! {
<div class="pub-list-pane" class:pane-focused=move || focused.get() == Pane::List>
{move || {
let list = items.get();
if list.is_empty() {
Either::Left(view! {
<div class="empty-state">"No publications"</div>
})
} else {
let cursor_pos = cursor.get();
Either::Right(view! {
<ul class="pub-list">
{list.into_iter().enumerate().map(|(i, item)| {
let is_cursor = i == cursor_pos;
let title = item.title_display().to_owned();
let authors = item.author_display();
let year = item.year.clone().unwrap_or_default();
let kind = item.entry_type.to_string();
let ref_id = item.id.to_string();
let cite_key = item.cite_key.clone();
let ref_id_for_dblclick = ref_id.clone();
view! {
<li
class="pub-item"
class:pub-cursor=is_cursor
draggable="true"
on:dragstart=move |ev| {
let ev: &web_sys::DragEvent = &ev;
if let Some(dt) = ev.data_transfer() {
let _ = dt.set_data(
"application/brittle-ref-id",
&ref_id,
);
}
}
on:click=move |_| cursor.set(i)
on:dblclick=move |_| {
cursor.set(i);
if let Some(ctx) = open_pdf_ctx {
ctx.0.set(Some(crate::PdfOpenRequest {
ref_id: ref_id_for_dblclick.clone(),
title: cite_key.clone(),
}));
}
}
>
<span class="pub-type">{kind}</span>
<span class="pub-title">{title}</span>
<span class="pub-meta">{authors}{" "}{year}</span>
</li>
}
}).collect::<Vec<_>>()}
</ul>
})
}
}}
</div>
}
}

57
brittle-ui/src/tab_bar.rs Normal file
View File

@@ -0,0 +1,57 @@
//! Tab bar component rendered at the top of the app.
use leptos::prelude::*;
use crate::AppTab;
/// Renders the horizontal tab bar.
///
/// Clicking a tab activates it. The Library tab has no close button;
/// PDF tabs show a `×` close button on hover.
#[component]
pub fn TabBar(
tabs: RwSignal<Vec<AppTab>>,
active_tab: RwSignal<usize>,
) -> impl IntoView {
view! {
<div class="tab-bar">
{move || {
tabs.get().into_iter().enumerate().map(|(i, tab)| {
let is_active = i == active_tab.get();
match tab {
AppTab::Library => view! {
<button
class="tab tab-library"
class:tab-active=is_active
on:click=move |_| active_tab.set(i)
>
"Library"
</button>
}.into_any(),
AppTab::Pdf { title, .. } => view! {
<span
class="tab tab-pdf"
class:tab-active=is_active
on:click=move |_| active_tab.set(i)
>
<span class="tab-label">{title}</span>
<button
class="tab-close"
on:click=move |ev| {
ev.stop_propagation();
tabs.update(|t| { t.remove(i); });
active_tab.update(|a| *a = (*a).min(
tabs.with_untracked(|t| t.len().saturating_sub(1))
));
}
>
"×"
</button>
</span>
}.into_any(),
}
}).collect::<Vec<_>>()
}}
</div>
}
}

187
brittle-ui/src/tauri.rs Normal file
View File

@@ -0,0 +1,187 @@
//! Wrappers around the Tauri `invoke` API.
//!
//! Each function maps directly to a Tauri command defined in `src-tauri`.
//! On non-wasm32 targets (native unit test runs) every function returns an
//! error immediately — they are never called in that context.
use std::collections::HashMap;
use serde::Serialize;
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
// ── Low-level invoke ───────────────────────────────────────────────────────────
/// Empty argument struct for commands that take no user parameters.
#[derive(Serialize)]
struct NoArgs {}
#[cfg(target_arch = "wasm32")]
mod ffi {
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
/// `window.__TAURI__.core.invoke(cmd, args)`
///
/// `catch` turns a JS exception (e.g. `window.__TAURI__` undefined when
/// running in a plain browser) into `Err(JsValue)` instead of a WASM
/// trap, so callers can degrade gracefully.
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], js_name = invoke, catch)]
pub fn invoke_js(cmd: &str, args: JsValue) -> Result<js_sys::Promise, JsValue>;
}
}
/// Invoke a Tauri command, serialise `args` as JSON and deserialise the result.
async fn invoke<T: for<'de> serde::Deserialize<'de>, A: Serialize>(
cmd: &str,
args: &A,
) -> Result<T, String> {
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen_futures::JsFuture;
let args_js =
serde_wasm_bindgen::to_value(args).map_err(|e| e.to_string())?;
let promise = ffi::invoke_js(cmd, args_js)
.map_err(|_| "Tauri IPC unavailable (not running inside the desktop app)".to_string())?;
let result = JsFuture::from(promise)
.await
.map_err(|e| format!("{e:?}"))?;
serde_wasm_bindgen::from_value::<T>(result).map_err(|e| e.to_string())
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = (cmd, args);
Err("tauri not available outside of wasm32".into())
}
}
// ── Config commands ────────────────────────────────────────────────────────────
/// Return the last-opened project path from the global config, or `None` if
/// none was recorded or the directory no longer exists on disk.
pub async fn get_last_project() -> Result<Option<String>, String> {
invoke("get_last_project", &NoArgs {}).await
}
/// Return the user's keybinding overrides from `~/.config/brittle/config.toml`.
///
/// Keys are snake_case action names (e.g. `"tab_next"`); values are
/// key-sequence strings (e.g. `"<C-Right>"`).
pub async fn get_keybindings() -> Result<HashMap<String, String>, String> {
invoke("get_keybindings", &NoArgs {}).await
}
// ── Appearance commands ────────────────────────────────────────────────────────
/// Return the current theme name (`"dark"` or `"light"`) from the global config.
pub async fn get_theme() -> Result<String, String> {
invoke("get_theme", &NoArgs {}).await
}
/// Persist a new theme choice (`"dark"` or `"light"`) to the global config.
pub async fn set_theme(theme: &str) -> Result<(), String> {
#[derive(Serialize)]
struct Args<'a> {
theme: &'a str,
}
invoke("set_theme", &Args { theme }).await
}
// ── Repository commands ────────────────────────────────────────────────────────
pub async fn open_repository(path: &str) -> Result<(), String> {
#[derive(Serialize)]
struct Args<'a> {
path: &'a str,
}
invoke("open_repository", &Args { path }).await
}
// ── Library commands ───────────────────────────────────────────────────────────
pub async fn list_root_libraries() -> Result<Vec<Library>, String> {
invoke("list_root_libraries", &NoArgs {}).await
}
pub async fn list_child_libraries(parent_id: &LibraryId) -> Result<Vec<Library>, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
parent_id: &'a LibraryId,
}
invoke("list_child_libraries", &Args { parent_id }).await
}
// ── Library membership commands ────────────────────────────────────────────────
/// Add a reference to a library (additive; does not move the reference).
pub async fn add_to_library(
library_id: &LibraryId,
reference_id: &ReferenceId,
) -> Result<(), String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
library_id: &'a LibraryId,
reference_id: &'a ReferenceId,
}
invoke("add_to_library", &Args { library_id, reference_id }).await
}
// ── Reference commands ─────────────────────────────────────────────────────────
pub async fn list_references() -> Result<Vec<ReferenceSummary>, String> {
invoke("list_references", &NoArgs {}).await
}
pub async fn list_library_references(
library_id: &LibraryId,
) -> Result<Vec<ReferenceSummary>, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
library_id: &'a LibraryId,
}
invoke("list_library_references", &Args { library_id }).await
}
pub async fn list_library_references_recursive(
library_id: &LibraryId,
) -> Result<Vec<ReferenceSummary>, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
library_id: &'a LibraryId,
}
invoke("list_library_references_recursive", &Args { library_id }).await
}
pub async fn search_references(query: &str) -> Result<Vec<ReferenceSummary>, String> {
#[derive(Serialize)]
struct Args<'a> {
query: &'a str,
}
invoke("search_references", &Args { query }).await
}
pub async fn search_library_references(
library_id: &LibraryId,
query: &str,
) -> Result<Vec<ReferenceSummary>, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
library_id: &'a LibraryId,
query: &'a str,
}
invoke("search_library_references", &Args { library_id, query }).await
}
pub async fn get_reference(id: &ReferenceId) -> Result<Reference, String> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a ReferenceId,
}
invoke("get_reference", &Args { id }).await
}

373
brittle-ui/style.css Normal file
View File

@@ -0,0 +1,373 @@
/* ── Reset / base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1e2e;
--bg-surface: #24243a;
--bg-overlay: #2e2e44;
--border: #3a3a55;
--accent: #7c6af7;
--accent-dim: #5a4dcc;
--text: #cdd6f4;
--text-muted: #7f849c;
--text-subtle: #585b70;
--cursor-bg: #3a3a70;
--cursor-fg: #ffffff;
--focused-ring: #7c6af7;
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
--font-ui: system-ui, -apple-system, "Segoe UI", sans-serif;
--radius: 4px;
--tab-h: 32px;
--cmd-h: 28px;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
}
ul { list-style: none; }
/* ── App shell ────────────────────────────────────────────────────────────── */
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-body {
flex: 1;
overflow: hidden;
position: relative;
}
/* ── Tab bar ──────────────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
align-items: stretch;
height: var(--tab-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 0 4px;
gap: 2px;
flex-shrink: 0;
}
.tab {
display: inline-flex;
align-items: center;
padding: 0 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
white-space: nowrap;
user-select: none;
border-radius: var(--radius) var(--radius) 0 0;
gap: 6px;
}
.tab:hover { color: var(--text); background: var(--bg-overlay); }
.tab.tab-active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tab-pdf { cursor: pointer; }
.tab-label { pointer-events: none; }
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--text-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 0;
opacity: 0;
transition: opacity 0.1s, background 0.1s;
}
.tab-pdf:hover .tab-close { opacity: 1; }
.tab-close:hover { background: var(--bg-overlay); color: var(--text); }
/* ── Three-pane library layout ────────────────────────────────────────────── */
.lib-tab {
display: flex;
height: 100%;
}
.pane {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border);
}
.pane:last-child { border-right: none; }
.pane-left { width: 220px; flex-shrink: 0; }
.pane-center { flex: 1; min-width: 0; }
.pane-right { width: 300px; flex-shrink: 0; }
.pane-focused { outline: 1px solid var(--focused-ring); outline-offset: -1px; }
/* ── Library tree pane ────────────────────────────────────────────────────── */
.tree-pane {
height: 100%;
overflow-y: auto;
padding: 4px 0;
}
.tree-list { padding: 0; }
.tree-item {
display: flex;
align-items: center;
gap: 2px;
padding: 3px 8px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 0;
}
.tree-item:hover { background: var(--bg-overlay); }
.tree-item.tree-cursor {
background: var(--cursor-bg);
color: var(--cursor-fg);
}
.tree-icon {
color: var(--text-muted);
font-size: 11px;
width: 14px;
flex-shrink: 0;
}
.tree-name {
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Publication list pane ────────────────────────────────────────────────── */
.pub-list-pane {
height: 100%;
overflow-y: auto;
}
.pub-list { padding: 4px 0; }
.pub-item {
display: flex;
flex-direction: column;
padding: 6px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border);
gap: 1px;
}
.pub-item:hover { background: var(--bg-overlay); }
.pub-item.pub-cursor {
background: var(--cursor-bg);
color: var(--cursor-fg);
}
.pub-item.pub-cursor .pub-meta { color: rgba(255,255,255,0.6); }
.pub-item.pub-cursor .pub-type { color: rgba(255,255,255,0.7); }
.pub-type {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
font-weight: 600;
}
.pub-title {
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.pub-meta {
font-size: 11px;
color: var(--text-muted);
}
/* ── Publication detail pane ──────────────────────────────────────────────── */
.pub-detail-pane {
height: 100%;
overflow-y: auto;
padding: 12px;
}
.detail-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 10px;
color: var(--text);
}
.detail-fields {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
align-items: baseline;
}
.detail-fields dt {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
text-align: right;
}
.detail-fields dd {
color: var(--text);
font-size: 12px;
word-break: break-word;
min-width: 0;
}
.detail-timestamps {
margin-top: 12px;
font-size: 10px;
color: var(--text-subtle);
}
.mono {
font-family: var(--font-mono);
font-size: 11px;
}
/* ── Empty state ──────────────────────────────────────────────────────────── */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-subtle);
font-size: 12px;
padding: 24px;
text-align: center;
}
/* ── Command / search bar ─────────────────────────────────────────────────── */
.command-bar {
display: flex;
align-items: center;
height: var(--cmd-h);
background: var(--bg-surface);
border-top: 1px solid var(--border);
padding: 0 8px;
gap: 4px;
flex-shrink: 0;
}
.command-prefix {
color: var(--accent);
font-family: var(--font-mono);
font-size: 14px;
font-weight: bold;
line-height: 1;
}
.command-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font: inherit;
font-family: var(--font-mono);
font-size: 12px;
caret-color: var(--accent);
}
.command-status {
color: #f38ba8;
font-size: 12px;
}
/* ── PDF viewer frame ─────────────────────────────────────────────────────── */
.pdf-frame {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* ── Drag-and-drop ────────────────────────────────────────────────────────── */
/* Suppress pointer events on text spans inside tree rows so that
dragleave / dragover never fire on child elements — avoids highlight flicker
when the cursor moves between the icon and the label inside one row. */
.tree-icon,
.tree-name { pointer-events: none; }
/* Publication items are drag sources. */
.pub-item[draggable="true"] { cursor: grab; }
.pub-item[draggable="true"]:active { cursor: grabbing; }
/* Library tree rows highlighted as drop targets. */
.tree-item.tree-drop-target {
background: var(--accent-dim);
color: var(--cursor-fg);
outline: 1px dashed var(--accent);
outline-offset: -1px;
}
/* ── Light theme (Catppuccin Latte) ──────────────────────────────────────── */
[data-theme="light"] {
--bg: #eff1f5;
--bg-surface: #e6e9ef;
--bg-overlay: #dce0e8;
--border: #ccd0da;
--accent: #7287fd;
--accent-dim: #5c6bc0;
--text: #4c4f69;
--text-muted: #8c8fa1;
--text-subtle: #acb0be;
--cursor-bg: #7287fd;
--cursor-fg: #eff1f5;
--focused-ring: #7287fd;
}
/* ── Scrollbar (WebKit) ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); }