2026-08-23 00:33:53 -04:00
|
|
|
//! The Settings tab. Edits happen on a draft; Apply validates, saves, and
|
|
|
|
|
//! hands the new config to the app.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
use crate::keychain;
|
2026-08-05 18:05:04 -04:00
|
|
|
use crate::tips::{self, tip_row, Tipped};
|
2026-08-09 16:25:43 -04:00
|
|
|
use crate::ui_util::hint;
|
2026-08-17 19:26:18 -04:00
|
|
|
use quicksearch_core::config::{ColumnsConfig, Config};
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// Who a row is for.
|
|
|
|
|
///
|
|
|
|
|
/// [`Level::Advanced`] means one of two things, and usually both: a person who
|
|
|
|
|
/// indexed their home folder and nothing else will never need to change it, or
|
|
|
|
|
/// they could not tell what it does without already knowing how the indexer
|
|
|
|
|
/// works. A byte budget over the writer's batching is both. Password
|
|
|
|
|
/// protection is neither, however technical it sounds.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub(crate) enum Level {
|
|
|
|
|
Everyday,
|
|
|
|
|
Advanced,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The settings form's render context: which rows are on screen.
|
|
|
|
|
///
|
|
|
|
|
/// Every row goes through [`Form`], which is what keeps the two lists from
|
|
|
|
|
/// drifting — a setting cannot be added to the tab without saying who it is
|
|
|
|
|
/// for, and it cannot be shown without a tooltip either, because
|
|
|
|
|
/// [`tips::tip_row`] is the only way through.
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
pub(crate) struct Form {
|
|
|
|
|
pub advanced: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Form {
|
|
|
|
|
fn shows(self, level: Level) -> bool {
|
|
|
|
|
level == Level::Everyday || self.advanced
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn row(
|
|
|
|
|
self,
|
|
|
|
|
level: Level,
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
label: impl Into<egui::WidgetText>,
|
|
|
|
|
tip: &'static tips::Tip,
|
|
|
|
|
widget: impl FnOnce(&mut egui::Ui) -> egui::Response,
|
|
|
|
|
) {
|
|
|
|
|
if self.shows(level) {
|
|
|
|
|
tip_row(ui, label, tip, widget);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn drag<N: egui::emath::Numeric>(
|
|
|
|
|
self,
|
|
|
|
|
level: Level,
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
label: impl Into<egui::WidgetText>,
|
|
|
|
|
tip: &'static tips::Tip,
|
|
|
|
|
value: &mut N,
|
|
|
|
|
range: std::ops::RangeInclusive<N>,
|
|
|
|
|
) {
|
|
|
|
|
self.row(level, ui, label, tip, |ui| {
|
|
|
|
|
ui.add(egui::DragValue::new(value).range(range))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A label ruled underneath in the palette's orange.
|
|
|
|
|
///
|
|
|
|
|
/// For the advanced toggle, which sits in the same two-column form as the
|
|
|
|
|
/// settings but is not one of them — it decides which of them are on screen.
|
|
|
|
|
/// The rule marks that difference without a second type size or a box: the
|
|
|
|
|
/// text keeps the ordinary label color, so it reads as part of the form.
|
|
|
|
|
fn accented_label(ui: &egui::Ui, text: &str) -> egui::text::LayoutJob {
|
|
|
|
|
let mut job = egui::text::LayoutJob::default();
|
|
|
|
|
job.append(
|
|
|
|
|
text,
|
|
|
|
|
0.0,
|
|
|
|
|
egui::text::TextFormat {
|
|
|
|
|
font_id: egui::TextStyle::Body.resolve(ui.style()),
|
|
|
|
|
color: ui.visuals().text_color(),
|
|
|
|
|
underline: egui::Stroke::new(1.0, crate::color::palette(ui.visuals().dark_mode).orange),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
job
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
2026-08-17 19:26:18 -04:00
|
|
|
enum Section {
|
2026-08-02 19:04:30 -04:00
|
|
|
Indexing,
|
|
|
|
|
Processing,
|
|
|
|
|
Search,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Not draft edits: every action runs its own explicit flow in the app.
|
2026-08-02 20:21:19 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum SecurityAction {
|
|
|
|
|
Enable,
|
|
|
|
|
Disable,
|
|
|
|
|
ChangePassword,
|
|
|
|
|
SetKeychain(bool),
|
2026-08-17 19:26:18 -04:00
|
|
|
ShowKey,
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
2026-08-17 19:26:18 -04:00
|
|
|
pub struct SettingsOutput {
|
2026-08-02 20:21:19 -04:00
|
|
|
pub applied: Option<Config>,
|
|
|
|
|
pub security: Option<SecurityAction>,
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Like Security, edits the live config, so it takes effect without Apply.
|
2026-08-17 19:26:18 -04:00
|
|
|
pub columns: Option<ColumnsConfig>,
|
2026-08-25 01:04:36 -04:00
|
|
|
/// Also live: a view preference should not need an Apply to look at, and
|
|
|
|
|
/// drafting it would make merely revealing a setting read as an edit.
|
|
|
|
|
pub show_advanced: Option<bool>,
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
pub struct SettingsTab {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Staged the first frame the tab is shown; dropped when it is left.
|
2026-08-02 19:04:30 -04:00
|
|
|
draft: Option<Config>,
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The `use_keychain` preference the cached probe answer was taken under.
|
2026-08-02 22:21:39 -04:00
|
|
|
keychain_probed_for: Option<bool>,
|
|
|
|
|
keychain_active: bool,
|
2026-08-09 02:58:13 -04:00
|
|
|
/// The search-shortcut button is waiting for a key press to bind.
|
|
|
|
|
capturing_hotkey: bool,
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
impl SettingsTab {
|
|
|
|
|
pub fn new() -> SettingsTab {
|
|
|
|
|
SettingsTab {
|
2026-08-02 19:04:30 -04:00
|
|
|
draft: None,
|
2026-08-02 22:21:39 -04:00
|
|
|
keychain_probed_for: None,
|
|
|
|
|
keychain_active: false,
|
2026-08-09 02:58:13 -04:00
|
|
|
capturing_hotkey: false,
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Whether the shortcut button is reading a key press, so the app can
|
|
|
|
|
/// hold the shortcut it is about to replace.
|
2026-08-09 02:58:13 -04:00
|
|
|
pub fn capturing_hotkey(&self) -> bool {
|
2026-08-17 19:26:18 -04:00
|
|
|
self.capturing_hotkey
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The fields the app pins on apply are neutralized first, so the
|
|
|
|
|
/// Security block never makes the tab read as dirty.
|
2026-08-04 03:27:05 -04:00
|
|
|
pub fn is_dirty(&self, current: &Config) -> bool {
|
|
|
|
|
let Some(draft) = &self.draft else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let mut d = draft.clone();
|
|
|
|
|
crate::app::pin_live_fields(&mut d, current);
|
|
|
|
|
d != *current
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn draft_config(&self) -> Option<Config> {
|
|
|
|
|
self.draft.clone()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Drop the draft; the next frame stages a fresh copy of the live
|
|
|
|
|
/// config, which keeps a draft from going stale against other tabs.
|
2026-08-17 19:26:18 -04:00
|
|
|
pub fn discard(&mut self) {
|
2026-08-04 03:27:05 -04:00
|
|
|
self.draft = None;
|
2026-08-09 02:58:13 -04:00
|
|
|
self.capturing_hotkey = false;
|
2026-08-17 19:26:18 -04:00
|
|
|
self.keychain_probed_for = None;
|
2026-08-04 03:27:05 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
fn stage(&mut self, current: &Config) {
|
|
|
|
|
if self.draft.is_none() {
|
|
|
|
|
self.draft = Some(current.clone());
|
2026-08-04 03:27:05 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// True when the key really is in the OS keychain: the preference is on
|
|
|
|
|
/// *and* the keychain answers (a dead daemon or locked keyring reads as
|
|
|
|
|
/// "no"). Probed sparingly — a keychain read is an IPC round trip.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn keychain_active(&mut self, current: &Config) -> bool {
|
|
|
|
|
if self.keychain_probed_for != Some(current.security.use_keychain) {
|
|
|
|
|
let db_path = current.resolved_database_path();
|
|
|
|
|
self.keychain_active = current.security.use_keychain
|
|
|
|
|
&& matches!(keychain::load_key(&db_path.to_string_lossy()), Ok(Some(_)));
|
|
|
|
|
self.keychain_probed_for = Some(current.security.use_keychain);
|
|
|
|
|
}
|
|
|
|
|
self.keychain_active
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// `indexed_files` is the coordinator's count, used only to show what the
|
|
|
|
|
/// automatic search cache works out to for *this* index; `None` while it
|
|
|
|
|
/// is not yet known.
|
|
|
|
|
pub fn ui(
|
|
|
|
|
&mut self,
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
current: &Config,
|
|
|
|
|
indexed_files: Option<i64>,
|
|
|
|
|
) -> SettingsOutput {
|
2026-08-17 19:26:18 -04:00
|
|
|
self.stage(current);
|
|
|
|
|
let mut out = SettingsOutput::default();
|
2026-08-02 22:21:39 -04:00
|
|
|
let keychain_active = self.keychain_active(current);
|
2026-08-04 03:27:05 -04:00
|
|
|
let dirty = self.is_dirty(current);
|
2026-08-09 02:58:13 -04:00
|
|
|
let capturing = &mut self.capturing_hotkey;
|
2026-08-02 19:04:30 -04:00
|
|
|
let draft = self.draft.as_mut().unwrap();
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
// Live, like the columns below: read from the saved config, not the
|
|
|
|
|
// draft, so ticking it reveals the rows at once instead of after Apply.
|
|
|
|
|
let form = Form {
|
|
|
|
|
advanced: current.ui.show_advanced_settings,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
let scroll = egui::ScrollArea::vertical()
|
|
|
|
|
.auto_shrink([false; 2])
|
|
|
|
|
.show(ui, |ui| {
|
2026-08-23 00:33:53 -04:00
|
|
|
// A maximized window would stretch every hint into one line.
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.set_max_width(620.0);
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
// The same two-column shape as every settings row — label
|
|
|
|
|
// left, control right — so it reads as part of the form; the
|
|
|
|
|
// orange rule is what says it governs the form rather than
|
|
|
|
|
// belonging to it.
|
|
|
|
|
let label = accented_label(ui, "Show advanced settings");
|
|
|
|
|
egui::Grid::new("opt-advanced")
|
|
|
|
|
.num_columns(2)
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
tip_row(ui, label, &tips::SHOW_ADVANCED, |ui| {
|
|
|
|
|
let mut advanced = form.advanced;
|
|
|
|
|
let response = ui.checkbox(&mut advanced, "");
|
|
|
|
|
if response.changed() {
|
|
|
|
|
out.show_advanced = Some(advanced);
|
|
|
|
|
}
|
|
|
|
|
response
|
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"Advanced settings control how the index is built, stored \
|
|
|
|
|
and searched. The defaults suit almost every installation.",
|
|
|
|
|
));
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.separator();
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
// The whole section, heading and all: its only row is the
|
|
|
|
|
// database path.
|
|
|
|
|
if form.advanced {
|
|
|
|
|
ui.heading(egui::RichText::new("Paths").strong());
|
|
|
|
|
egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| {
|
|
|
|
|
form.row(
|
|
|
|
|
Level::Advanced,
|
|
|
|
|
ui,
|
|
|
|
|
"Database file",
|
|
|
|
|
&tips::DATABASE_PATH,
|
|
|
|
|
|ui| {
|
|
|
|
|
ui.add(
|
|
|
|
|
egui::TextEdit::singleline(&mut draft.paths.database_path)
|
|
|
|
|
.desired_width(260.0),
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
ui.label(hint("Indexed folders are managed on the Manage Index tab."));
|
|
|
|
|
ui.separator();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.heading(egui::RichText::new("Indexing").strong());
|
2026-08-25 01:04:36 -04:00
|
|
|
config_editor_ui(ui, draft, Section::Indexing, indexed_files, form);
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"Automatic and manual indexing are switched on the \
|
|
|
|
|
Manage Index tab.",
|
|
|
|
|
));
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Search").strong());
|
2026-08-25 01:04:36 -04:00
|
|
|
config_editor_ui(ui, draft, Section::Search, indexed_files, form);
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.add_space(6.0);
|
|
|
|
|
// Live, not drafted — see `columns_ui`.
|
|
|
|
|
out.columns = columns_ui(ui, ¤t.search.columns);
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Interface").strong());
|
|
|
|
|
egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| {
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(Level::Everyday, ui, "UI scale", &tips::UI_SCALE, |ui| {
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.add(
|
2026-09-04 21:39:54 -04:00
|
|
|
egui::Slider::new(&mut draft.ui.scale, crate::app::SCALE_RANGE)
|
2026-08-17 19:26:18 -04:00
|
|
|
.step_by(0.05)
|
|
|
|
|
.fixed_decimals(2),
|
|
|
|
|
)
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
|
|
|
|
ui,
|
|
|
|
|
"Search shortcut",
|
|
|
|
|
&tips::SEARCH_HOTKEY,
|
|
|
|
|
|ui| hotkey_edit(ui, &mut draft.ui.search_hotkey, capturing),
|
|
|
|
|
);
|
|
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
|
|
|
|
ui,
|
|
|
|
|
"Color scheme",
|
|
|
|
|
&tips::COLOR_SCHEME,
|
|
|
|
|
|ui| color_scheme_edit(ui, &mut draft.ui.color_scheme),
|
|
|
|
|
);
|
2026-08-17 19:26:18 -04:00
|
|
|
});
|
|
|
|
|
hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey);
|
2026-09-05 01:55:59 -04:00
|
|
|
shortcut_note(ui, ¤t.ui.search_hotkey);
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.separator();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
// Security acts on the live config, not the draft; the KDF
|
|
|
|
|
// salt is never shown anywhere in the GUI.
|
2026-08-17 19:26:18 -04:00
|
|
|
ui.heading(egui::RichText::new("Security").strong());
|
2026-08-25 01:04:36 -04:00
|
|
|
out.security = security_ui(ui, current, keychain_active, form);
|
2026-08-02 19:04:30 -04:00
|
|
|
ui.separator();
|
2026-08-17 19:26:18 -04:00
|
|
|
|
2026-09-04 21:39:54 -04:00
|
|
|
ui.heading(egui::RichText::new("Processing").strong());
|
|
|
|
|
config_editor_ui(ui, draft, Section::Processing, indexed_files, form);
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
2026-08-09 02:58:13 -04:00
|
|
|
let p = crate::color::palette(ui.visuals().dark_mode);
|
2026-08-02 19:04:30 -04:00
|
|
|
ui.horizontal(|ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
let apply = ui
|
|
|
|
|
.add(crate::ui_util::bordered_button(
|
|
|
|
|
"Apply & Save",
|
2026-08-09 02:58:13 -04:00
|
|
|
if dirty { p.orange } else { p.blue },
|
2026-08-05 18:05:04 -04:00
|
|
|
))
|
|
|
|
|
.tip(&tips::APPLY_SAVE);
|
2026-08-04 03:27:05 -04:00
|
|
|
if apply.clicked() {
|
2026-08-02 20:21:19 -04:00
|
|
|
out.applied = Some(draft.clone());
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// Comes and goes with the dirty state.
|
2026-08-04 03:27:05 -04:00
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
|
|
|
|
if dirty {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new("Unsaved changes")
|
|
|
|
|
.small()
|
2026-08-09 02:58:13 -04:00
|
|
|
.color(p.orange),
|
2026-08-04 03:27:05 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
// The second sentence names two rows that are only on screen
|
|
|
|
|
// with advanced settings shown.
|
|
|
|
|
ui.label(hint(if form.advanced {
|
2026-08-09 16:25:43 -04:00
|
|
|
"Narrowing a filter removes the entries it excludes; widening \
|
2026-08-17 19:26:18 -04:00
|
|
|
one reindexes to find what it now allows. Only the tokenizer \
|
2026-08-25 01:04:36 -04:00
|
|
|
and hash length require a full rebuild."
|
|
|
|
|
} else {
|
|
|
|
|
"Narrowing a filter removes the entries it excludes; widening \
|
|
|
|
|
one reindexes to find what it now allows."
|
|
|
|
|
}));
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
crate::ui_util::more_below_hint(ui, &scroll);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 20:21:19 -04:00
|
|
|
out
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:58:13 -04:00
|
|
|
const COLOR_SCHEMES: [(&str, &str); 2] = [("dark", "Dark"), ("light", "Light")];
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Via [`crate::app::theme_for`], so the box says what the app will actually do.
|
2026-08-09 02:58:13 -04:00
|
|
|
fn scheme_label(value: &str) -> &'static str {
|
|
|
|
|
match crate::app::theme_for(value) {
|
|
|
|
|
egui::Theme::Dark => "Dark",
|
|
|
|
|
egui::Theme::Light => "Light",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn color_scheme_edit(ui: &mut egui::Ui, setting: &mut String) -> egui::Response {
|
|
|
|
|
egui::ComboBox::from_id_salt("cfg-color-scheme")
|
|
|
|
|
.selected_text(scheme_label(setting))
|
|
|
|
|
.show_ui(ui, |ui| {
|
|
|
|
|
for (stored, label) in COLOR_SCHEMES {
|
|
|
|
|
ui.selectable_value(setting, stored.to_string(), label);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.response
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// A button showing the current binding that turns into a key-press reader
|
|
|
|
|
/// when clicked, and a Clear beside it.
|
2026-09-04 21:39:54 -04:00
|
|
|
/// The shortcut button: click it, press a combination, or Clear. Shared with
|
|
|
|
|
/// the tour's shortcut page, which offers the same setting.
|
|
|
|
|
pub(crate) fn hotkey_edit(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
setting: &mut String,
|
|
|
|
|
capturing: &mut bool,
|
|
|
|
|
) -> egui::Response {
|
2026-08-09 02:58:13 -04:00
|
|
|
let p = crate::color::palette(ui.visuals().dark_mode);
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
let label = if *capturing {
|
2026-09-05 01:55:59 -04:00
|
|
|
"Press a key combination…".to_string()
|
2026-08-09 02:58:13 -04:00
|
|
|
} else if setting.trim().is_empty() {
|
|
|
|
|
"None".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
setting.clone()
|
|
|
|
|
};
|
|
|
|
|
let button = ui.add(crate::ui_util::bordered_button(
|
|
|
|
|
label,
|
|
|
|
|
if *capturing { p.orange } else { p.blue },
|
|
|
|
|
));
|
2026-08-09 16:25:43 -04:00
|
|
|
// A second click backs out.
|
2026-08-09 02:58:13 -04:00
|
|
|
if button.clicked() {
|
|
|
|
|
*capturing = !*capturing;
|
|
|
|
|
} else if *capturing {
|
|
|
|
|
match read_capture(ui) {
|
|
|
|
|
Some(Some(binding)) => {
|
|
|
|
|
*setting = binding.to_string();
|
|
|
|
|
*capturing = false;
|
|
|
|
|
}
|
|
|
|
|
Some(None) => *capturing = false,
|
|
|
|
|
None => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ui
|
|
|
|
|
.add_enabled(
|
|
|
|
|
!setting.trim().is_empty(),
|
|
|
|
|
egui::Button::new("Clear").small(),
|
|
|
|
|
)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
setting.clear();
|
|
|
|
|
*capturing = false;
|
|
|
|
|
}
|
|
|
|
|
button
|
|
|
|
|
})
|
|
|
|
|
.inner
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// One frame of capture: `Some(Some(binding))` for a press worth binding,
|
|
|
|
|
/// `Some(None)` for a cancel, `None` while nothing usable arrived. Raw
|
|
|
|
|
/// events, because egui's shortcut matching cannot report an arbitrary
|
|
|
|
|
/// combination; invalid presses are ignored, not treated as a cancel.
|
2026-08-09 02:58:13 -04:00
|
|
|
fn read_capture(ui: &egui::Ui) -> Option<Option<crate::hotkey::Binding>> {
|
|
|
|
|
ui.input(|i| {
|
|
|
|
|
for event in &i.events {
|
|
|
|
|
let egui::Event::Key {
|
|
|
|
|
key,
|
|
|
|
|
pressed: true,
|
|
|
|
|
modifiers,
|
|
|
|
|
..
|
|
|
|
|
} = event
|
|
|
|
|
else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if *key == egui::Key::Escape {
|
|
|
|
|
return Some(None);
|
|
|
|
|
}
|
|
|
|
|
if let Some(binding) = crate::hotkey::Binding::from_egui(*key, modifiers) {
|
|
|
|
|
return Some(Some(binding));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Silent while registered and working; a line appears only when what is on
|
|
|
|
|
/// the button is not what is in force.
|
2026-08-09 02:58:13 -04:00
|
|
|
fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
|
|
|
|
|
use crate::hotkey::Status;
|
|
|
|
|
let (text, color) = if draft.trim() != live.trim() {
|
|
|
|
|
("Not registered until Apply and Save.".to_string(), None)
|
|
|
|
|
} else {
|
|
|
|
|
match crate::hotkey::status() {
|
|
|
|
|
Status::Disabled | Status::Active => (String::new(), None),
|
|
|
|
|
Status::Pending => (
|
|
|
|
|
"Waiting for your desktop to accept the shortcut.".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
Status::PortalBound(trigger) => (
|
|
|
|
|
format!(
|
|
|
|
|
"Your desktop registered this as {}. It has the final say; \
|
|
|
|
|
change it in its own keyboard settings. On Wayland it also \
|
|
|
|
|
decides whether the window comes forward, so a minimised \
|
|
|
|
|
window may stay minimised.",
|
|
|
|
|
trigger
|
|
|
|
|
),
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
Status::Error(why) => (
|
|
|
|
|
format!("The shortcut is not active: {}.", why),
|
|
|
|
|
Some(crate::color::palette(ui.visuals().dark_mode).orange),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let rich = egui::RichText::new(text).small();
|
|
|
|
|
ui.label(match color {
|
|
|
|
|
Some(color) => rich.color(color),
|
|
|
|
|
None => rich.weak(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 21:39:54 -04:00
|
|
|
/// How to get a shortcut that also *starts* QuickSearch.
|
|
|
|
|
///
|
|
|
|
|
/// The shortcut above is ours and needs no setup, but it cannot fire while
|
|
|
|
|
/// QuickSearch is not running — see `crate::activate`. Only the desktop can
|
2026-09-05 01:55:59 -04:00
|
|
|
/// bind a key that launches something. Where the desktop's own configuration
|
|
|
|
|
/// has a known home for that binding (`crate::shortcut_setup`), one button
|
|
|
|
|
/// writes it — into the place the desktop's settings UI lists and edits, so
|
|
|
|
|
/// it stays the user's to see and change. Everywhere else this says what to
|
|
|
|
|
/// bind and opens the place to bind it.
|
|
|
|
|
///
|
|
|
|
|
/// `hotkey_setting` is the shortcut in force, which the one-click binding
|
|
|
|
|
/// mirrors system-wide.
|
2026-09-04 21:39:54 -04:00
|
|
|
///
|
|
|
|
|
/// Shared with the tour's shortcut page, which puts it under the same
|
|
|
|
|
/// shortcut button this sentence says is "above".
|
2026-09-05 01:55:59 -04:00
|
|
|
pub(crate) fn shortcut_note(ui: &mut egui::Ui, hotkey_setting: &str) {
|
|
|
|
|
// Tests reach this through the tour's shortcut page; probing the real
|
|
|
|
|
// desktop there would make them depend on the machine they run on and
|
|
|
|
|
// spawn `gsettings`. Tests that want a desktop pin one through
|
|
|
|
|
// `shortcut_note_for`.
|
|
|
|
|
let desktop = if cfg!(test) {
|
|
|
|
|
crate::shortcut_setup::Desktop::Unsupported
|
|
|
|
|
} else {
|
|
|
|
|
crate::shortcut_setup::detect()
|
|
|
|
|
};
|
|
|
|
|
shortcut_note_for(ui, hotkey_setting, desktop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One frame's answer from [`crate::shortcut_setup`], cached: `installed`
|
|
|
|
|
/// probes the desktop with a subprocess, which must not run per frame.
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
struct SystemShortcutState {
|
|
|
|
|
installed: bool,
|
|
|
|
|
/// The last install/remove outcome, `(succeeded, what to say)`.
|
|
|
|
|
feedback: Option<(bool, String)>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The desktop split out of the environment so tests can pick one.
|
|
|
|
|
fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::shortcut_setup::Desktop) {
|
2026-09-04 21:39:54 -04:00
|
|
|
let command = format!("{} --toggle", crate::activate::command_name());
|
|
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
2026-09-05 01:55:59 -04:00
|
|
|
// One-click only with a key to write: an unset or unparseable
|
|
|
|
|
// shortcut leaves nothing to bind system-wide.
|
|
|
|
|
let binding = crate::hotkey::parse_setting(hotkey_setting).ok().flatten();
|
|
|
|
|
let one_click = binding.filter(|_| desktop != crate::shortcut_setup::Desktop::Unsupported);
|
|
|
|
|
|
|
|
|
|
if let Some(binding) = one_click {
|
|
|
|
|
// One id for the settings tab and the tour: they show one fact.
|
|
|
|
|
let id = egui::Id::new("system-shortcut-state");
|
|
|
|
|
let mut state = ui
|
|
|
|
|
.data_mut(|d| d.get_temp::<SystemShortcutState>(id))
|
|
|
|
|
.unwrap_or_else(|| SystemShortcutState {
|
|
|
|
|
installed: crate::shortcut_setup::installed(),
|
|
|
|
|
feedback: None,
|
|
|
|
|
});
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new(
|
|
|
|
|
"The shortcut above works while QuickSearch is open. Your \
|
|
|
|
|
desktop can also bind it to start QuickSearch when it is \
|
|
|
|
|
not:",
|
|
|
|
|
)
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
|
|
|
if state.installed {
|
|
|
|
|
ui.label(egui::RichText::new("The system shortcut is set up.").small());
|
|
|
|
|
if ui.add(egui::Button::new("Remove").small()).clicked() {
|
|
|
|
|
match crate::shortcut_setup::remove() {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
state.installed = false;
|
|
|
|
|
state.feedback =
|
|
|
|
|
Some((true, "System shortcut removed.".to_string()));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => state.feedback = Some((false, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if ui
|
|
|
|
|
.add(egui::Button::new(format!("Set up {} system-wide", binding)).small())
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
match crate::shortcut_setup::install(&binding) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
state.installed = true;
|
|
|
|
|
state.feedback = Some((
|
|
|
|
|
true,
|
|
|
|
|
"Added to your desktop's keyboard shortcuts. If the \
|
|
|
|
|
key does not answer right away, it will after the \
|
|
|
|
|
next login."
|
|
|
|
|
.to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => state.feedback = Some((false, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if let Some((ok, text)) = &state.feedback {
|
|
|
|
|
let rich = egui::RichText::new(text).small();
|
|
|
|
|
ui.label(if *ok {
|
|
|
|
|
rich.weak()
|
|
|
|
|
} else {
|
|
|
|
|
rich.color(crate::color::palette(ui.visuals().dark_mode).orange)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
ui.data_mut(|d| d.insert_temp(id, state));
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new("Or bind this command there yourself:")
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new(
|
|
|
|
|
"The shortcut above works while QuickSearch is open. To have a key \
|
|
|
|
|
start it as well, bind this command in your desktop's keyboard \
|
|
|
|
|
settings:",
|
|
|
|
|
)
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-04 21:39:54 -04:00
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
|
|
|
ui.label(egui::RichText::new(&command).small().monospace());
|
|
|
|
|
if ui.add(egui::Button::new("Copy").small()).clicked() {
|
|
|
|
|
ui.ctx().copy_text(command.clone());
|
|
|
|
|
}
|
|
|
|
|
if let Some(label) = crate::platform::keyboard_settings_label() {
|
|
|
|
|
if ui.add(egui::Button::new(label).small()).clicked() {
|
|
|
|
|
crate::platform::open_keyboard_settings();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if crate::activate::raise::is_wayland() {
|
|
|
|
|
ui.label(
|
|
|
|
|
egui::RichText::new(
|
|
|
|
|
"On Wayland a window that is already open cannot be raised by \
|
|
|
|
|
another process, so the shortcut will highlight QuickSearch in \
|
|
|
|
|
the task bar rather than bring it to the front.",
|
|
|
|
|
)
|
|
|
|
|
.small()
|
|
|
|
|
.weak(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The Search-tab column picker, mirroring the header right-click menu.
|
|
|
|
|
/// Acts on the **live** config: the header menu writes columns the instant
|
|
|
|
|
/// they change, and a draft-backed copy here would silently revert that on
|
|
|
|
|
/// the next Apply.
|
2026-08-17 19:26:18 -04:00
|
|
|
fn columns_ui(ui: &mut egui::Ui, current: &ColumnsConfig) -> Option<ColumnsConfig> {
|
|
|
|
|
let mut next = current.clone();
|
|
|
|
|
ui.label("Search columns").on_hover_text(tips::COLUMNS.body);
|
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
|
|
|
ui.checkbox(&mut next.name, "Name").tip(&tips::COLUMNS);
|
|
|
|
|
ui.add_enabled(false, egui::Checkbox::new(&mut true, "Path"))
|
|
|
|
|
.on_disabled_hover_text(
|
|
|
|
|
"The path is always shown — it is the only column that \
|
|
|
|
|
identifies a result on its own.",
|
|
|
|
|
);
|
|
|
|
|
ui.checkbox(&mut next.content_match, "Content Match")
|
|
|
|
|
.tip(&tips::COLUMNS);
|
|
|
|
|
ui.checkbox(&mut next.size, "Size").tip(&tips::COLUMNS);
|
|
|
|
|
ui.checkbox(&mut next.modified, "Modified")
|
|
|
|
|
.tip(&tips::COLUMNS);
|
|
|
|
|
ui.checkbox(&mut next.rank, "Rank").tip(&tips::COLUMNS);
|
|
|
|
|
});
|
|
|
|
|
ui.label(hint(
|
|
|
|
|
"Also on the Search tab: right-click any column header. Applied and \
|
|
|
|
|
saved immediately.",
|
|
|
|
|
));
|
|
|
|
|
(next != *current).then_some(next)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Never renders the salt.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn security_ui(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
current: &Config,
|
|
|
|
|
keychain_active: bool,
|
2026-08-25 01:04:36 -04:00
|
|
|
form: Form,
|
2026-08-02 22:21:39 -04:00
|
|
|
) -> Option<SecurityAction> {
|
2026-08-02 20:21:19 -04:00
|
|
|
let mut action = None;
|
|
|
|
|
if current.security.password_protected {
|
2026-08-02 22:21:39 -04:00
|
|
|
if keychain_active {
|
|
|
|
|
ui.label(
|
|
|
|
|
"The index is encrypted; its password is securely stored by \
|
|
|
|
|
your Operating System.",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
ui.label("The index is encrypted; a password is required at startup.");
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
ui.horizontal(|ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
if ui
|
2026-09-05 01:55:59 -04:00
|
|
|
.button("Change password")
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::CHANGE_PASSWORD)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
2026-08-02 20:21:19 -04:00
|
|
|
action = Some(SecurityAction::ChangePassword);
|
|
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
if ui
|
2026-09-05 01:55:59 -04:00
|
|
|
.button("Disable protection")
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::DISABLE_PASSWORD)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
2026-08-02 20:21:19 -04:00
|
|
|
action = Some(SecurityAction::Disable);
|
|
|
|
|
}
|
2026-09-05 01:55:59 -04:00
|
|
|
// The raw key is for someone recovering the file by hand; the
|
|
|
|
|
// password controls beside it are for everyone.
|
|
|
|
|
if form.advanced
|
|
|
|
|
&& ui
|
|
|
|
|
.button("Show database key")
|
|
|
|
|
.tip(&tips::SHOW_KEY)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
|
|
|
|
action = Some(SecurityAction::ShowKey);
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
});
|
|
|
|
|
let mut remember = current.security.use_keychain;
|
|
|
|
|
if ui
|
|
|
|
|
.checkbox(&mut remember, "Remember on this device")
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::REMEMBER_KEYCHAIN)
|
2026-08-02 20:21:19 -04:00
|
|
|
.changed()
|
|
|
|
|
{
|
|
|
|
|
action = Some(SecurityAction::SetKeychain(remember));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
ui.label("The index is not encrypted.");
|
2026-08-05 18:05:04 -04:00
|
|
|
if ui
|
2026-09-05 01:55:59 -04:00
|
|
|
.button("Enable password protection")
|
2026-08-05 18:05:04 -04:00
|
|
|
.tip(&tips::ENABLE_PASSWORD)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
2026-08-02 20:21:19 -04:00
|
|
|
action = Some(SecurityAction::Enable);
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"The index stores the names and text of your files. A password \
|
2026-08-02 20:21:19 -04:00
|
|
|
encrypts it on disk; enabling one rebuilds the index.",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
action
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// What the automatic search cache resolves to, as a sentence. `None` when the
|
|
|
|
|
/// file count is not known yet, in which case the row shows nothing rather
|
|
|
|
|
/// than a number that would be wrong.
|
|
|
|
|
fn search_cache_hint(config: &Config, indexed_files: Option<i64>) -> Option<String> {
|
|
|
|
|
use quicksearch_core::db::schema::{
|
|
|
|
|
recommended_search_cache_mib, SEARCH_CACHE_BYTES_PER_FILE, SEARCH_CACHE_MAX_MIB,
|
|
|
|
|
};
|
|
|
|
|
if config.search.cache_size_mib != 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let files = indexed_files?;
|
|
|
|
|
let keyed = config.security.password_protected;
|
|
|
|
|
let mib = recommended_search_cache_mib(files, keyed);
|
|
|
|
|
if !keyed {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Automatic: {} MiB. An unencrypted index reads a cache miss \
|
|
|
|
|
straight from the operating system, so a larger cache measures no \
|
|
|
|
|
faster.",
|
|
|
|
|
mib
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let counted = crate::format::group_thousands(files.max(0) as u64);
|
|
|
|
|
// Past ~800k files the automatic value is capped below what the index
|
|
|
|
|
// wants. Saying so is the only way the override is discoverable in the one
|
|
|
|
|
// case that needs it.
|
|
|
|
|
let wanted = files.max(0).saturating_mul(SEARCH_CACHE_BYTES_PER_FILE) / (1024 * 1024);
|
|
|
|
|
if wanted > SEARCH_CACHE_MAX_MIB {
|
|
|
|
|
return Some(format!(
|
|
|
|
|
"Automatic: {} MiB, the most it will choose on its own. This \
|
|
|
|
|
index's {} files want about {} MiB to search at full speed — set \
|
|
|
|
|
that here if you would rather spend the memory than the time.",
|
|
|
|
|
mib, counted, wanted
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Some(format!(
|
|
|
|
|
"Automatic: {} MiB, sized to hold this index's {} file records — an \
|
|
|
|
|
encrypted index re-decrypts them on every keystroke when they do not \
|
|
|
|
|
fit.",
|
|
|
|
|
mib, counted
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every row goes through [`Form`], so a setting cannot arrive here without a
|
|
|
|
|
/// tooltip or without saying who it is for.
|
|
|
|
|
fn config_editor_ui(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
config: &mut Config,
|
|
|
|
|
section: Section,
|
|
|
|
|
indexed_files: Option<i64>,
|
|
|
|
|
form: Form,
|
|
|
|
|
) {
|
2026-08-02 19:04:30 -04:00
|
|
|
match section {
|
|
|
|
|
Section::Indexing => {
|
2026-08-04 03:27:05 -04:00
|
|
|
egui::Grid::new("cfg-indexing")
|
|
|
|
|
.num_columns(2)
|
|
|
|
|
.show(ui, |ui| {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Automatic vs manual is absent: it is live state, and a
|
|
|
|
|
// staged copy would fight the Manage Index buttons.
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Advanced,
|
|
|
|
|
ui,
|
|
|
|
|
"Full reindex every",
|
|
|
|
|
&tips::REINDEX_INTERVAL,
|
|
|
|
|
|ui| {
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.add(
|
|
|
|
|
egui::DragValue::new(
|
|
|
|
|
&mut config.indexing.reindex_interval_minutes,
|
|
|
|
|
)
|
2026-08-05 18:05:04 -04:00
|
|
|
.range(5..=60 * 24 * 30),
|
2026-08-25 01:04:36 -04:00
|
|
|
);
|
|
|
|
|
ui.label("minutes");
|
|
|
|
|
})
|
|
|
|
|
.response
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Advanced,
|
|
|
|
|
ui,
|
|
|
|
|
"Follow symlinks",
|
|
|
|
|
&tips::FOLLOW_SYMLINKS,
|
|
|
|
|
|ui| ui.checkbox(&mut config.indexing.follow_symlinks, ""),
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
|
|
|
|
ui,
|
|
|
|
|
"Include hidden files",
|
|
|
|
|
&tips::INCLUDE_HIDDEN,
|
|
|
|
|
|ui| ui.checkbox(&mut config.indexing.include_hidden, ""),
|
|
|
|
|
);
|
2026-08-04 03:27:05 -04:00
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
Section::Processing => {
|
2026-08-04 03:27:05 -04:00
|
|
|
egui::Grid::new("cfg-processing")
|
|
|
|
|
.num_columns(2)
|
|
|
|
|
.show(ui, |ui| {
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(Level::Advanced, ui, "Tokenizer", &tips::TOKENIZER, |ui| {
|
2026-08-05 18:05:04 -04:00
|
|
|
egui::ComboBox::from_id_salt("cfg-tokenize")
|
|
|
|
|
.selected_text(&config.processing.tokenize)
|
|
|
|
|
.show_ui(ui, |ui| {
|
|
|
|
|
for opt in ["trigram", "unicode61", "porter"] {
|
|
|
|
|
ui.selectable_value(
|
|
|
|
|
&mut config.processing.tokenize,
|
|
|
|
|
opt.to_string(),
|
|
|
|
|
opt,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.response
|
|
|
|
|
});
|
2026-08-04 03:27:05 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
// Not a row, so it needs its own guard: it documents the
|
|
|
|
|
// tokenizer above and makes no sense without it.
|
|
|
|
|
if form.advanced {
|
|
|
|
|
ui.label("");
|
|
|
|
|
ui.hyperlink_to(
|
|
|
|
|
"Tokenizer documentation",
|
|
|
|
|
"https://www.sqlite.org/fts5.html#tokenizers",
|
|
|
|
|
);
|
|
|
|
|
ui.end_row();
|
|
|
|
|
}
|
2026-08-02 20:21:19 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Hash sample size (bytes)",
|
|
|
|
|
&tips::HASH_LENGTH,
|
|
|
|
|
&mut config.processing.hash_length,
|
|
|
|
|
512..=1_048_576,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-05 18:05:04 -04:00
|
|
|
ui,
|
|
|
|
|
"Max stored text (bytes)",
|
|
|
|
|
&tips::MAX_STORED_TEXT,
|
2026-08-09 16:25:43 -04:00
|
|
|
&mut config.processing.maximum_text_size,
|
|
|
|
|
1024..=16_777_216,
|
2026-08-04 03:27:05 -04:00
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-05 18:05:04 -04:00
|
|
|
ui,
|
|
|
|
|
"Max text file size (bytes)",
|
|
|
|
|
&tips::MAX_TEXT_FILE_SIZE,
|
2026-08-09 16:25:43 -04:00
|
|
|
&mut config.processing.maximum_text_file_size,
|
|
|
|
|
1024..=1_073_741_824,
|
2026-08-04 03:27:05 -04:00
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Batch size",
|
|
|
|
|
&tips::BATCH_SIZE,
|
|
|
|
|
&mut config.processing.batch_size,
|
|
|
|
|
10..=100_000,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Max WAL size (bytes)",
|
|
|
|
|
&tips::MAX_WAL_SIZE,
|
|
|
|
|
&mut config.processing.maximum_wal_size,
|
|
|
|
|
0u64..=8_589_934_592u64,
|
|
|
|
|
);
|
2026-08-03 03:06:19 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
|
|
|
|
ui,
|
|
|
|
|
"Store text for snippets",
|
|
|
|
|
&tips::STORE_TEXT,
|
|
|
|
|
|ui| ui.checkbox(&mut config.processing.store_text_for_snippets, ""),
|
|
|
|
|
);
|
2026-08-04 03:27:05 -04:00
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
Section::Search => {
|
|
|
|
|
egui::Grid::new("cfg-search").num_columns(2).show(ui, |ui| {
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
2026-08-05 18:05:04 -04:00
|
|
|
ui,
|
|
|
|
|
"Fuzzy search ON by default",
|
|
|
|
|
&tips::FUZZY_DEFAULT,
|
|
|
|
|
|ui| ui.checkbox(&mut config.search.fuzzy_default, ""),
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Fuzzy edit distance",
|
|
|
|
|
&tips::FUZZY_EDITS,
|
|
|
|
|
&mut config.search.fuzzy_max_edits,
|
|
|
|
|
0..=8,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Display limit",
|
|
|
|
|
&tips::DISPLAY_LIMIT,
|
|
|
|
|
&mut config.search.display_limit,
|
|
|
|
|
50..=100_000,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Stream batch size",
|
|
|
|
|
&tips::RESULTS_PER_PAGE,
|
|
|
|
|
&mut config.search.results_per_page,
|
|
|
|
|
10..=10_000,
|
|
|
|
|
);
|
2026-08-05 18:05:04 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
2026-08-09 16:25:43 -04:00
|
|
|
ui,
|
|
|
|
|
"Debounce (ms)",
|
|
|
|
|
&tips::DEBOUNCE,
|
|
|
|
|
&mut config.search.debounce_ms,
|
|
|
|
|
0..=2000,
|
|
|
|
|
);
|
2026-08-17 19:26:18 -04:00
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
form.row(
|
|
|
|
|
Level::Everyday,
|
|
|
|
|
ui,
|
|
|
|
|
"Live results",
|
|
|
|
|
&tips::LIVE_RESULTS,
|
|
|
|
|
|ui| ui.checkbox(&mut config.search.live_results, ""),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 0 is "derive it from the index", which is why this is a
|
|
|
|
|
// plain box rather than a range starting at the floor.
|
|
|
|
|
form.drag(
|
|
|
|
|
Level::Advanced,
|
|
|
|
|
ui,
|
|
|
|
|
"Search cache MiB (0 = auto)",
|
|
|
|
|
&tips::SEARCH_CACHE,
|
|
|
|
|
&mut config.search.cache_size_mib,
|
|
|
|
|
0..=quicksearch_core::db::schema::SEARCH_CACHE_OVERRIDE_MAX_MIB as usize,
|
|
|
|
|
);
|
2026-08-03 03:06:19 -04:00
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
// Both come and go as their values are edited, and both explain
|
|
|
|
|
// advanced rows — there is nothing to say when those are hidden.
|
|
|
|
|
if form.advanced {
|
|
|
|
|
crate::ui_util::stable_section(ui, |ui| {
|
|
|
|
|
if let Some(warning) = config.search.fuzzy_edits_warning() {
|
|
|
|
|
ui.colored_label(ui.visuals().warn_fg_color, warning);
|
|
|
|
|
}
|
|
|
|
|
if let Some(recommended) = search_cache_hint(config, indexed_files) {
|
|
|
|
|
ui.label(hint(&recommended));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 03:27:05 -04:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
2026-08-09 16:25:43 -04:00
|
|
|
mod tests;
|