quick_search/crates/quicksearch-gui/src/app/modals.rs

650 lines
24 KiB
Rust
Raw Normal View History

//! The app's confirmation prompts, as free render functions plus the
//! `*_ui` drivers `update()` calls in its documented order.
use super::*;
use crate::ui_util::{centered_modal, hint};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum UnsavedChoice {
Apply,
Discard,
Cancel,
}
impl QuickSearchApp {
pub(super) fn rebuild_prompt_ui(&mut self, ctx: &egui::Context) {
let Some(changes) = &self.rebuild_prompt else {
return;
};
let changes = changes.clone();
let mut close = false;
// Not `centered_modal`: the columns below need `default_width`.
egui::Window::new("Rebuild index?")
.collapsible(false)
.resizable(false)
.default_width(560.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label("These settings differ from what the index was built with:");
ui.add_space(4.0);
if changes.is_empty() {
ui.monospace("the index cannot be read with the new settings");
}
for change in &changes {
ui.strong(format!("{}:", change.key));
ui.columns(2, |cols| {
cols[0].label(hint("index was built with"));
cols[0].monospace(display_value(&change.stored));
cols[1].label(hint("config now says"));
cols[1].monospace(display_value(&change.current));
});
ui.add_space(6.0);
}
ui.label(hint(
"Unlike folders, filters and hidden files — which are applied \
to the existing index in place — these cannot be, so the \
index has to be built again. Until it is, existing entries \
keep the old settings.",
));
ui.horizontal(|ui| {
if ui.button("Rebuild now").clicked() {
self.backend.rebuild_index();
close = true;
}
if ui.button("Later").clicked() {
close = true;
}
});
});
if close {
self.rebuild_prompt = None;
}
}
pub(super) fn nested_prompt_ui(&mut self, ctx: &egui::Context) {
let Some(pairs) = &self.nested_prompt else {
return;
};
let pairs = pairs.clone();
let close = centered_modal(ctx, "Indexed folders may not be nested", |ui| {
ui.label(
"Each root is indexed by its own worker pool, so one root \
may not contain another. Fix the folder list below:",
);
for (child, parent) in &pairs {
ui.monospace(format!("{} ⊂ {}", child, parent));
}
ui.label(hint(
"Indexing stays paused until the overlap is removed and \
the list is applied.",
));
ui.button("Fix folders").clicked()
});
if close == Some(true) {
self.nested_prompt = None;
self.switch_tab(ctx, Tab::Manage);
}
}
/// The button starts the rebuild rather than merely dismissing — in
/// manual mode nothing else would.
pub(super) fn stale_index_prompt_ui(&mut self, ctx: &egui::Context) {
if !self.stale_index_prompt {
return;
}
if stale_index_window(ctx, self.key_source) {
self.stale_index_prompt = false;
self.backend.rebuild_index();
self.dups.state = DupState::NotLoaded;
}
}
/// Work still owed means a pass was abandoned or the config was edited
/// while the app was closed. Hidden while a run or reconcile is going.
pub(super) fn reconcile_owed_ui(&mut self, ctx: &egui::Context) {
if !self.reconcile_owed {
return;
}
let state = self.backend.coordinator.state();
// A completed run stamps this; a run the user stops does not.
if state.last_full_index > self.reconcile_owed_since {
self.reconcile_owed = false;
return;
}
if !matches!(
state.activity,
IndexingStatus::Idle | IndexingStatus::Error(_)
) || state.reconcile.is_some()
{
return;
}
match reconcile_owed_banner(ctx) {
None => {}
Some(ReconcileOwedChoice::StartIndexing) => {
self.backend.coordinator.reindex_now();
// The run that finishes clears it; one stopped half-way
// leaves the reminder standing.
ctx.request_repaint_after(Duration::from_millis(100));
}
Some(ReconcileOwedChoice::Dismiss) => self.reconcile_owed = false,
}
}
pub(super) fn watch_cap_prompt_ui(&mut self, ctx: &egui::Context) {
let Some(reason) = &self.watch_cap_prompt else {
return;
};
let reason = reason.clone();
let close = centered_modal(ctx, "Live index updating is disabled", |ui| {
ui.set_max_width(420.0);
match &reason {
WatchError::TooManyDirectories { cap, .. } => {
ui.label(format!(
"Your indexed folders contain more than {} directories. The \
system limits how many folders can be watched for changes at \
once, so QuickSearch cannot update the index as files change.",
group_thousands(*cap as u64),
));
}
WatchError::KernelLimit { registered } => {
ui.label(format!(
"The system ran out of folder watches after {} directories, so \
QuickSearch cannot update the index as files change.",
group_thousands(*registered as u64),
));
}
// Not reachable today: an overflow schedules a full run
// instead of disabling live updates. Spelled out so a future
// variant is a compile error here.
WatchError::Overflowed => {
ui.label(
"The system dropped some change notifications, so the index \
is being rebuilt to catch up.",
);
}
WatchError::Other(msg) => {
ui.label(format!("Live updates are unavailable: {}", msg));
}
}
ui.add_space(4.0);
ui.label(format!(
"The index is rebuilt every {} instead. Searches keep working; \
recent changes may take that long to appear.",
fmt_interval(self.cfg.indexing.reindex_interval_minutes),
));
ui.label(hint(
"To restore live updates, index fewer folders or exclude large \
subfolders under Filters on the Manage Index tab.",
));
ui.button("OK").clicked()
});
if close == Some(true) {
self.watch_cap_prompt = None;
for root in &self.cfg.paths.indexing_paths {
if !self.cfg.ui.watch_cap_warned_roots.contains(root) {
self.cfg.ui.watch_cap_warned_roots.push(root.clone());
}
}
self.save_cfg();
}
}
/// Dismissal is written straight to the config, not the Settings draft.
pub(super) fn tutorial_ui(&mut self, ctx: &egui::Context) {
let Some(tour) = &mut self.tutorial else {
return;
};
let roots = self.cfg.paths.indexing_paths.clone();
let actions = tour.ui(ctx, &roots);
// Through the same path a keystroke takes: `seed` arms the debounce,
// so the demonstration search runs once the typing stops.
if let Some(query) = actions.set_query {
self.search.seed(query);
}
if actions.focus_search {
self.search.request_focus();
}
// Through the guard, so a dirty draft still gets its say.
if let Some(tab) = actions.goto_tab {
self.request_tab(ctx, tab);
}
if actions.dismissed {
self.tutorial = None;
self.cfg.ui.tutorial_seen = Some(true);
self.save_cfg();
}
}
/// Re-open the tour from the Help tab.
pub(crate) fn show_tutorial(&mut self) {
self.tutorial = Some(crate::tutorial::Tutorial::new());
}
pub(super) fn clear_prompt_ui(&mut self, ctx: &egui::Context) {
if !self.clear_prompt {
return;
}
let close = centered_modal(ctx, "Clear index?", |ui| {
ui.label("This deletes the search index database. Your files are not touched.");
ui.label(hint(
"Indexing switches to manual until you start it again or return \
to automatic mode.",
));
ui.horizontal(|ui| {
let delete = egui::RichText::new("Delete index").color(ui.visuals().error_fg_color);
if ui.button(delete).clicked() {
// Manual first, and persisted: automatic mode must not
// resurrect what was just deleted.
self.set_index_mode(false);
self.backend.clear_index();
self.dups.state = DupState::NotLoaded;
return true;
}
ui.button("Cancel").clicked()
})
.inner
});
if close == Some(true) {
self.clear_prompt = false;
}
}
/// Each frame the pending intent picks the editor to ask about; Apply
/// and Discard clean one editor and the next frame moves on. Cancel —
/// button, Esc, or backdrop click — drops the intent.
pub(super) fn unsaved_prompt_ui(&mut self, ctx: &egui::Context) {
let Some(intent) = self.pending_nav else {
return;
};
let dirty = (self.manage.is_dirty(), self.settings.is_dirty(&self.cfg));
let Some(source) = guard_source(intent, self.tab, dirty.0, dirty.1) else {
// Inside the guard: the Discard-then-quit path never returns to
// the close-request check, so a warning only there is skipped.
if quit_needs_reconcile_warning(intent, self.backend.coordinator.reconciling()) {
ctx.request_repaint_after(Duration::from_millis(250));
match reconcile_quit_modal(ctx) {
None => return,
Some(false) => {
self.pending_nav = None;
return;
}
Some(true) => {}
}
}
return self.complete_nav(ctx, intent);
};
match unsaved_changes_modal(ctx, source) {
None => {}
Some(UnsavedChoice::Cancel) => self.pending_nav = None,
Some(UnsavedChoice::Discard) => match source {
UnsavedSource::Manage => self.manage.discard(),
UnsavedSource::Settings => self.settings.discard(),
},
Some(UnsavedChoice::Apply) => {
let ok = match source {
UnsavedSource::Manage => match self.manage.take_apply_config(&self.cfg) {
Some(cfg) => {
let ok = self.apply_new_config(ctx, cfg);
if ok {
self.manage.mark_applied();
}
ok
}
None => true,
},
UnsavedSource::Settings => match self.settings.draft_config() {
Some(cfg) => {
let ok = self.apply_new_config(ctx, cfg);
if ok {
self.settings.discard();
}
ok
}
None => true,
},
};
if !ok {
// Rejected: stay put, keep the staged edits.
self.pending_nav = None;
}
}
}
}
pub(super) fn complete_nav(&mut self, ctx: &egui::Context, intent: NavIntent) {
self.pending_nav = None;
match intent {
NavIntent::SwitchTab(tab) => self.switch_tab(ctx, tab),
NavIntent::Quit => {
self.quit_confirmed = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
}
/// Esc and a backdrop click count as Cancel. `egui::Modal`, not the usual
/// centered window: its backdrop blocks input, and a click landing on the
/// tab strip would re-trigger or bypass the guard.
fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option<UnsavedChoice> {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("unsaved-guard")).show(ctx, |ui| {
ui.set_max_width(420.0);
ui.heading("Unsaved changes");
ui.label(match source {
UnsavedSource::Manage => "The Manage Index tab has edits that have not been applied.",
UnsavedSource::Settings => "The Settings tab has edits that have not been applied.",
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui
.add(crate::ui_util::bordered_button(
"Apply & Save",
palette(ui.visuals().dark_mode).blue,
))
.clicked()
{
choice = Some(UnsavedChoice::Apply);
}
if ui
.button(egui::RichText::new("Discard changes").color(ui.visuals().error_fg_color))
.clicked()
{
choice = Some(UnsavedChoice::Discard);
}
if ui.button("Cancel").clicked() {
choice = Some(UnsavedChoice::Cancel);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(UnsavedChoice::Cancel);
}
choice
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ReconcileOwedChoice {
StartIndexing,
Dismiss,
}
fn reconcile_owed_banner(ctx: &egui::Context) -> Option<ReconcileOwedChoice> {
let mut choice = None;
egui::TopBottomPanel::top("reconcile-owed").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.colored_label(
ui.visuals().warn_fg_color,
"Your indexing settings have not been applied to the index yet.",
);
if ui.small_button("Start indexing now").clicked() {
choice = Some(ReconcileOwedChoice::StartIndexing);
}
if ui.small_button("Dismiss").clicked() {
choice = Some(ReconcileOwedChoice::Dismiss);
}
});
});
choice
}
/// `Some(true)` to quit anyway; Esc and a backdrop click count as staying.
fn reconcile_quit_modal(ctx: &egui::Context) -> Option<bool> {
let mut choice = None;
let modal = egui::Modal::new(egui::Id::new("reconcile-quit-guard")).show(ctx, |ui| {
ui.set_max_width(460.0);
ui.heading("Settings are still being applied");
ui.label(
"QuickSearch is still applying your indexing settings to the index. If you \
quit now it stops part-way, and entries you excluded can still turn up in \
search results.",
);
ui.add_space(4.0);
ui.label(
"Nothing is lost: the next indexing run picks the work up again. In manual \
mode, choose \"Start indexing now\" on the Manage Index tab after the next \
launch.",
);
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui
.button(egui::RichText::new("Quit anyway").color(ui.visuals().error_fg_color))
.clicked()
{
choice = Some(true);
}
if ui
.add(crate::ui_util::bordered_button(
"Cancel",
palette(ui.visuals().dark_mode).blue,
))
.clicked()
{
choice = Some(false);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
choice
}
fn stale_index_window(ctx: &egui::Context, key_source: KeySource) -> bool {
centered_modal(ctx, "Index reset for this version", |ui| {
ui.set_max_width(440.0);
ui.label(
"Your search index was created by an older version of QuickSearch, \
which this version cannot read. It is being reset and rebuilt from \
scratch. Don't worry, this is very Quick!",
);
let reassurance = match key_source {
KeySource::Unprotected => None,
KeySource::Prompt => Some(
"The rebuilt index is encrypted with the password you just \
entered, so it stays password protected.",
),
KeySource::Keychain => Some(
"The rebuilt index is encrypted with the key remembered on \
this device, so it stays password protected.",
),
};
if let Some(text) = reassurance {
ui.add_space(4.0);
ui.label(text);
}
ui.add_space(4.0);
ui.label(hint(
"Your files are not touched. Searches return incomplete results \
until the rebuild finishes; progress is on the Manage Index tab.",
));
ui.add_space(4.0);
ui.button("Rebuild now").clicked()
})
.unwrap_or(false)
}
fn display_value(value: &str) -> String {
if value.trim().is_empty() {
"(none)".to_string()
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(ctx: &egui::Context, source: KeySource, events: Vec<egui::Event>) -> bool {
let input = crate::test_ui::raw_input(SCREEN, events);
let mut clicked = false;
let _ = ctx.run(input, |ctx| clicked = stale_index_window(ctx, source));
clicked
}
use crate::test_ui::click_at;
const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
#[test]
fn the_stale_index_modal_renders_and_its_button_fires() {
for source in [
KeySource::Unprotected,
KeySource::Prompt,
KeySource::Keychain,
] {
let ctx = crate::test_ui::ctx();
assert!(
!frame(&ctx, source, Vec::new()),
"an untouched frame must not request a rebuild"
);
// The window's height depends on which sentence is shown; sweep.
let mut fired = None;
'sweep: for y in (230..480).step_by(3) {
for x in (250..760).step_by(6) {
let pos = egui::pos2(x as f32, y as f32);
if frame(&ctx, source, click_at(pos)) {
fired = Some(pos);
break 'sweep;
}
}
}
assert!(
fired.is_some(),
"no clickable Rebuild button for {source:?}"
);
}
}
fn modal_frame(
ctx: &egui::Context,
source: UnsavedSource,
events: Vec<egui::Event>,
) -> Option<UnsavedChoice> {
let input = crate::test_ui::raw_input(SCREEN, events);
let mut choice = None;
let _ = ctx.run(input, |ctx| choice = unsaved_changes_modal(ctx, source));
choice
}
#[test]
fn the_unsaved_modal_reports_each_choice() {
for source in [UnsavedSource::Manage, UnsavedSource::Settings] {
let ctx = crate::test_ui::ctx();
assert_eq!(
modal_frame(&ctx, source, Vec::new()),
None,
"an untouched frame must not decide"
);
let mut seen = std::collections::HashSet::new();
for y in (250..450).step_by(3) {
for x in (250..760).step_by(6) {
let pos = egui::pos2(x as f32, y as f32);
if let Some(choice) = modal_frame(&ctx, source, click_at(pos)) {
seen.insert(choice);
}
}
}
for expected in [
UnsavedChoice::Apply,
UnsavedChoice::Discard,
UnsavedChoice::Cancel,
] {
assert!(
seen.contains(&expected),
"{expected:?} never fired ({source:?})"
);
}
let esc = modal_frame(
&ctx,
source,
vec![egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
);
assert_eq!(esc, Some(UnsavedChoice::Cancel), "Esc must cancel");
}
}
fn reconcile_modal_frame(ctx: &egui::Context, events: Vec<egui::Event>) -> Option<bool> {
let input = crate::test_ui::raw_input(SCREEN, events);
let mut choice = None;
let _ = ctx.run(input, |ctx| choice = reconcile_quit_modal(ctx));
choice
}
#[test]
fn the_quit_warning_reports_both_answers() {
let ctx = crate::test_ui::ctx();
assert_eq!(
reconcile_modal_frame(&ctx, Vec::new()),
None,
"an untouched frame must not decide"
);
let mut seen = std::collections::HashSet::new();
for y in (250..450).step_by(3) {
for x in (250..760).step_by(6) {
if let Some(choice) =
reconcile_modal_frame(&ctx, click_at(egui::pos2(x as f32, y as f32)))
{
seen.insert(choice);
}
}
}
assert!(seen.contains(&true), "\"Quit anyway\" never fired");
assert!(seen.contains(&false), "Cancel never fired");
let esc = reconcile_modal_frame(
&ctx,
vec![egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
);
assert_eq!(esc, Some(false), "Esc must keep the app open");
}
fn banner_frame(ctx: &egui::Context, events: Vec<egui::Event>) -> Option<ReconcileOwedChoice> {
let input = crate::test_ui::raw_input(SCREEN, events);
let mut choice = None;
let _ = ctx.run(input, |ctx| choice = reconcile_owed_banner(ctx));
choice
}
#[test]
fn the_reconcile_banner_reports_both_buttons() {
let ctx = crate::test_ui::ctx();
assert_eq!(banner_frame(&ctx, Vec::new()), None);
let mut seen = std::collections::HashSet::new();
for y in (0..60).step_by(2) {
for x in (0..1000).step_by(4) {
if let Some(choice) = banner_frame(&ctx, click_at(egui::pos2(x as f32, y as f32))) {
seen.insert(choice);
}
}
}
assert!(
seen.contains(&ReconcileOwedChoice::StartIndexing),
"\"Start indexing now\" never fired"
);
assert!(
seen.contains(&ReconcileOwedChoice::Dismiss),
"Dismiss never fired"
);
}
}