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:
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(¤t.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();
+ }
+ });
+ }
}