use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use iced::widget::operation::move_cursor_to_end;
use iced::Task;
use super::command_list::{CommandItem, CommandListAddon, CommandListState};
use super::recent::RecentPick;
use super::snippets::{
compute_previews, fetch_clipboard, filter_snippets, load_snippet_source, missing_clipboard,
snippet_mru_key,
};
use super::state::{ClipboardData, ClipboardSnapshot, LauncherApp, ScriptFilterResult};
use super::support::{contains_words, mru_bounds, mru_sort_key};
use super::types::Message;
use crate::{AddonsConfig, TextSnippet};
pub(super) const MAX_ROWS: usize = 5;
pub(super) const SCRIPT_FILTER_DEBOUNCE: Duration = Duration::from_millis(150);
const SCRIPT_FILTER_TIMEOUT: Duration = Duration::from_secs(10);
const SCRIPT_FILTER_MAX_OUTPUT: usize = 4 * 1024 * 1024;
pub(super) const MAIN_VIEW_SESSION: u64 = 1 << 63;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Entry {
Launchers,
List(CommandListAddon),
Snippets(usize),
ScriptFilter(usize),
}
pub(super) fn resolve(entries: &[String], addons: &AddonsConfig) -> Vec<Entry> {
let mut resolved = Vec::new();
for raw in entries {
let name = raw.trim();
let name = name.strip_prefix("addons.").unwrap_or(name);
let list = |enabled: bool, addon: CommandListAddon| {
if enabled {
Ok(Entry::List(addon))
} else {
Err(format!("main_view entry '{raw}' names a disabled addon"))
}
};
let entry = match name {
"launchers" => Ok(Entry::Launchers),
"system" => list(addons.system.enabled, CommandListAddon::System),
"windows" => list(addons.windows.enabled, CommandListAddon::Windows),
"browser_tabs" => list(addons.browser_tabs.enabled, CommandListAddon::BrowserTabs),
"media" => list(addons.media.enabled, CommandListAddon::Media),
"audio" => list(addons.audio.enabled, CommandListAddon::Audio),
"bluetooth" => list(addons.bluetooth.enabled, CommandListAddon::Bluetooth),
"pass" => list(addons.pass.enabled, CommandListAddon::Pass),
_ => {
if let Some(source) = name.strip_prefix("text_snippets.") {
let source = source.trim_matches('"');
addons
.text_snippets
.iter()
.position(|config| config.name == source)
.map(Entry::Snippets)
.ok_or_else(|| {
format!("main_view text snippet source '{source}' not found in addons")
})
} else if let Some(filter) = name.strip_prefix("script_filters.") {
let filter = filter.trim_matches('"');
addons
.script_filters
.iter()
.position(|config| config.name == filter)
.map(Entry::ScriptFilter)
.ok_or_else(|| {
format!("main_view script filter '{filter}' not found in addons")
})
} else {
Err(format!("unsupported main_view entry '{raw}'"))
}
}
};
match entry {
Ok(entry) if resolved.contains(&entry) => {
crate::debug_log!("main_view: duplicate entry '{raw}' skipped");
}
Ok(entry) => resolved.push(entry),
Err(warning) => {
crate::debug_log!("main_view: {warning}");
eprintln!("Warning: {warning}");
}
}
}
resolved
}
pub(super) fn recent_list_name(addon: CommandListAddon) -> Option<&'static str> {
match addon {
CommandListAddon::Windows => Some("windows"),
CommandListAddon::BrowserTabs => Some("browser_tabs"),
CommandListAddon::Pass => Some("pass"),
_ => None,
}
}
pub(super) fn is_mixable(addon: CommandListAddon, item: &CommandItem) -> bool {
if item.match_text.is_empty() || item.unavailable.is_some() {
return false;
}
match addon {
CommandListAddon::BrowserTabs => super::browser_tabs::tab_target(item).is_some(),
CommandListAddon::Pass => super::pass::entry_name(&item.identity).is_some(),
_ => item.primary.is_some(),
}
}
pub(super) enum Source {
List(Box<CommandListState>),
Snippets {
config_index: usize,
loading: bool,
items: Vec<TextSnippet>,
filtered: Vec<usize>,
previews: Vec<Option<String>>,
clipboard: ClipboardSnapshot,
},
ScriptFilter {
config_index: usize,
generation: u64,
live: Arc<AtomicU64>,
searched: String,
running: bool,
results: Option<ScriptFilterResult>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Block {
BeforeLaunchers,
AfterLaunchers,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Focus {
Launchers,
Source(usize),
}
#[derive(Default)]
pub(super) struct MainViewState {
pub hide_launchers: bool,
pub launchers_at: usize,
pub sources: Vec<Source>,
pub query: String,
pub active: bool,
pub stops: Vec<Focus>,
pub focus: Option<Focus>,
pub stale: HashSet<usize>,
pub names: Vec<Option<String>>,
pub recent: Vec<RecentPick>,
pub recent_path: Option<PathBuf>,
pub window_session: Option<String>,
pub launchers_strong: bool,
recent_applied: bool,
}
impl MainViewState {
pub fn new(entries: &[Entry]) -> Self {
let mut state = Self {
hide_launchers: !entries.contains(&Entry::Launchers),
active: true,
..Default::default()
};
for entry in entries {
let source = match *entry {
Entry::Launchers => {
state.launchers_at = state.sources.len();
state.stops.push(Focus::Launchers);
continue;
}
Entry::List(addon) => {
let mut list = CommandListState::default();
list.enter(addon);
list.session = MAIN_VIEW_SESSION | state.sources.len() as u64;
list.loading = true;
list.strict = true;
Source::List(Box::new(list))
}
Entry::Snippets(config_index) => Source::Snippets {
config_index,
loading: false,
items: Vec::new(),
filtered: Vec::new(),
previews: Vec::new(),
clipboard: ClipboardSnapshot::default(),
},
Entry::ScriptFilter(config_index) => Source::ScriptFilter {
config_index,
generation: 0,
live: Arc::default(),
searched: String::new(),
running: false,
results: None,
},
};
state.stops.push(Focus::Source(state.sources.len()));
state.sources.push(source);
}
if state.hide_launchers {
state.launchers_at = state.sources.len();
}
state
}
pub fn cycle(&mut self, backwards: bool) {
let all = self.stops.len();
let at = self
.focus
.and_then(|focus| self.stops.iter().position(|&stop| stop == focus))
.unwrap_or(all);
let next = if backwards {
(at + all) % (all + 1)
} else {
(at + 1) % (all + 1)
};
self.focus = self.stops.get(next).copied();
}
pub fn focused(&self) -> Option<Focus> {
self.focus.filter(|_| self.active)
}
pub fn shows(&self, source: usize) -> bool {
self.active
&& match self.focus {
None => !self.query.is_empty() || self.has_picks(source),
Some(focus) => focus == Focus::Source(source),
}
}
pub fn has_loading(&self) -> bool {
self.sources.iter().enumerate().any(|(source, entry)| {
self.shows(source)
&& match entry {
Source::List(list) => list.loading || list.refreshing,
Source::Snippets { loading, .. } => *loading,
Source::ScriptFilter { running, .. } => *running,
}
})
}
pub fn loaded_nothing(&mut self, source: usize) {
if let Some(Source::List(list)) = self.sources.get_mut(source) {
list.loading = false;
list.refreshing = false;
}
self.apply_recent();
}
fn has_picks(&self, source: usize) -> bool {
self.names
.get(source)
.and_then(Option::as_deref)
.is_some_and(|name| self.recent.iter().any(|pick| pick.source == name))
}
fn recent_mode(&self) -> bool {
self.active && self.focus.is_none() && self.query.is_empty()
}
pub fn apply_recent(&mut self) {
if !self.recent_mode() {
if std::mem::take(&mut self.recent_applied) {
for source in &mut self.sources {
match source {
Source::List(list) => {
let query = list.query.clone();
list.set_query(query);
}
Source::ScriptFilter { results, .. } => *results = None,
Source::Snippets { .. } => {}
}
}
}
return;
}
self.recent_applied = true;
let mut rows = vec![Vec::new(); self.sources.len()];
let mut stored = vec![Vec::new(); self.sources.len()];
let mut taken = 0;
for pick in &self.recent {
if taken == MAX_ROWS {
break;
}
let Some(source) = self
.names
.iter()
.position(|name| name.as_deref() == Some(pick.source.as_str()))
else {
continue;
};
let picked: &[usize] = &rows[source];
let found = match &self.sources[source] {
Source::List(list)
if list.addon == Some(CommandListAddon::Windows)
&& pick.session != self.window_session =>
{
continue;
}
Source::List(list) if list.loading => {
taken += 1;
continue;
}
Source::List(list) => find_pick(&list.items, pick, picked),
Source::Snippets { items, .. } => items
.iter()
.position(|snippet| snippet.name == pick.key)
.filter(|index| !picked.contains(index)),
Source::ScriptFilter { .. } => {
if let Some(item) = &pick.item {
stored[source].push(item.to_item());
taken += 1;
}
continue;
}
};
if let Some(index) = found {
rows[source].push(index);
taken += 1;
}
}
for (source, (rows, stored)) in rows.into_iter().zip(stored).enumerate() {
if !self.has_picks(source) {
continue;
}
match &mut self.sources[source] {
Source::List(list) => list.filtered = rows,
Source::Snippets { filtered, .. } => *filtered = rows,
Source::ScriptFilter { results, .. } => {
*results = (!stored.is_empty()).then_some(ScriptFilterResult {
items: stored,
default_icon: None,
});
}
}
}
}
fn arrangement(&self) -> (Vec<usize>, usize) {
if !self.active || self.focus.is_some() || self.query.is_empty() {
return ((0..self.sources.len()).collect(), self.launchers_at);
}
let groups = (0..self.launchers_at)
.map(Some)
.chain(std::iter::once(None))
.chain((self.launchers_at..self.sources.len()).map(Some));
let (strong, weak): (Vec<_>, Vec<_>) = groups.partition(|group| match group {
Some(source) => self.is_strong(*source),
None => self.launchers_strong && !self.hide_launchers,
});
let groups: Vec<Option<usize>> = strong.into_iter().chain(weak).collect();
let split = groups.iter().position(Option::is_none).unwrap_or_default();
(groups.into_iter().flatten().collect(), split)
}
fn is_strong(&self, source: usize) -> bool {
let rows = self.source_rows(source);
let strong = |title: &str| super::support::word_prefix_match(title, &self.query);
match &self.sources[source] {
Source::List(list) => (0..rows)
.filter_map(|row| list.item(row))
.any(|item| strong(&item.title)),
Source::Snippets {
items, filtered, ..
} => filtered
.iter()
.take(rows)
.any(|&index| strong(&items[index].name)),
Source::ScriptFilter { results, .. } => results
.iter()
.flat_map(|result| result.items.iter().take(rows))
.any(|item| strong(&item.title)),
}
}
fn block_sources(&self, block: Block) -> Vec<usize> {
let (mut order, split) = self.arrangement();
match block {
Block::BeforeLaunchers => {
order.truncate(split);
order
}
Block::AfterLaunchers => order.split_off(split),
}
}
pub fn source_rows(&self, source: usize) -> usize {
if !self.shows(source) {
return 0;
}
let count = match &self.sources[source] {
Source::List(list) if list.status().is_some() => 0,
Source::List(list) => list.filtered.len(),
Source::Snippets { filtered, .. } => filtered.len(),
Source::ScriptFilter { results, .. } => {
results.as_ref().map_or(0, |result| result.items.len())
}
};
if self.focus.is_some() {
count
} else {
count.min(MAX_ROWS)
}
}
pub fn row_count(&self, block: Block) -> usize {
self.block_sources(block)
.into_iter()
.map(|source| self.source_rows(source))
.sum()
}
pub fn rows(&self, block: Block) -> impl Iterator<Item = (usize, usize)> + '_ {
self.block_sources(block)
.into_iter()
.flat_map(move |source| (0..self.source_rows(source)).map(move |row| (source, row)))
}
pub fn locate(&self, block: Block, row: usize) -> Option<(usize, usize)> {
self.rows(block).nth(row)
}
pub fn block_of(&self, source: usize) -> Block {
if self.block_sources(Block::BeforeLaunchers).contains(&source) {
Block::BeforeLaunchers
} else {
Block::AfterLaunchers
}
}
fn rows_before(&self, source: usize) -> usize {
self.block_sources(self.block_of(source))
.into_iter()
.take_while(|&other| other != source)
.map(|other| self.source_rows(other))
.sum()
}
}
fn find_pick(items: &[CommandItem], pick: &RecentPick, picked: &[usize]) -> Option<usize> {
let free = |index: &usize| !picked.contains(index);
items
.iter()
.position(|item| item.identity == pick.key)
.filter(free)
.or_else(|| {
let url = pick.url.as_deref()?;
items.iter().enumerate().position(|(index, item)| {
free(&index)
&& super::browser_tabs::tab_target(item).is_some_and(|tab| tab.url == url)
})
})
}
fn filter_main_view_snippets(main_view: &mut MainViewState, source: usize, usage: &[u64]) {
let shows = main_view.shows(source);
let query = &main_view.query;
if let Some(Source::Snippets {
items, filtered, ..
}) = main_view.sources.get_mut(source)
{
*filtered = if shows {
let lowered = query.to_lowercase();
let mut matches = filter_snippets(items, query, usage);
matches.retain(|&index| {
let snippet = &items[index];
query.is_empty()
|| snippet
.keyword
.as_deref()
.is_some_and(|keyword| keyword.to_lowercase().starts_with(&lowered))
|| contains_words(&snippet.name, query)
|| contains_words(&snippet.value, query)
});
matches
} else {
Vec::new()
};
}
}
async fn off_thread<T: Send + 'static>(work: impl FnOnce() -> T + Send + 'static) -> Option<T> {
let (sender, receiver) = iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let _ = sender.send(work());
});
receiver.await.ok()
}
type ListLoad = Box<dyn FnOnce() -> Result<Vec<CommandItem>, String> + Send>;
impl LauncherApp {
pub(super) fn configure_main_view(&mut self, entries: Option<&[String]>) -> Task<Message> {
let Some(entries) = entries else {
return Task::none();
};
let entries = resolve(entries, &self.addons);
self.main_view = MainViewState::new(&entries);
self.main_view.names = entries
.iter()
.filter_map(|entry| match *entry {
Entry::Launchers => None,
Entry::List(addon) => Some(recent_list_name(addon).map(str::to_string)),
Entry::Snippets(index) => Some(
self.addons
.text_snippets
.get(index)
.map(|config| format!("text_snippets.{}", config.name)),
),
Entry::ScriptFilter(index) => Some(
self.addons
.script_filters
.get(index)
.map(|config| format!("script_filters.{}", config.name)),
),
})
.collect();
self.main_view.recent_path = crate::ui::get_recent_cache_path().ok();
self.main_view.window_session =
super::windows::Compositor::instance(|name| std::env::var(name).ok());
self.main_view.recent = super::recent::load(self.main_view.recent_path.as_deref());
if self.main_view.hide_launchers {
self.filtered_configs.clear();
}
self.main_view.apply_recent();
let loads: Vec<_> = (0..self.main_view.sources.len())
.map(|source| self.load_main_view_source(source, true))
.collect();
Task::batch(loads)
}
pub(super) fn record_recent(&mut self, pick: RecentPick) {
let listed = self
.main_view
.names
.iter()
.any(|name| name.as_deref() == Some(pick.source.as_str()));
if !listed {
return;
}
if let Some(path) = &self.main_view.recent_path {
self.main_view.recent = super::recent::load(Some(path));
}
super::recent::record(&mut self.main_view.recent, pick);
super::recent::save(
self.main_view.recent_path.as_deref(),
&self.main_view.recent,
);
}
fn load_main_view_source(&mut self, source: usize, fetch_favicons: bool) -> Task<Message> {
match &self.main_view.sources[source] {
Source::List(list) => match list.addon {
Some(addon) => self.load_main_view_list(source, addon, fetch_favicons),
None => Task::none(),
},
Source::Snippets { config_index, .. } => {
let config_index = *config_index;
let Some(config) = self.addons.text_snippets.get(config_index).cloned() else {
if let Some(Source::Snippets { loading, .. }) =
self.main_view.sources.get_mut(source)
{
*loading = false;
}
return Task::none();
};
if let Some(Source::Snippets { loading, .. }) =
self.main_view.sources.get_mut(source)
{
*loading = true;
}
Task::perform(
off_thread(move || load_snippet_source(&config)),
move |result| {
Message::MainViewSnippetsLoaded(
source,
result.unwrap_or_else(|| Err("loading stopped unexpectedly".into())),
)
},
)
}
Source::ScriptFilter { .. } => Task::none(),
}
}
fn load_main_view_list(
&mut self,
source: usize,
addon: CommandListAddon,
fetch_favicons: bool,
) -> Task<Message> {
let generation = {
let Source::List(list) = &mut self.main_view.sources[source] else {
return Task::none();
};
list.generation = list.generation.wrapping_add(1);
if list.items.is_empty() {
list.loading = true;
} else {
list.refreshing = true;
}
list.generation
};
let exists = self.program_exists;
let missing = |this: &mut Self, tool: &str| {
crate::debug_log!("main_view: {} hidden, {tool} not found", addon.name());
this.update_main_view(|main_view| main_view.loaded_nothing(source));
Task::none()
};
let load: ListLoad = match addon {
CommandListAddon::System => {
let session_id = std::env::var("XDG_SESSION_ID").ok();
let items = super::system::system_entries(
&self.addons.system,
(self.compositor)(),
session_id.as_deref().filter(|id| !id.is_empty()),
self.program_exists,
)
.iter()
.map(|entry| entry.to_item())
.collect();
return self.handle_main_view_loaded(source, generation, Ok(items));
}
CommandListAddon::Windows => {
let Some(compositor) = (self.compositor)() else {
return missing(self, "a supported compositor");
};
if !exists(compositor.program()) {
return missing(self, compositor.program());
}
Box::new(move || compositor.load_windows())
}
CommandListAddon::BrowserTabs => Box::new(super::browser_tabs::load_tabs),
CommandListAddon::Media => {
if !exists("playerctl") {
return missing(self, "playerctl");
}
let keep_open = !self.addons.media.close_on_action;
Box::new(move || super::media::load_players(keep_open))
}
CommandListAddon::Audio => {
if !exists("pactl") {
return missing(self, "pactl");
}
let options = super::audio::AudioOptions::of(&self.addons.audio);
let query_row = self.audio_query_row(false);
if let Source::List(list) = &mut self.main_view.sources[source] {
list.query_row = Some(query_row);
}
Box::new(move || Ok(super::audio::load_devices(options)))
}
CommandListAddon::Bluetooth => {
if !exists("bluetoothctl") {
return missing(self, "bluetoothctl");
}
let keep_open = !self.addons.bluetooth.close_on_action;
Box::new(move || Ok(super::bluetooth::load_devices(keep_open)))
}
CommandListAddon::Pass => {
if !exists("pass") {
return missing(self, "pass");
}
if !exists("gpg") && !exists("gpg2") {
return missing(self, "gpg");
}
let usage = self.vault_usage();
if let Source::List(list) = &mut self.main_view.sources[source] {
list.usage = usage;
}
let ctx = super::pass::PassContext::new(&self.addons.pass);
Box::new(move || super::pass::load_entries(&ctx))
}
CommandListAddon::Kill
| CommandListAddon::Bitwarden
| CommandListAddon::Terminal
| CommandListAddon::Slack => {
self.update_main_view(|main_view| main_view.loaded_nothing(source));
return Task::none();
}
};
let load = Task::perform(off_thread(load), move |result| {
Message::MainViewLoaded(
source,
generation,
result.unwrap_or_else(|| Err("loading stopped unexpectedly".into())),
)
});
if addon != CommandListAddon::BrowserTabs || !fetch_favicons {
return load;
}
let session = MAIN_VIEW_SESSION | source as u64;
let service = self.addons.browser_tabs.favicon_service;
load.chain(Task::perform(
off_thread(move || super::favicons::fetch_pending(service)),
move |fetched| Message::FaviconsFetched(session, fetched.unwrap_or(false)),
))
}
pub(super) fn handle_main_view_loaded(
&mut self,
source: usize,
generation: u64,
result: Result<Vec<CommandItem>, String>,
) -> Task<Message> {
let Some(Source::List(list)) = self.main_view.sources.get(source) else {
return Task::none();
};
if list.generation != generation {
return Task::none();
}
let Some(addon) = list.addon else {
return Task::none();
};
let mut items = match result {
Ok(items) => items,
Err(error) => {
crate::debug_log!("main_view: {} hidden: {error}", addon.name());
self.update_main_view(|main_view| main_view.loaded_nothing(source));
return Task::none();
}
};
items.retain(|item| is_mixable(addon, item));
for item in &mut items {
item.icon = item
.icon_names
.iter()
.find_map(|name| self.resolve_icon_name(name));
}
let query = self.main_view.query.clone();
self.update_main_view(|main_view| {
if let Some(Source::List(list)) = main_view.sources.get_mut(source) {
list.set_items(items);
list.set_query(query);
}
main_view.apply_recent();
});
Task::none()
}
fn update_main_view(&mut self, update: impl FnOnce(&mut MainViewState)) {
if self.view.help_active {
update(&mut self.main_view);
return;
}
let before_total = self.total_items();
let selected = self.selected_index;
let launchers = self.launchers_start();
let in_launchers = (launchers..launchers + self.filtered_configs.len()).contains(&selected);
let before = self.main_view_spans();
let within = before
.iter()
.position(|&(start, count)| (start..start + count).contains(&selected))
.map(|source| {
let row = selected - before[source].0;
let identity = match &self.main_view.sources[source] {
Source::List(list) => list.item(row).map(|item| item.identity.clone()),
_ => None,
};
(source, row, identity)
});
update(&mut self.main_view);
if selected >= before_total {
return;
}
let after = self.main_view_spans();
self.selected_index = match within {
Some((source, row, identity)) => {
let (start, count) = after[source];
let found = identity
.and_then(|identity| match &self.main_view.sources[source] {
Source::List(list) => list.position(&identity),
_ => None,
})
.filter(|&row| row < count);
start + found.unwrap_or(row.min(count.saturating_sub(1)))
}
None if in_launchers => self.launchers_start() + selected - launchers,
None => {
let moved: isize = before
.iter()
.zip(&after)
.filter(|((start, count), _)| start + count <= selected)
.map(|((_, old), (_, new))| *new as isize - *old as isize)
.sum();
selected.saturating_add_signed(moved)
}
};
self.selected_index = self
.selected_index
.min(self.total_items().saturating_sub(1));
}
fn launchers_start(&self) -> usize {
self.main_view_offset(Block::AfterLaunchers)
.saturating_sub(self.filtered_configs.len())
}
fn main_view_spans(&self) -> Vec<(usize, usize)> {
(0..self.main_view.sources.len())
.map(|source| {
let start = self.main_view_offset(self.main_view.block_of(source))
+ self.main_view.rows_before(source);
(start, self.main_view.source_rows(source))
})
.collect()
}
pub(super) fn invalidate_main_view(&mut self, addon: CommandListAddon) -> Task<Message> {
let sources: Vec<usize> = (0..self.main_view.sources.len())
.filter(|&source| {
matches!(&self.main_view.sources[source], Source::List(list) if list.addon == Some(addon))
})
.collect();
let mut tasks = Vec::new();
for source in sources {
if self.main_view.shows(source) {
self.main_view.stale.remove(&source);
tasks.push(self.load_main_view_source(source, false));
} else {
self.main_view.stale.insert(source);
}
}
Task::batch(tasks)
}
pub(super) fn handle_main_view_favicons(
&mut self,
session: u64,
fetched: bool,
) -> Task<Message> {
let source = (session & !MAIN_VIEW_SESSION) as usize;
if fetched && matches!(self.main_view.sources.get(source), Some(Source::List(_))) {
self.load_main_view_source(source, false)
} else {
Task::none()
}
}
pub(super) fn handle_main_view_snippets_loaded(
&mut self,
source: usize,
result: Result<Vec<TextSnippet>, String>,
) -> Task<Message> {
if let Some(Source::Snippets { loading, .. }) = self.main_view.sources.get_mut(source) {
*loading = false;
}
match result {
Ok(snippets) => {
if let Some(Source::Snippets { items, .. }) = self.main_view.sources.get_mut(source)
{
*items = snippets;
}
let usage = self.main_view_snippet_usage(source);
self.update_main_view(|main_view| {
filter_main_view_snippets(main_view, source, &usage);
main_view.apply_recent();
});
self.compute_main_view_snippet_previews(source);
return self.fetch_main_view_clipboard(source);
}
Err(error) => crate::debug_log!("main_view: snippet source hidden: {error}"),
}
Task::none()
}
fn compute_main_view_snippet_previews(&mut self, source: usize) {
let Some(Source::Snippets {
config_index,
items,
previews,
clipboard,
..
}) = self.main_view.sources.get_mut(source)
else {
return;
};
let Some(config) = self.addons.text_snippets.get(*config_index) else {
return;
};
*previews = if config.placeholders {
let allow_shell = config.command.is_none();
compute_previews(items, allow_shell, &clipboard.data)
} else {
Vec::new()
};
}
fn fetch_main_view_clipboard(&mut self, source: usize) -> Task<Message> {
let history = self.text_snippets.clipboard_history;
let hidden = !self.main_view.shows(source);
let Some(Source::Snippets {
config_index,
items,
clipboard,
..
}) = self.main_view.sources.get_mut(source)
else {
return Task::none();
};
let placeholders = self
.addons
.text_snippets
.get(*config_index)
.is_some_and(|config| config.placeholders);
if hidden || !placeholders || clipboard.fetching {
return Task::none();
}
let needs = missing_clipboard(items, &clipboard.data);
if needs.is_empty() {
return Task::none();
}
clipboard.fetching = true;
fetch_clipboard(MAIN_VIEW_SESSION | source as u64, needs, history)
}
pub(super) fn handle_main_view_clipboard(
&mut self,
session: u64,
data: ClipboardData,
) -> Task<Message> {
let source = (session & !MAIN_VIEW_SESSION) as usize;
if let Some(Source::Snippets { clipboard, .. }) = self.main_view.sources.get_mut(source) {
clipboard.fetching = false;
clipboard.data.merge(data);
self.compute_main_view_snippet_previews(source);
}
Task::none()
}
fn main_view_snippet_usage(&self, source: usize) -> Vec<u64> {
let Some(Source::Snippets {
config_index,
items,
..
}) = self.main_view.sources.get(source)
else {
return Vec::new();
};
let keyword = self
.addons
.text_snippets
.get(*config_index)
.map(|config| config.keyword.as_str())
.unwrap_or_default();
let map = &self.text_snippets.mru_map;
let (max_count, min_ts, max_ts) = mru_bounds(map);
items
.iter()
.map(|snippet| {
let key = snippet_mru_key(keyword, &snippet.name);
mru_sort_key(&key, map, &self.sort_mode, max_count, min_ts, max_ts)
})
.collect()
}
pub(super) fn refresh_main_view(&mut self, query: &str) -> Task<Message> {
self.main_view.query = query.to_string();
let mut tasks = Vec::new();
for source in 0..self.main_view.sources.len() {
let shows = self.main_view.shows(source);
match &mut self.main_view.sources[source] {
Source::List(list) => {
if list.query != query {
list.set_query(query.to_string());
}
if shows && self.main_view.stale.remove(&source) {
tasks.push(self.load_main_view_source(source, false));
}
}
Source::Snippets { .. } => {
let usage = self.main_view_snippet_usage(source);
filter_main_view_snippets(&mut self.main_view, source, &usage);
tasks.push(self.fetch_main_view_clipboard(source));
}
Source::ScriptFilter { .. } => {
let query = if shows { query } else { "" };
tasks.push(self.run_main_view_script_filter(source, query.to_string()));
}
}
}
self.main_view.apply_recent();
Task::batch(tasks)
}
fn run_main_view_script_filter(&mut self, source: usize, query: String) -> Task<Message> {
let Some(Source::ScriptFilter {
generation,
live,
searched,
results,
..
}) = self.main_view.sources.get_mut(source)
else {
return Task::none();
};
if *searched == query {
return Task::none();
}
searched.clone_from(&query);
*generation = generation.wrapping_add(1);
live.store(*generation, Ordering::Relaxed);
if query.is_empty() {
*results = None;
}
self.start_main_view_script_filter(source)
}
fn start_main_view_script_filter(&mut self, source: usize) -> Task<Message> {
let Some(Source::ScriptFilter {
config_index,
generation,
live,
searched,
running,
results,
}) = self.main_view.sources.get_mut(source)
else {
return Task::none();
};
let Some(config) = self.addons.script_filters.get(*config_index) else {
return Task::none();
};
if searched.is_empty() {
return Task::none();
}
if searched.len() < config.min_query_length.unwrap_or(0) {
*results = None;
return Task::none();
}
if *running {
return Task::none();
}
*running = true;
let generation = *generation;
let live = live.clone();
let query = searched.clone();
let mut command = Command::new(&config.command);
command.args(&config.args).envs(&config.env).arg(&query);
let icon = config.icon.clone();
Task::perform(
off_thread(move || {
std::thread::sleep(SCRIPT_FILTER_DEBOUNCE);
(live.load(Ordering::Relaxed) == generation).then(|| {
crate::debug_log!("main_view: script filter query={query:?}");
super::actions::run_bounded(
command,
SCRIPT_FILTER_TIMEOUT,
SCRIPT_FILTER_MAX_OUTPUT,
)
.and_then(|output| {
super::script_filters::parse_script_filter_output(
&String::from_utf8_lossy(&output),
icon,
)
})
})
}),
move |result| Message::MainViewScriptFilter(source, generation, result.flatten()),
)
}
pub(super) fn handle_main_view_script_filter(
&mut self,
source: usize,
run: u64,
result: Option<Result<ScriptFilterResult, String>>,
) -> Task<Message> {
let Some(Source::ScriptFilter {
generation,
running,
..
}) = self.main_view.sources.get_mut(source)
else {
return Task::none();
};
*running = false;
if *generation != run {
return self.start_main_view_script_filter(source);
}
let result = match result {
Some(Ok(result)) => Some(result),
Some(Err(error)) => {
crate::debug_log!("main_view: script filter hidden: {error}");
None
}
None => return Task::none(),
};
self.update_main_view(|main_view| {
if let Some(Source::ScriptFilter { results, .. }) = main_view.sources.get_mut(source) {
*results = result;
}
});
Task::none()
}
pub(super) fn handle_main_view_tab_preview(
&mut self,
session: u64,
url: String,
bodies: Option<Vec<super::preview::PreviewBody>>,
) -> Task<Message> {
let source = (session & !MAIN_VIEW_SESSION) as usize;
if let Some(Source::List(list)) = self.main_view.sources.get_mut(source) {
if list.preview_inflight.as_ref() == Some(&url) {
list.preview_inflight = None;
}
if let Some(bodies) = bodies {
list.previews.insert(url, bodies);
}
}
Task::none()
}
pub(super) fn main_view_focus_label(&self) -> String {
let Some(Focus::Source(source)) = self.main_view.focus else {
let label = if self.main_view.focus.is_some() {
"Launchers"
} else {
"All"
};
return label.to_string();
};
match &self.main_view.sources[source] {
Source::List(list) => match list.addon {
Some(CommandListAddon::BrowserTabs) => "Tabs",
Some(CommandListAddon::Windows) => "Windows",
Some(CommandListAddon::Pass) => "Passwords",
Some(addon) => addon.badge(),
None => "",
}
.to_string(),
Source::Snippets { config_index, .. } => self
.addons
.text_snippets
.get(*config_index)
.map(|config| config.name.clone())
.unwrap_or_default(),
Source::ScriptFilter { config_index, .. } => self
.addons
.script_filters
.get(*config_index)
.map(|config| config.name.clone())
.unwrap_or_default(),
}
}
pub(super) fn main_view_offset(&self, block: Block) -> usize {
let after = self.main_view.row_count(Block::AfterLaunchers);
let end = self
.total_items()
.saturating_sub(self.fallback_count() + after);
match block {
Block::AfterLaunchers => end,
Block::BeforeLaunchers => end.saturating_sub(
self.filtered_configs.len() + self.main_view.row_count(Block::BeforeLaunchers),
),
}
}
pub(super) fn selected_main_view(&self) -> Option<(usize, usize)> {
if self.view.help_active {
return None;
}
[Block::BeforeLaunchers, Block::AfterLaunchers]
.into_iter()
.find_map(|block| {
let row = self
.selected_index
.checked_sub(self.main_view_offset(block))?;
(row < self.main_view.row_count(block))
.then(|| self.main_view.locate(block, row))
.flatten()
})
}
pub(super) fn ensure_main_view_tab_preview(
&mut self,
service: crate::FaviconService,
) -> Task<Message> {
let selected = self
.view
.show_preview
.then(|| self.selected_main_view())
.flatten();
let mut task = Task::none();
for (index, source) in self.main_view.sources.iter_mut().enumerate() {
let Source::List(list) = source else {
continue;
};
let tab = match selected {
Some((source, row)) if source == index => list
.item(row)
.and_then(super::browser_tabs::tab_target)
.cloned(),
_ => None,
};
match tab {
Some(tab) => task = super::app::request_tab_preview(list, &tab, service),
None => list.preview_wanted.clear(),
}
}
task
}
pub(super) fn activate_main_view(&mut self, block: Block, row: usize) -> Task<Message> {
let Some((source, row)) = self.main_view.locate(block, row) else {
return Task::none();
};
if self.view.current_modifiers.alt() {
return Task::none();
}
let query = self.main_view.query.clone();
match &self.main_view.sources[source] {
Source::List(list) => {
let Some(addon) = list.addon else {
return Task::none();
};
let Some(item) = list.item(row) else {
return Task::none();
};
let identity = item.identity.clone();
let items = list.items.clone();
let usage = list.usage.clone();
let previews = list.previews.clone();
let trigger = match addon {
CommandListAddon::System => self.addons.system.trigger(),
CommandListAddon::Windows => self.addons.windows.trigger(),
CommandListAddon::BrowserTabs => self.addons.browser_tabs.trigger(),
CommandListAddon::Media => self.addons.media.trigger(),
CommandListAddon::Audio => self.addons.audio.trigger(),
CommandListAddon::Bluetooth => self.addons.bluetooth.trigger(),
CommandListAddon::Pass => self.addons.pass.trigger(),
CommandListAddon::Kill
| CommandListAddon::Bitwarden
| CommandListAddon::Terminal
| CommandListAddon::Slack => return Task::none(),
}
.to_string();
self.main_view.stale.insert(source);
self.command_list.enter(addon);
if addon == CommandListAddon::Audio {
self.command_list.query_row = Some(self.audio_query_row(true));
}
self.command_list.usage = usage;
self.command_list.previews = previews;
self.command_list.set_items(items);
let task = self.handle_search_changed(format!("{trigger} {query}"));
if self.command_list.addon != Some(addon) {
return task;
}
let Some(position) = self.command_list.position(&identity) else {
return task;
};
self.selected_index = self.command_list_offset() + position;
let cursor = move_cursor_to_end(self.view.search_input_id.clone());
let selected = self.handle_command_list_selected(position);
Task::batch([task, cursor, selected])
}
Source::Snippets {
config_index,
items,
filtered,
..
} => {
let config_index = *config_index;
let Some(&item) = filtered.get(row) else {
return Task::none();
};
let items = items.clone();
let Some(keyword) = self
.addons
.text_snippets
.get(config_index)
.map(|config| config.keyword.clone())
else {
return Task::none();
};
let _ = self.handle_search_changed(format!("{keyword} {query}"));
if !self.text_snippets.active
|| self.text_snippets.source_index != Some(config_index)
{
return Task::none();
}
self.text_snippets.generation = self.text_snippets.generation.wrapping_add(1);
self.text_snippets.items = items;
let mut tasks = Vec::new();
self.finish_text_snippet_load(&query, &mut tasks);
let Some(position) = self
.text_snippets
.filtered
.iter()
.position(|&index| index == item)
else {
return Task::batch(tasks);
};
self.selected_index =
self.keyword_suggestions.len() + self.script_filter_offset() + position;
tasks.push(self.handle_text_snippet_selected(position));
Task::batch(tasks)
}
Source::ScriptFilter {
config_index,
results,
..
} => {
let config_index = *config_index;
let Some(result) = results.clone() else {
return Task::none();
};
if !result.items.get(row).is_some_and(|item| item.valid) {
return Task::none();
}
let Some(keyword) = self
.addons
.script_filters
.get(config_index)
.map(|config| config.keyword.clone())
else {
return Task::none();
};
let _ = self.handle_search_changed(format!("{keyword} {query}"));
if self.script_filter.config_index != Some(config_index) {
return Task::none();
}
self.script_filter.generation = self.script_filter.generation.wrapping_add(1);
self.script_filter.loading = false;
self.script_filter.results = Some(result);
self.selected_index = self.keyword_suggestions.len() + row;
self.handle_script_filter_selected(row)
}
}
}
}