Clean up project structure
This commit is contained in:
2143
brittle-ui/Cargo.lock
generated
Normal file
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
19
brittle-ui/Cargo.toml
Normal 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
7
brittle-ui/Trunk.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../dist"
|
||||
|
||||
[serve]
|
||||
port = 1420
|
||||
open = false
|
||||
11
brittle-ui/index.html
Normal file
11
brittle-ui/index.html
Normal 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>
|
||||
147
brittle-ui/src/command_bar.rs
Normal file
147
brittle-ui/src/command_bar.rs
Normal 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
128
brittle-ui/src/commands.rs
Normal 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
604
brittle-ui/src/lib_tab.rs
Normal 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
332
brittle-ui/src/lib_tree.rs
Normal 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
568
brittle-ui/src/main.rs
Normal 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
24
brittle-ui/src/mode.rs
Normal 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)
|
||||
}
|
||||
55
brittle-ui/src/pdf_viewer.rs
Normal file
55
brittle-ui/src/pdf_viewer.rs
Normal 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.
|
||||
/>
|
||||
}
|
||||
}
|
||||
108
brittle-ui/src/pub_detail.rs
Normal file
108
brittle-ui/src/pub_detail.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
82
brittle-ui/src/pub_list.rs
Normal file
82
brittle-ui/src/pub_list.rs
Normal 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
57
brittle-ui/src/tab_bar.rs
Normal 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
187
brittle-ui/src/tauri.rs
Normal 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
373
brittle-ui/style.css
Normal 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); }
|
||||
Reference in New Issue
Block a user