2026-08-17 19:26:18 -04:00
|
|
|
//! The Settings tab: every configuration control the GUI offers, grouped
|
|
|
|
|
//! into sections. 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-09 16:25:43 -04:00
|
|
|
/// A [`tip_row`] holding one numeric [`egui::DragValue`] — the shape of most
|
|
|
|
|
/// rows in the config editor.
|
|
|
|
|
fn drag_row<N: egui::emath::Numeric>(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
label: &str,
|
|
|
|
|
tip: &'static tips::Tip,
|
|
|
|
|
value: &mut N,
|
|
|
|
|
range: std::ops::RangeInclusive<N>,
|
|
|
|
|
) {
|
|
|
|
|
tip_row(ui, label, tip, |ui| {
|
|
|
|
|
ui.add(egui::DragValue::new(value).range(range))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
/// A click in the Security block. These are 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
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
/// What one frame of the Settings tab produced.
|
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
|
|
|
/// "Apply & Save" was clicked with this draft.
|
|
|
|
|
pub applied: Option<Config>,
|
|
|
|
|
/// A Security block action was clicked.
|
|
|
|
|
pub security: Option<SecurityAction>,
|
2026-08-17 19:26:18 -04:00
|
|
|
/// The Columns block changed. Like Security, it edits the live config
|
|
|
|
|
/// rather than the draft, so it takes effect without Apply.
|
|
|
|
|
pub columns: Option<ColumnsConfig>,
|
2026-08-02 20:21:19 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
pub struct SettingsTab {
|
|
|
|
|
/// The staged config, built from the live one the first frame the tab is
|
|
|
|
|
/// shown and dropped again when it is left.
|
2026-08-02 19:04:30 -04:00
|
|
|
draft: Option<Config>,
|
2026-08-17 19:26:18 -04:00
|
|
|
/// Cached answer from [`SettingsTab::keychain_active`], with the
|
2026-08-02 22:21:39 -04:00
|
|
|
/// `use_keychain` preference it was probed under.
|
|
|
|
|
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-09 02:58:13 -04:00
|
|
|
/// Whether the shortcut button is reading a key press right now, so the
|
2026-08-17 19:26:18 -04:00
|
|
|
/// app can hold the shortcut it is about to replace. The app gates this
|
|
|
|
|
/// on the tab being the one on screen. See
|
2026-08-09 02:58:13 -04:00
|
|
|
/// [`crate::unlock::Gate::handle_hotkey`].
|
|
|
|
|
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-04 03:27:05 -04:00
|
|
|
/// Whether the draft differs from the live config. The fields the app
|
2026-08-09 16:25:43 -04:00
|
|
|
/// pins on apply are neutralized first, so the Security block never
|
2026-08-17 19:26:18 -04:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The draft as it stands, for the app's unsaved-changes guard.
|
|
|
|
|
pub fn draft_config(&self) -> Option<Config> {
|
|
|
|
|
self.draft.clone()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
/// Drop the draft: Discard, or leaving the tab. The next frame that
|
|
|
|
|
/// shows the tab stages a fresh copy of the live config, which is what
|
|
|
|
|
/// keeps a draft from going stale against edits made on Manage Index.
|
|
|
|
|
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
|
|
|
/// Take a draft if there is none: the first frame the tab is shown, and
|
|
|
|
|
/// the first frame after it was left.
|
|
|
|
|
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-02 22:21:39 -04:00
|
|
|
/// True when this index's key really is in the OS keychain: the
|
|
|
|
|
/// preference is on *and* the keychain answers with an entry (a dead
|
2026-08-09 16:25:43 -04:00
|
|
|
/// daemon, a locked keyring or a denied prompt all read as "no").
|
2026-08-17 19:26:18 -04:00
|
|
|
/// Probed when the tab is entered and when the preference changes — a
|
2026-08-09 16:25:43 -04:00
|
|
|
/// 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-02 20:21:19 -04:00
|
|
|
/// Render; reports an applied draft config and/or a security action.
|
2026-08-17 19:26:18 -04:00
|
|
|
pub fn ui(&mut self, ui: &mut egui::Ui, current: &Config) -> SettingsOutput {
|
|
|
|
|
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-17 19:26:18 -04:00
|
|
|
let scroll = egui::ScrollArea::vertical()
|
|
|
|
|
.auto_shrink([false; 2])
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
// Cap the column like a document page: a maximized window
|
|
|
|
|
// would otherwise stretch every hint into one long line.
|
|
|
|
|
ui.set_max_width(620.0);
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Paths").strong());
|
|
|
|
|
egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| {
|
|
|
|
|
tip_row(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();
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Indexing").strong());
|
|
|
|
|
config_editor_ui(ui, draft, Section::Indexing);
|
|
|
|
|
ui.label(hint(
|
|
|
|
|
"Automatic and manual indexing are switched on the \
|
|
|
|
|
Manage Index tab.",
|
|
|
|
|
));
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Processing").strong());
|
|
|
|
|
config_editor_ui(ui, draft, Section::Processing);
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
|
|
|
ui.heading(egui::RichText::new("Search").strong());
|
|
|
|
|
config_editor_ui(ui, draft, Section::Search);
|
|
|
|
|
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| {
|
|
|
|
|
tip_row(ui, "UI scale", &tips::UI_SCALE, |ui| {
|
|
|
|
|
ui.add(
|
|
|
|
|
egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5)
|
|
|
|
|
.step_by(0.05)
|
|
|
|
|
.fixed_decimals(2),
|
|
|
|
|
)
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
tip_row(ui, "Search shortcut", &tips::SEARCH_HOTKEY, |ui| {
|
|
|
|
|
hotkey_edit(ui, &mut draft.ui.search_hotkey, capturing)
|
|
|
|
|
});
|
|
|
|
|
tip_row(ui, "Color scheme", &tips::COLOR_SCHEME, |ui| {
|
|
|
|
|
color_scheme_edit(ui, &mut draft.ui.color_scheme)
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey);
|
|
|
|
|
ui.separator();
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
// Security acts on the live config, not the draft. The KDF
|
|
|
|
|
// salt is never shown here or anywhere else in the GUI.
|
|
|
|
|
ui.heading(egui::RichText::new("Security").strong());
|
|
|
|
|
out.security = security_ui(ui, current, keychain_active);
|
2026-08-02 19:04:30 -04:00
|
|
|
ui.separator();
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
// Last in the scroll, where the Manage Index tab also puts
|
|
|
|
|
// it, so the two draft-backed editors read the same way.
|
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-04 03:27:05 -04:00
|
|
|
// Comes and goes with the dirty state; keep it off the
|
|
|
|
|
// ids of the hint that follows.
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
ui.label(hint(
|
|
|
|
|
"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 \
|
|
|
|
|
and hash length require a full rebuild.",
|
2026-08-09 16:25:43 -04:00
|
|
|
));
|
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 16:25:43 -04:00
|
|
|
/// The color schemes, as stored (lowercase) and as shown.
|
2026-08-09 02:58:13 -04:00
|
|
|
const COLOR_SCHEMES: [(&str, &str); 2] = [("dark", "Dark"), ("light", "Light")];
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
/// What the dropdown shows for a stored value, resolved through
|
|
|
|
|
/// [`crate::app::theme_for`] so the box says what the app will actually do
|
|
|
|
|
/// — including with a value it does not recognise.
|
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",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The color scheme dropdown. Returns the box, which is what the row's
|
|
|
|
|
/// tooltip hangs off.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The search shortcut's control: a button showing the current binding that
|
|
|
|
|
/// turns into a key-press reader when clicked, and a Clear beside it.
|
|
|
|
|
/// Returns the button, which is what the row's tooltip hangs off.
|
|
|
|
|
fn hotkey_edit(ui: &mut egui::Ui, setting: &mut String, capturing: &mut bool) -> egui::Response {
|
|
|
|
|
let p = crate::color::palette(ui.visuals().dark_mode);
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
let label = if *capturing {
|
|
|
|
|
"Press a key combination...".to_string()
|
|
|
|
|
} 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One frame of shortcut capture: `Some(Some(binding))` for a press worth
|
|
|
|
|
/// binding, `Some(None)` for a cancel, `None` while nothing usable has
|
2026-08-09 16:25:43 -04:00
|
|
|
/// arrived. Raw events, because egui's shortcut matching cannot report an
|
|
|
|
|
/// arbitrary combination; invalid presses (a bare letter) 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-09 16:25:43 -04:00
|
|
|
/// What the shortcut is really doing, under the Interface grid. 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),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// Comes and goes with the state; keep it off the ids of what follows.
|
|
|
|
|
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-08-17 19:26:18 -04:00
|
|
|
/// The Search-tab column picker, mirroring the right-click menu on the table
|
|
|
|
|
/// headers. Returns the new set when a checkbox moved.
|
|
|
|
|
///
|
|
|
|
|
/// Acts on the **live** config, not the draft, for the same reason the
|
|
|
|
|
/// Security block does: the header menu writes columns the instant they
|
|
|
|
|
/// change, and a draft-backed copy here would silently revert that on the next
|
|
|
|
|
/// Apply. `app::pin_live_fields` keeps the draft out of this field entirely.
|
|
|
|
|
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);
|
|
|
|
|
// Checked and greyed rather than absent: an omitted entry reads as an
|
|
|
|
|
// oversight, a disabled one answers the question.
|
|
|
|
|
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-02 20:21:19 -04:00
|
|
|
/// The Security block: status plus action buttons. Never renders the salt.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn security_ui(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
current: &Config,
|
|
|
|
|
keychain_active: bool,
|
|
|
|
|
) -> 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
|
|
|
|
|
.button("Change password…")
|
|
|
|
|
.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
|
|
|
|
|
.button("Disable protection…")
|
|
|
|
|
.tip(&tips::DISABLE_PASSWORD)
|
|
|
|
|
.clicked()
|
|
|
|
|
{
|
2026-08-02 20:21:19 -04:00
|
|
|
action = Some(SecurityAction::Disable);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
// Its own row: three buttons do not fit the window's width.
|
|
|
|
|
if 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
|
|
|
|
|
.button("Enable password protection…")
|
|
|
|
|
.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-17 19:26:18 -04:00
|
|
|
/// The per-section config controls of the Settings tab. Every row goes
|
2026-08-09 16:25:43 -04:00
|
|
|
/// through [`crate::tips::tip_row`], so a setting cannot arrive here
|
|
|
|
|
/// without a tooltip.
|
2026-08-17 19:26:18 -04:00
|
|
|
fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section) {
|
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-09 16:25:43 -04:00
|
|
|
// Automatic vs manual is absent: it is live state switched
|
|
|
|
|
// on the Manage Index tab, and a staged copy here would
|
|
|
|
|
// fight those buttons.
|
2026-08-05 18:05:04 -04:00
|
|
|
tip_row(ui, "Full reindex every", &tips::REINDEX_INTERVAL, |ui| {
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
ui.add(
|
|
|
|
|
egui::DragValue::new(&mut config.indexing.reindex_interval_minutes)
|
|
|
|
|
.range(5..=60 * 24 * 30),
|
|
|
|
|
);
|
|
|
|
|
ui.label("minutes");
|
|
|
|
|
})
|
|
|
|
|
.response
|
2026-08-04 03:27:05 -04:00
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
tip_row(ui, "Follow symlinks", &tips::FOLLOW_SYMLINKS, |ui| {
|
|
|
|
|
ui.checkbox(&mut config.indexing.follow_symlinks, "")
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
tip_row(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-05 18:05:04 -04:00
|
|
|
tip_row(ui, "Tokenizer", &tips::TOKENIZER, |ui| {
|
|
|
|
|
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
|
|
|
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
ui,
|
|
|
|
|
"Batch size",
|
|
|
|
|
&tips::BATCH_SIZE,
|
|
|
|
|
&mut config.processing.batch_size,
|
|
|
|
|
10..=100_000,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
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-05 18:05:04 -04:00
|
|
|
tip_row(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-05 18:05:04 -04:00
|
|
|
tip_row(
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
ui,
|
|
|
|
|
"Display limit",
|
|
|
|
|
&tips::DISPLAY_LIMIT,
|
|
|
|
|
&mut config.search.display_limit,
|
|
|
|
|
50..=100_000,
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
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-09 16:25:43 -04:00
|
|
|
drag_row(
|
|
|
|
|
ui,
|
|
|
|
|
"Debounce (ms)",
|
|
|
|
|
&tips::DEBOUNCE,
|
|
|
|
|
&mut config.search.debounce_ms,
|
|
|
|
|
0..=2000,
|
|
|
|
|
);
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
tip_row(ui, "Live results", &tips::LIVE_RESULTS, |ui| {
|
|
|
|
|
ui.checkbox(&mut config.search.live_results, "")
|
|
|
|
|
});
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
2026-08-09 16:25:43 -04:00
|
|
|
// The warning comes and goes as the value is edited; keep it off
|
|
|
|
|
// the ids of what follows (`ui_util::stable_section`).
|
2026-08-03 03:06:19 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
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;
|