notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 84c67067311bf0c5aa673d2797602fc9cb5a3459
parent f3f9cefb00ed60259e36162069262ca470a97e26
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 28 Jan 2026 21:18:27 -0800

refactor(dave): extract inline code into separate modules

Extract large inline functions from claude.rs and dave.rs into focused modules:

- backend/tool_summary.rs: tool result formatting functions
- backend/session_info.rs: session info parsing
- ui/pill.rs: pill-style label components
- ui/query_ui.rs: query call display
- ui/top_buttons.rs: top button bar (pfp, settings)

Reduces claude.rs by ~180 lines and dave.rs by ~185 lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 188+++----------------------------------------------------------------------------
Mcrates/notedeck_dave/src/backend/mod.rs | 2++
Acrates/notedeck_dave/src/backend/session_info.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/tool_summary.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 2+-
Mcrates/notedeck_dave/src/ui/dave.rs | 204+++++--------------------------------------------------------------------------
Mcrates/notedeck_dave/src/ui/mod.rs | 3+++
Acrates/notedeck_dave/src/ui/pill.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/query_ui.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/top_buttons.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 435 insertions(+), 377 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -1,8 +1,12 @@ +use crate::backend::session_info::parse_session_info; +use crate::backend::tool_summary::{ + extract_response_content, format_tool_summary, truncate_output, +}; use crate::backend::traits::AiBackend; use crate::file_update::FileUpdate; use crate::messages::{ CompactionInfo, DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse, - SessionInfo, SubagentInfo, SubagentStatus, ToolResult, + SubagentInfo, SubagentStatus, ToolResult, }; use crate::tools::Tool; use crate::Message; @@ -653,185 +657,3 @@ impl AiBackend for ClaudeBackend { } } } - -/// Extract string content from a tool response, handling various JSON structures -fn extract_response_content(response: &serde_json::Value) -> Option<String> { - // Try direct string first - if let Some(s) = response.as_str() { - return Some(s.to_string()); - } - // Try "content" field (common wrapper) - if let Some(s) = response.get("content").and_then(|v| v.as_str()) { - return Some(s.to_string()); - } - // Try file.content for Read tool responses - if let Some(s) = response - .get("file") - .and_then(|f| f.get("content")) - .and_then(|v| v.as_str()) - { - return Some(s.to_string()); - } - // Try "output" field - if let Some(s) = response.get("output").and_then(|v| v.as_str()) { - return Some(s.to_string()); - } - // Try "result" field - if let Some(s) = response.get("result").and_then(|v| v.as_str()) { - return Some(s.to_string()); - } - // Fallback: serialize the whole response if it's not null - if !response.is_null() { - return Some(response.to_string()); - } - None -} - -/// Format a human-readable summary for tool execution results -fn format_tool_summary( - tool_name: &str, - input: &serde_json::Value, - response: &serde_json::Value, -) -> String { - match tool_name { - "Read" => { - let file = input - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let filename = file.rsplit('/').next().unwrap_or(file); - // Try to get numLines directly from file metadata (most accurate) - let lines = response - .get("file") - .and_then(|f| f.get("numLines").or_else(|| f.get("totalLines"))) - .and_then(|v| v.as_u64()) - .map(|n| n as usize) - // Fallback to counting lines in content - .or_else(|| { - extract_response_content(response) - .as_ref() - .map(|s| s.lines().count()) - }) - .unwrap_or(0); - format!("{} ({} lines)", filename, lines) - } - "Write" => { - let file = input - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let filename = file.rsplit('/').next().unwrap_or(file); - let bytes = input - .get("content") - .and_then(|v| v.as_str()) - .map(|s| s.len()) - .unwrap_or(0); - format!("{} ({} bytes)", filename, bytes) - } - "Bash" => { - let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); - // Truncate long commands - let cmd_display = if cmd.len() > 40 { - format!("{}...", &cmd[..37]) - } else { - cmd.to_string() - }; - let output_len = extract_response_content(response) - .as_ref() - .map(|s| s.len()) - .unwrap_or(0); - if output_len > 0 { - format!("`{}` ({} chars)", cmd_display, output_len) - } else { - format!("`{}`", cmd_display) - } - } - "Grep" => { - let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); - format!("'{}'", pattern) - } - "Glob" => { - let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); - format!("'{}'", pattern) - } - "Edit" => { - let file = input - .get("file_path") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let filename = file.rsplit('/').next().unwrap_or(file); - filename.to_string() - } - "Task" => { - let description = input - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("task"); - let subagent_type = input - .get("subagent_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - format!("{} ({})", description, subagent_type) - } - _ => String::new(), - } -} - -/// Parse a System message into SessionInfo -fn parse_session_info(system_msg: &claude_agent_sdk_rs::SystemMessage) -> SessionInfo { - let data = &system_msg.data; - - // Extract slash_commands from data - let slash_commands = data - .get("slash_commands") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - // Extract agents from data - let agents = data - .get("agents") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - // Extract CLI version - let cli_version = data - .get("claude_code_version") - .and_then(|v| v.as_str()) - .map(String::from); - - SessionInfo { - tools: system_msg.tools.clone().unwrap_or_default(), - model: system_msg.model.clone(), - permission_mode: system_msg.permission_mode.clone(), - slash_commands, - agents, - cli_version, - cwd: system_msg.cwd.clone(), - claude_session_id: system_msg.session_id.clone(), - } -} - -/// Truncate output to a maximum size, keeping the end (most recent) content -fn truncate_output(output: &str, max_size: usize) -> String { - if output.len() <= max_size { - output.to_string() - } else { - let start = output.len() - max_size; - // Find a newline near the start to avoid cutting mid-line - let adjusted_start = output[start..] - .find('\n') - .map(|pos| start + pos + 1) - .unwrap_or(start); - format!("...\n{}", &output[adjusted_start..]) - } -} diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs @@ -1,5 +1,7 @@ mod claude; mod openai; +mod session_info; +mod tool_summary; mod traits; pub use claude::ClaudeBackend; diff --git a/crates/notedeck_dave/src/backend/session_info.rs b/crates/notedeck_dave/src/backend/session_info.rs @@ -0,0 +1,46 @@ +/// Session info parsing utilities for Claude backend responses. +use crate::messages::SessionInfo; + +/// Parse a System message into SessionInfo +pub fn parse_session_info(system_msg: &claude_agent_sdk_rs::SystemMessage) -> SessionInfo { + let data = &system_msg.data; + + // Extract slash_commands from data + let slash_commands = data + .get("slash_commands") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Extract agents from data + let agents = data + .get("agents") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Extract CLI version + let cli_version = data + .get("claude_code_version") + .and_then(|v| v.as_str()) + .map(String::from); + + SessionInfo { + tools: system_msg.tools.clone().unwrap_or_default(), + model: system_msg.model.clone(), + permission_mode: system_msg.permission_mode.clone(), + slash_commands, + agents, + cli_version, + cwd: system_msg.cwd.clone(), + claude_session_id: system_msg.session_id.clone(), + } +} diff --git a/crates/notedeck_dave/src/backend/tool_summary.rs b/crates/notedeck_dave/src/backend/tool_summary.rs @@ -0,0 +1,156 @@ +//! Formatting utilities for tool execution summaries shown in the UI. +//! +//! These functions convert raw tool inputs and outputs into human-readable +//! summary strings that are displayed to users after tool execution. + +/// Extract string content from a tool response, handling various JSON structures +pub fn extract_response_content(response: &serde_json::Value) -> Option<String> { + // Try direct string first + if let Some(s) = response.as_str() { + return Some(s.to_string()); + } + // Try "content" field (common wrapper) + if let Some(s) = response.get("content").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Try file.content for Read tool responses + if let Some(s) = response + .get("file") + .and_then(|f| f.get("content")) + .and_then(|v| v.as_str()) + { + return Some(s.to_string()); + } + // Try "output" field + if let Some(s) = response.get("output").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Try "result" field + if let Some(s) = response.get("result").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Fallback: serialize the whole response if it's not null + if !response.is_null() { + return Some(response.to_string()); + } + None +} + +/// Format a human-readable summary for tool execution results +pub fn format_tool_summary( + tool_name: &str, + input: &serde_json::Value, + response: &serde_json::Value, +) -> String { + match tool_name { + "Read" => format_read_summary(input, response), + "Write" => format_write_summary(input), + "Bash" => format_bash_summary(input, response), + "Grep" => format_grep_summary(input), + "Glob" => format_glob_summary(input), + "Edit" => format_edit_summary(input), + "Task" => format_task_summary(input), + _ => String::new(), + } +} + +fn format_read_summary(input: &serde_json::Value, response: &serde_json::Value) -> String { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + // Try to get numLines directly from file metadata (most accurate) + let lines = response + .get("file") + .and_then(|f| f.get("numLines").or_else(|| f.get("totalLines"))) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + // Fallback to counting lines in content + .or_else(|| { + extract_response_content(response) + .as_ref() + .map(|s| s.lines().count()) + }) + .unwrap_or(0); + format!("{} ({} lines)", filename, lines) +} + +fn format_write_summary(input: &serde_json::Value) -> String { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + let bytes = input + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len()) + .unwrap_or(0); + format!("{} ({} bytes)", filename, bytes) +} + +fn format_bash_summary(input: &serde_json::Value, response: &serde_json::Value) -> String { + let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); + // Truncate long commands + let cmd_display = if cmd.len() > 40 { + format!("{}...", &cmd[..37]) + } else { + cmd.to_string() + }; + let output_len = extract_response_content(response) + .as_ref() + .map(|s| s.len()) + .unwrap_or(0); + if output_len > 0 { + format!("`{}` ({} chars)", cmd_display, output_len) + } else { + format!("`{}`", cmd_display) + } +} + +fn format_grep_summary(input: &serde_json::Value) -> String { + let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("'{}'", pattern) +} + +fn format_glob_summary(input: &serde_json::Value) -> String { + let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("'{}'", pattern) +} + +fn format_edit_summary(input: &serde_json::Value) -> String { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + filename.to_string() +} + +fn format_task_summary(input: &serde_json::Value) -> String { + let description = input + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("task"); + let subagent_type = input + .get("subagent_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + format!("{} ({})", description, subagent_type) +} + +/// Truncate output to a maximum size, keeping the end (most recent) content +pub fn truncate_output(output: &str, max_size: usize) -> String { + if output.len() <= max_size { + output.to_string() + } else { + let start = output.len() - max_size; + // Find a newline near the start to avoid cutting mid-line + let adjusted_start = output[start..] + .find('\n') + .map(|pos| start + pos + 1) + .unwrap_or(start); + format!("...\n{}", &output[adjusted_start..]) + } +} diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -4,7 +4,7 @@ use std::sync::mpsc::Receiver; use crate::agent_status::AgentStatus; use crate::messages::{ - CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, + CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus, }; use crate::{DaveApiResponse, Message}; use claude_agent_sdk_rs::PermissionMode; diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,5 +1,7 @@ use super::badge::{BadgeVariant, StatusBadge}; use super::diff; +use super::query_ui::query_call_ui; +use super::top_buttons::top_buttons_ui; use crate::{ config::DaveSettings, file_update::FileUpdate, @@ -8,14 +10,12 @@ use crate::{ PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult, }, session::PermissionMessageState, - tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse}, + tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse}, }; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; -use nostrdb::{Ndb, Transaction}; -use notedeck::{ - tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext, -}; -use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic}; +use nostrdb::Transaction; +use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext}; +use notedeck_ui::{icons::search_icon, NoteOptions}; use std::collections::HashMap; use uuid::Uuid; @@ -675,7 +675,11 @@ impl<'a> DaveUi<'a> { }); } - fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) { + fn search_call_ui( + ctx: &mut AppContext, + query_call: &crate::tools::QueryCall, + ui: &mut egui::Ui, + ) { ui.add(search_icon(16.0, 16.0)); ui.add_space(8.0); @@ -926,189 +930,3 @@ impl<'a> DaveUi<'a> { }); } } - -fn settings_button(dark_mode: bool) -> impl egui::Widget { - move |ui: &mut egui::Ui| { - let img_size = 24.0; - let max_size = 32.0; - - let img = if dark_mode { - app_images::settings_dark_image() - } else { - app_images::settings_light_image() - } - .max_width(img_size); - - let helper = notedeck_ui::anim::AnimationHelper::new( - ui, - "settings-button", - egui::vec2(max_size, max_size), - ); - - let cur_img_size = helper.scale_1d_pos(img_size); - img.paint_at( - ui, - helper - .get_animation_rect() - .shrink((max_size - cur_img_size) / 2.0), - ); - - helper.take_animation_response() - } -} - -fn query_call_ui( - cache: &mut notedeck::Images, - ndb: &Ndb, - query: &QueryCall, - jobs: &MediaJobSender, - ui: &mut egui::Ui, -) { - ui.spacing_mut().item_spacing.x = 8.0; - if let Some(pubkey) = query.author() { - let txn = Transaction::new(ndb).unwrap(); - pill_label_ui( - "author", - move |ui| { - ui.add( - &mut ProfilePic::from_profile_or_default( - cache, - jobs, - ndb.get_profile_by_pubkey(&txn, pubkey.bytes()) - .ok() - .as_ref(), - ) - .size(ProfilePic::small_size() as f32), - ); - }, - ui, - ); - } - - if let Some(limit) = query.limit { - pill_label("limit", &limit.to_string(), ui); - } - - if let Some(since) = query.since { - pill_label("since", &since.to_string(), ui); - } - - if let Some(kind) = query.kind { - pill_label("kind", &kind.to_string(), ui); - } - - if let Some(until) = query.until { - pill_label("until", &until.to_string(), ui); - } - - if let Some(search) = query.search.as_ref() { - pill_label("search", search, ui); - } -} - -fn pill_label(name: &str, value: &str, ui: &mut egui::Ui) { - pill_label_ui( - name, - move |ui| { - ui.label(value); - }, - ui, - ); -} - -fn pill_label_ui(name: &str, mut value: impl FnMut(&mut egui::Ui), ui: &mut egui::Ui) { - egui::Frame::new() - .fill(ui.visuals().noninteractive().bg_fill) - .inner_margin(egui::Margin::same(4)) - .corner_radius(egui::CornerRadius::same(10)) - .stroke(egui::Stroke::new( - 1.0, - ui.visuals().noninteractive().bg_stroke.color, - )) - .show(ui, |ui| { - egui::Frame::new() - .fill(ui.visuals().noninteractive().weak_bg_fill) - .inner_margin(egui::Margin::same(4)) - .corner_radius(egui::CornerRadius::same(10)) - .stroke(egui::Stroke::new( - 1.0, - ui.visuals().noninteractive().bg_stroke.color, - )) - .show(ui, |ui| { - ui.label(name); - }); - - value(ui); - }); -} - -fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAction> { - // Scroll area for chat messages - let mut action: Option<DaveAction> = None; - let mut rect = ui.available_rect_before_wrap(); - rect = rect.translate(egui::vec2(20.0, 20.0)); - rect.set_height(32.0); - rect.set_width(32.0); - - // Show session list button on mobile/narrow screens - if notedeck::ui::is_narrow(ui.ctx()) { - let r = ui - .put(rect, egui::Button::new("\u{2630}").frame(false)) - .on_hover_text("Show chats") - .on_hover_cursor(egui::CursorIcon::PointingHand); - - if r.clicked() { - action = Some(DaveAction::ShowSessionList); - } - - rect = rect.translate(egui::vec2(30.0, 0.0)); - } - - let txn = Transaction::new(app_ctx.ndb).unwrap(); - let r = ui - .put( - rect, - &mut pfp_button( - &txn, - app_ctx.accounts, - app_ctx.img_cache, - app_ctx.ndb, - app_ctx.media_jobs.sender(), - ), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand); - - if r.clicked() { - action = Some(DaveAction::ToggleChrome); - } - - // Settings button - rect = rect.translate(egui::vec2(30.0, 0.0)); - let dark_mode = ui.visuals().dark_mode; - let r = ui - .put(rect, settings_button(dark_mode)) - .on_hover_cursor(egui::CursorIcon::PointingHand); - - if r.clicked() { - action = Some(DaveAction::OpenSettings); - } - - action -} - -fn pfp_button<'me, 'a>( - txn: &'a Transaction, - accounts: &Accounts, - img_cache: &'me mut Images, - ndb: &Ndb, - jobs: &'me MediaJobSender, -) -> ProfilePic<'me, 'a> { - let account = accounts.get_selected_account(); - let profile = ndb - .get_profile_by_pubkey(txn, account.key.pubkey.bytes()) - .ok(); - - ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()) - .size(24.0) - .sense(egui::Sense::click()) -} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -4,9 +4,12 @@ mod dave; pub mod diff; pub mod keybind_hint; pub mod keybindings; +mod pill; +mod query_ui; pub mod scene; pub mod session_list; mod settings; +mod top_buttons; pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui}; pub use dave::{DaveAction, DaveResponse, DaveUi}; diff --git a/crates/notedeck_dave/src/ui/pill.rs b/crates/notedeck_dave/src/ui/pill.rs @@ -0,0 +1,43 @@ +/// Pill-style UI components for displaying labeled values. +/// +/// Pills are compact, rounded UI elements used to display key-value pairs +/// in a visually distinct way, commonly used in query displays. +use egui::Ui; + +/// Render a pill label with a text value +pub fn pill_label(name: &str, value: &str, ui: &mut Ui) { + pill_label_ui( + name, + move |ui| { + ui.label(value); + }, + ui, + ); +} + +/// Render a pill label with a custom UI closure for the value +pub fn pill_label_ui(name: &str, mut value: impl FnMut(&mut Ui), ui: &mut Ui) { + egui::Frame::new() + .fill(ui.visuals().noninteractive().bg_fill) + .inner_margin(egui::Margin::same(4)) + .corner_radius(egui::CornerRadius::same(10)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().noninteractive().bg_stroke.color, + )) + .show(ui, |ui| { + egui::Frame::new() + .fill(ui.visuals().noninteractive().weak_bg_fill) + .inner_margin(egui::Margin::same(4)) + .corner_radius(egui::CornerRadius::same(10)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().noninteractive().bg_stroke.color, + )) + .show(ui, |ui| { + ui.label(name); + }); + + value(ui); + }); +} diff --git a/crates/notedeck_dave/src/ui/query_ui.rs b/crates/notedeck_dave/src/ui/query_ui.rs @@ -0,0 +1,59 @@ +/// UI components for displaying query call information. +/// +/// These components render the parameters of a nostr query in a visual format, +/// using pill labels to show search terms, authors, limits, and other filter criteria. +use super::pill::{pill_label, pill_label_ui}; +use crate::tools::QueryCall; +use nostrdb::{Ndb, Transaction}; +use notedeck::{Images, MediaJobSender}; +use notedeck_ui::ProfilePic; + +/// Render query call parameters as pill labels +pub fn query_call_ui( + cache: &mut Images, + ndb: &Ndb, + query: &QueryCall, + jobs: &MediaJobSender, + ui: &mut egui::Ui, +) { + ui.spacing_mut().item_spacing.x = 8.0; + if let Some(pubkey) = query.author() { + let txn = Transaction::new(ndb).unwrap(); + pill_label_ui( + "author", + move |ui| { + ui.add( + &mut ProfilePic::from_profile_or_default( + cache, + jobs, + ndb.get_profile_by_pubkey(&txn, pubkey.bytes()) + .ok() + .as_ref(), + ) + .size(ProfilePic::small_size() as f32), + ); + }, + ui, + ); + } + + if let Some(limit) = query.limit { + pill_label("limit", &limit.to_string(), ui); + } + + if let Some(since) = query.since { + pill_label("since", &since.to_string(), ui); + } + + if let Some(kind) = query.kind { + pill_label("kind", &kind.to_string(), ui); + } + + if let Some(until) = query.until { + pill_label("until", &until.to_string(), ui); + } + + if let Some(search) = query.search.as_ref() { + pill_label("search", search, ui); + } +} diff --git a/crates/notedeck_dave/src/ui/top_buttons.rs b/crates/notedeck_dave/src/ui/top_buttons.rs @@ -0,0 +1,109 @@ +/// Top buttons UI for the Dave chat interface. +/// +/// Contains the profile picture button, settings button, and session list toggle +/// that appear at the top of the chat view. +use super::DaveAction; +use nostrdb::{Ndb, Transaction}; +use notedeck::{Accounts, AppContext, Images, MediaJobSender}; +use notedeck_ui::{app_images, ProfilePic}; + +/// Render the top buttons UI (profile pic, settings, session list toggle) +pub fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAction> { + let mut action: Option<DaveAction> = None; + let mut rect = ui.available_rect_before_wrap(); + rect = rect.translate(egui::vec2(20.0, 20.0)); + rect.set_height(32.0); + rect.set_width(32.0); + + // Show session list button on mobile/narrow screens + if notedeck::ui::is_narrow(ui.ctx()) { + let r = ui + .put(rect, egui::Button::new("\u{2630}").frame(false)) + .on_hover_text("Show chats") + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if r.clicked() { + action = Some(DaveAction::ShowSessionList); + } + + rect = rect.translate(egui::vec2(30.0, 0.0)); + } + + let txn = Transaction::new(app_ctx.ndb).unwrap(); + let r = ui + .put( + rect, + &mut pfp_button( + &txn, + app_ctx.accounts, + app_ctx.img_cache, + app_ctx.ndb, + app_ctx.media_jobs.sender(), + ), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if r.clicked() { + action = Some(DaveAction::ToggleChrome); + } + + // Settings button + rect = rect.translate(egui::vec2(30.0, 0.0)); + let dark_mode = ui.visuals().dark_mode; + let r = ui + .put(rect, settings_button(dark_mode)) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if r.clicked() { + action = Some(DaveAction::OpenSettings); + } + + action +} + +fn settings_button(dark_mode: bool) -> impl egui::Widget { + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = 32.0; + + let img = if dark_mode { + app_images::settings_dark_image() + } else { + app_images::settings_light_image() + } + .max_width(img_size); + + let helper = notedeck_ui::anim::AnimationHelper::new( + ui, + "settings-button", + egui::vec2(max_size, max_size), + ); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() + } +} + +fn pfp_button<'me, 'a>( + txn: &'a Transaction, + accounts: &Accounts, + img_cache: &'me mut Images, + ndb: &Ndb, + jobs: &'me MediaJobSender, +) -> ProfilePic<'me, 'a> { + let account = accounts.get_selected_account(); + let profile = ndb + .get_profile_by_pubkey(txn, account.key.pubkey.bytes()) + .ok(); + + ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()) + .size(24.0) + .sense(egui::Sense::click()) +}