notedeck

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

commit 4d1ccd6165dacd7aa7d1a4e6e254e0096986cdc9
parent 1bdf6fa5fca1817fa604a1202ff2279a302db971
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 28 Jan 2026 21:59:21 -0800

refactor(dave): replace egui::Window modals with full-panel overlays

Settings and directory picker now take over the main UI area instead
of floating as popup windows. This is more responsive on mobile with
larger touch targets, full-width buttons, and appropriate padding.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_dave/src/ui/directory_picker.rs | 257++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/notedeck_dave/src/ui/settings.rs | 268+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
3 files changed, 382 insertions(+), 250 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -45,6 +45,15 @@ pub use ui::{ }; pub use vec3::Vec3; +/// Represents which full-screen overlay (if any) is currently active +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DaveOverlay { + #[default] + None, + Settings, + DirectoryPicker, +} + pub struct Dave { /// Manages multiple chat sessions session_manager: SessionManager, @@ -76,6 +85,8 @@ pub struct Dave { home_session: Option<SessionId>, /// Directory picker for selecting working directory when creating sessions directory_picker: DirectoryPicker, + /// Current overlay taking over the UI (if any) + active_overlay: DaveOverlay, } /// Calculate an anonymous user_id from a keypair @@ -148,9 +159,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let settings = DaveSettings::from_model_config(&model_config); - let mut directory_picker = DirectoryPicker::new(); - // Auto-open the picker on startup since there are no sessions - directory_picker.open(); + let directory_picker = DirectoryPicker::new(); Dave { backend, @@ -168,6 +177,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr auto_steal_focus: false, home_session: None, directory_picker, + // Auto-show directory picker on startup since there are no sessions + active_overlay: DaveOverlay::DirectoryPicker, } } @@ -367,6 +378,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + // Check overlays first - they take over the entire UI + match self.active_overlay { + DaveOverlay::Settings => return self.settings_overlay_ui(app_ctx, ui), + DaveOverlay::DirectoryPicker => return self.directory_picker_overlay_ui(app_ctx, ui), + DaveOverlay::None => {} + } + + // Normal routing if is_narrow(ui.ctx()) { self.narrow_ui(app_ctx, ui) } else if self.show_scene { @@ -376,6 +395,54 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Full-screen settings overlay + fn settings_overlay_ui( + &mut self, + _app_ctx: &mut AppContext, + ui: &mut egui::Ui, + ) -> DaveResponse { + if let Some(action) = self.settings_panel.overlay_ui(ui, &self.settings) { + match action { + SettingsPanelAction::Save(new_settings) => { + self.apply_settings(new_settings.clone()); + self.active_overlay = DaveOverlay::None; + return DaveResponse::new(DaveAction::UpdateSettings(new_settings)); + } + SettingsPanelAction::Cancel => { + self.active_overlay = DaveOverlay::None; + } + } + } + DaveResponse::default() + } + + /// Full-screen directory picker overlay + fn directory_picker_overlay_ui( + &mut self, + _app_ctx: &mut AppContext, + ui: &mut egui::Ui, + ) -> DaveResponse { + let has_sessions = !self.session_manager.is_empty(); + if let Some(action) = self.directory_picker.overlay_ui(ui, has_sessions) { + match action { + DirectoryPickerAction::DirectorySelected(path) => { + self.create_session_with_cwd(path); + self.active_overlay = DaveOverlay::None; + } + DirectoryPickerAction::Cancelled => { + // Only close if there are existing sessions to fall back to + if has_sessions { + self.active_overlay = DaveOverlay::None; + } + } + DirectoryPickerAction::BrowseRequested => { + // Handled internally by the picker + } + } + } + DaveResponse::default() + } + /// Scene view with RTS-style agent visualization and chat side panel fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { use egui_extras::{Size, StripBuilder}; @@ -666,8 +733,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } fn handle_new_chat(&mut self) { - // Open the directory picker instead of creating session directly - self.directory_picker.open(); + // Show the directory picker overlay + self.active_overlay = DaveOverlay::DirectoryPicker; } /// Create a new session with the given cwd (called after directory picker selection) @@ -1366,34 +1433,6 @@ impl notedeck::App for Dave { } } - // Render settings panel and handle its actions - if let Some(settings_action) = self.settings_panel.ui(ui.ctx()) { - match settings_action { - SettingsPanelAction::Save(new_settings) => { - self.apply_settings(new_settings.clone()); - dave_action = Some(DaveAction::UpdateSettings(new_settings)); - } - SettingsPanelAction::Cancel => { - // Panel closed, nothing to do - } - } - } - - // Render directory picker and handle its actions - if let Some(picker_action) = self.directory_picker.ui(ui.ctx()) { - match picker_action { - DirectoryPickerAction::DirectorySelected(path) => { - self.create_session_with_cwd(path); - } - DirectoryPickerAction::Cancelled => { - // Picker closed without selection, nothing to do - } - DirectoryPickerAction::BrowseRequested => { - // Handled internally by the picker - } - } - } - // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); let has_pending_question = self.has_pending_question(); @@ -1589,7 +1628,7 @@ impl notedeck::App for Dave { self.show_session_list = !self.show_session_list; } DaveAction::OpenSettings => { - self.settings_panel.open(&self.settings); + self.active_overlay = DaveOverlay::Settings; } DaveAction::UpdateSettings(settings) => { dave_action = Some(DaveAction::UpdateSettings(settings)); diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs @@ -1,4 +1,4 @@ -use egui::{Align, Color32, Layout, RichText, Vec2}; +use egui::{RichText, Vec2}; use std::path::PathBuf; /// Maximum number of recent directories to store @@ -88,127 +88,180 @@ impl DirectoryPicker { None } - /// Render the directory picker UI - /// Returns an action if one was triggered - pub fn ui(&mut self, ctx: &egui::Context) -> Option<DirectoryPickerAction> { - if !self.is_open { - return None; - } - + /// Render the directory picker as a full-panel overlay + /// `has_sessions` indicates whether there are existing sessions (enables cancel) + pub fn overlay_ui( + &mut self, + ui: &mut egui::Ui, + has_sessions: bool, + ) -> Option<DirectoryPickerAction> { // Check for pending folder pick result first if let Some(path) = self.check_pending_pick() { - self.close(); return Some(DirectoryPickerAction::DirectorySelected(path)); } let mut action = None; + let is_narrow = notedeck::ui::is_narrow(ui.ctx()); - egui::Window::new("Select Working Directory") - .collapsible(false) - .resizable(true) - .default_width(400.0) - .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) - .show(ctx, |ui| { - ui.add_space(8.0); - - // Recent directories section - if !self.recent_directories.is_empty() { - ui.label(RichText::new("Recent Directories").strong()); - ui.add_space(4.0); - - egui::ScrollArea::vertical() - .max_height(200.0) - .show(ui, |ui| { - for path in &self.recent_directories.clone() { - let display = abbreviate_path(path); - let response = ui.add( - egui::Button::new(RichText::new(&display).monospace()) - .min_size(Vec2::new(ui.available_width(), 28.0)) - .fill(Color32::TRANSPARENT), - ); - - if response - .on_hover_text(path.display().to_string()) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - action = Some(DirectoryPickerAction::DirectorySelected( - path.clone(), - )); - } - } - }); - - ui.add_space(12.0); - ui.separator(); - ui.add_space(8.0); - } - - // Browse button + // Full panel frame + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) + .show(ui, |ui| { + // Header ui.horizontal(|ui| { - if ui - .button(RichText::new("Browse...").size(14.0)) - .on_hover_text("Open folder picker dialog") - .clicked() - { - // Spawn async folder picker - let (tx, rx) = std::sync::mpsc::channel(); - let ctx_clone = ctx.clone(); - std::thread::spawn(move || { - let result = rfd::FileDialog::new().pick_folder(); - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - self.pending_folder_pick = Some(rx); - } - - if self.pending_folder_pick.is_some() { - ui.spinner(); - ui.label("Opening dialog..."); + // Only show back button if there are existing sessions + if has_sessions { + if ui.button("< Back").clicked() { + action = Some(DirectoryPickerAction::Cancelled); + } + ui.add_space(16.0); } + ui.heading("Select Working Directory"); }); - ui.add_space(8.0); + ui.add_space(16.0); - // Manual path input - ui.label("Or enter path:"); - ui.horizontal(|ui| { - let response = ui.add( - egui::TextEdit::singleline(&mut self.path_input) - .hint_text("/path/to/project") - .desired_width(ui.available_width() - 50.0), - ); - - if ui.button("Go").clicked() - || response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) - { - let path = PathBuf::from(&self.path_input); - if path.exists() && path.is_dir() { - action = Some(DirectoryPickerAction::DirectorySelected(path)); + // Centered content (max width for desktop) + let max_content_width = if is_narrow { + ui.available_width() + } else { + 500.0 + }; + let available_height = ui.available_height(); + + ui.allocate_ui_with_layout( + egui::vec2(max_content_width, available_height), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + // Recent directories section + if !self.recent_directories.is_empty() { + ui.label(RichText::new("Recent Directories").strong()); + ui.add_space(8.0); + + // Use more vertical space on mobile + let scroll_height = if is_narrow { + (ui.available_height() - 150.0).max(100.0) + } else { + 300.0 + }; + + egui::ScrollArea::vertical() + .max_height(scroll_height) + .show(ui, |ui| { + for path in &self.recent_directories.clone() { + let display = abbreviate_path(path); + + // Full-width button style with larger touch targets on mobile + let button_height = if is_narrow { 44.0 } else { 32.0 }; + let button = + egui::Button::new(RichText::new(&display).monospace()) + .min_size(Vec2::new( + ui.available_width(), + button_height, + )) + .fill(ui.visuals().widgets.inactive.weak_bg_fill); + + if ui + .add(button) + .on_hover_text(path.display().to_string()) + .clicked() + { + action = + Some(DirectoryPickerAction::DirectorySelected( + path.clone(), + )); + } + + ui.add_space(4.0); + } + }); + + ui.add_space(16.0); + ui.separator(); + ui.add_space(12.0); } - } - }); - ui.add_space(12.0); + // Browse button (larger touch target on mobile) + let browse_button = + egui::Button::new(RichText::new("Browse...").size(if is_narrow { + 16.0 + } else { + 14.0 + })) + .min_size(Vec2::new( + if is_narrow { + ui.available_width() + } else { + 120.0 + }, + if is_narrow { 48.0 } else { 32.0 }, + )); - // Cancel button - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Cancel").clicked() { - action = Some(DirectoryPickerAction::Cancelled); - } - }); + if ui + .add(browse_button) + .on_hover_text("Open folder picker dialog") + .clicked() + { + // Spawn async folder picker + let (tx, rx) = std::sync::mpsc::channel(); + let ctx_clone = ui.ctx().clone(); + std::thread::spawn(move || { + let result = rfd::FileDialog::new().pick_folder(); + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + self.pending_folder_pick = Some(rx); + } + + if self.pending_folder_pick.is_some() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Opening dialog..."); + }); + } + + ui.add_space(16.0); + + // Manual path input + ui.label("Or enter path:"); + ui.add_space(4.0); + + let response = ui.add( + egui::TextEdit::singleline(&mut self.path_input) + .hint_text("/path/to/project") + .desired_width(ui.available_width()), + ); + + ui.add_space(8.0); + + let go_button = egui::Button::new("Go").min_size(Vec2::new( + if is_narrow { + ui.available_width() + } else { + 50.0 + }, + if is_narrow { 44.0 } else { 28.0 }, + )); + + if ui.add(go_button).clicked() + || response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + let path = PathBuf::from(&self.path_input); + if path.exists() && path.is_dir() { + action = Some(DirectoryPickerAction::DirectorySelected(path)); + } + } + }, + ); }); - // Handle Escape key to cancel - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + // Handle Escape key (only if cancellation is allowed) + if has_sessions && ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) { action = Some(DirectoryPickerAction::Cancelled); } - // Close picker if action taken - if action.is_some() { - self.close(); - } - action } } diff --git a/crates/notedeck_dave/src/ui/settings.rs b/crates/notedeck_dave/src/ui/settings.rs @@ -57,137 +57,177 @@ impl DaveSettingsPanel { self.open = false; } - /// Render the settings panel UI - pub fn ui(&mut self, ctx: &egui::Context) -> Option<SettingsPanelAction> { - let mut action: Option<SettingsPanelAction> = None; - + /// Prepare editing state for overlay mode + pub fn prepare_edit(&mut self, current: &DaveSettings) { if !self.open { - return None; + self.editing = current.clone(); + self.custom_model = current.model.clone(); + self.use_custom_model = !current + .provider + .available_models() + .contains(&current.model.as_str()); + self.open = true; } + } - let mut open = self.open; - egui::Window::new("Dave Settings") - .open(&mut open) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.set_min_width(300.0); - - egui::Grid::new("settings_grid") - .num_columns(2) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - // Provider dropdown - ui.label("Provider:"); - let prev_provider = self.editing.provider; - egui::ComboBox::from_id_salt("provider_combo") - .selected_text(self.editing.provider.name()) - .show_ui(ui, |ui| { - for provider in AiProvider::ALL { - ui.selectable_value( - &mut self.editing.provider, - provider, - provider.name(), - ); - } - }); - ui.end_row(); - - // If provider changed, reset to provider defaults - if self.editing.provider != prev_provider { - self.editing.model = self.editing.provider.default_model().to_string(); - self.editing.endpoint = self - .editing - .provider - .default_endpoint() - .map(|s| s.to_string()); - self.custom_model = self.editing.model.clone(); - self.use_custom_model = false; - } - - // Model selection - ui.label("Model:"); - ui.vertical(|ui| { - // Checkbox for custom model - ui.checkbox(&mut self.use_custom_model, "Custom model"); - - if self.use_custom_model { - // Custom text input - let response = ui.text_edit_singleline(&mut self.custom_model); - if response.changed() { - self.editing.model = self.custom_model.clone(); - } - } else { - // Dropdown with available models - egui::ComboBox::from_id_salt("model_combo") - .selected_text(&self.editing.model) - .show_ui(ui, |ui| { - for model in self.editing.provider.available_models() { - ui.selectable_value( - &mut self.editing.model, - model.to_string(), - *model, - ); - } - }); - } - }); - ui.end_row(); - - // Endpoint field - ui.label("Endpoint:"); - let mut endpoint_str = self.editing.endpoint.clone().unwrap_or_default(); - if ui.text_edit_singleline(&mut endpoint_str).changed() { - self.editing.endpoint = if endpoint_str.is_empty() { - None - } else { - Some(endpoint_str) - }; - } - ui.end_row(); - - // API Key field (only shown when required) - if self.editing.provider.requires_api_key() { - ui.label("API Key:"); - let mut key_str = self.editing.api_key.clone().unwrap_or_default(); - if ui - .add(egui::TextEdit::singleline(&mut key_str).password(true)) - .changed() - { - self.editing.api_key = if key_str.is_empty() { - None - } else { - Some(key_str) - }; - } - ui.end_row(); - } - }); - - ui.add_space(16.0); + /// Render settings as a full-panel overlay (replaces the main content) + pub fn overlay_ui( + &mut self, + ui: &mut egui::Ui, + current: &DaveSettings, + ) -> Option<SettingsPanelAction> { + // Initialize editing state if not already set + self.prepare_edit(current); - // Save/Cancel buttons + let mut action: Option<SettingsPanelAction> = None; + let is_narrow = notedeck::ui::is_narrow(ui.ctx()); + + // Full panel frame with padding + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) + .show(ui, |ui| { + // Header with back button ui.horizontal(|ui| { - if ui.button("Save").clicked() { - action = Some(SettingsPanelAction::Save(self.editing.clone())); - } - if ui.button("Cancel").clicked() { + if ui.button("< Back").clicked() { action = Some(SettingsPanelAction::Cancel); } + ui.add_space(16.0); + ui.heading("Settings"); }); + + ui.add_space(24.0); + + // Centered content container (max width for readability on desktop) + let max_content_width = if is_narrow { + ui.available_width() + } else { + 500.0 + }; + ui.allocate_ui_with_layout( + egui::vec2(max_content_width, ui.available_height()), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + self.settings_form(ui); + + ui.add_space(24.0); + + // Action buttons + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + action = Some(SettingsPanelAction::Save(self.editing.clone())); + } + if ui.button("Cancel").clicked() { + action = Some(SettingsPanelAction::Cancel); + } + }); + }, + ); }); - // Handle window close button - if !open { + // Handle Escape key + if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) { action = Some(SettingsPanelAction::Cancel); } - // Close panel if we have an action if action.is_some() { self.close(); } action } + + /// Render the settings form content (shared between overlay and window modes) + fn settings_form(&mut self, ui: &mut egui::Ui) { + egui::Grid::new("settings_grid") + .num_columns(2) + .spacing([10.0, 12.0]) + .show(ui, |ui| { + // Provider dropdown + ui.label("Provider:"); + let prev_provider = self.editing.provider; + egui::ComboBox::from_id_salt("provider_combo") + .selected_text(self.editing.provider.name()) + .show_ui(ui, |ui| { + for provider in AiProvider::ALL { + ui.selectable_value( + &mut self.editing.provider, + provider, + provider.name(), + ); + } + }); + ui.end_row(); + + // If provider changed, reset to provider defaults + if self.editing.provider != prev_provider { + self.editing.model = self.editing.provider.default_model().to_string(); + self.editing.endpoint = self + .editing + .provider + .default_endpoint() + .map(|s| s.to_string()); + self.custom_model = self.editing.model.clone(); + self.use_custom_model = false; + } + + // Model selection + ui.label("Model:"); + ui.vertical(|ui| { + // Checkbox for custom model + ui.checkbox(&mut self.use_custom_model, "Custom model"); + + if self.use_custom_model { + // Custom text input + let response = ui.text_edit_singleline(&mut self.custom_model); + if response.changed() { + self.editing.model = self.custom_model.clone(); + } + } else { + // Dropdown with available models + egui::ComboBox::from_id_salt("model_combo") + .selected_text(&self.editing.model) + .show_ui(ui, |ui| { + for model in self.editing.provider.available_models() { + ui.selectable_value( + &mut self.editing.model, + model.to_string(), + *model, + ); + } + }); + } + }); + ui.end_row(); + + // Endpoint field + ui.label("Endpoint:"); + let mut endpoint_str = self.editing.endpoint.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut endpoint_str).changed() { + self.editing.endpoint = if endpoint_str.is_empty() { + None + } else { + Some(endpoint_str) + }; + } + ui.end_row(); + + // API Key field (only shown when required) + if self.editing.provider.requires_api_key() { + ui.label("API Key:"); + let mut key_str = self.editing.api_key.clone().unwrap_or_default(); + if ui + .add(egui::TextEdit::singleline(&mut key_str).password(true)) + .changed() + { + self.editing.api_key = if key_str.is_empty() { + None + } else { + Some(key_str) + }; + } + ui.end_row(); + } + }); + } }