use color_eyre::Result;
use directories::ProjectDirs;
use eframe::App;
use egui::Vec2;
use existing_instance::Listener;
use iconography::icon_cache::{Icon, discover_icons, load_icon_textures};
use keybinds::Keybinds;
use std::collections::HashMap;
use std::default::Default;
use std::error::Error;
use std::sync::LazyLock;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread::{self, spawn};
use std::time::{Duration, Instant};
use strum::EnumString;
use tracing::{debug, error, info, warn};
use crate::free_launch::free_launch::Actions::FocusWindow;
use crate::free_launch::search_index::{ItemVec, SearchIndex};
use crate::launch_entries::action_result::ActionResult;
use crate::model::search_provider::SearchProvider;
use crate::signaling::request::Request;
use crate::signaling::response::{IndexingStatus, Response};
use crate::signaling::search_metadata::SearchMetadata;
const DEFAULT_PIXELS_PER_POINT: f32 = 1.0;
const MIN_DEBOUNCE_DURATION: u64 = 50;
const MAX_DEBOUNCE_DURATION: u64 = 100;
pub(crate) const FRAME_DELAY: u64 = 1000 / 60;
pub(crate) const LOCALES: [&str; 1] = ["en"];
pub static PROJECT_DIRS: LazyLock<ProjectDirs> = LazyLock::new(|| {
ProjectDirs::from("com", "symplasma", "free-launch")
.expect("project directories to be available")
});
pub(crate) type KeybindActions = Keybinds<Actions>;
#[derive(Clone, Copy, Debug, PartialEq, EnumString)]
pub(crate) enum Actions {
ToggleConfigScreen,
ToggleHelpScreen,
ToggleDebugMode,
ClearSearch,
Quit,
ReturnToMainScreenOrClearOrQuit,
EntryNext,
EntryPrevious,
EntryPageNext,
EntryPagePrevious,
InvokeDefault,
InvokeDefaultAndQuit,
InvokeSecondary,
InvokeSecondaryAndQuit,
InvokeDefaultAt(usize),
InvokeDefaultAtAndQuit(usize),
InvokeSecondaryAt(usize),
InvokeSecondaryAtAndQuit(usize),
FocusWindow,
}
pub struct FreeLaunch {
pub(crate) request_sender: Sender<Box<Request>>,
response_receiver: Receiver<Box<Response>>,
pub(crate) index_metadata: Option<SearchMetadata>,
pub(crate) search_providers: Vec<SearchProvider>,
pub(crate) search_query: String,
last_search_id: usize,
pub(crate) last_search_results: ItemVec,
pub(crate) focus_requested: bool,
pub(crate) focus_window_requested: bool,
pub(crate) highlighted_index: usize,
pub(crate) visible_item_count: usize,
first_visible_index: usize,
pub(crate) visible_item_height: usize,
pub(crate) help_mode: bool,
pub(crate) debug_mode: bool,
pub(crate) last_query_change: Option<Instant>,
pub(crate) loading_progress: IndexingStatus,
total_icons_discovered: usize,
icon_receiver: Receiver<Icon>,
pub(crate) icon_hashmap: HashMap<String, Icon>,
pub(crate) keybinds: KeybindActions,
}
impl<'a> FreeLaunch {
fn new<'b>(
request_sender: Sender<Box<Request>>,
response_receiver: Receiver<Box<Response>>,
ctx: &egui::Context,
) -> Result<Box<dyn App>, Box<dyn Error + Send + Sync>> {
let egui_ctx = ctx.clone();
info!("Starting the icon discovery...");
let icon_infos = discover_icons();
let total_icons_discovered = icon_infos.len();
info!("Starting the icon loader thread...");
let (icon_sender, icon_receiver) = channel();
let moved_ctx = ctx.clone();
spawn(move || {
load_icon_textures(icon_infos, &moved_ctx, icon_sender);
});
let dark_mode = false;
egui_ctx.set_visuals(if dark_mode {
egui::Visuals::dark()
} else {
egui::Visuals::light()
});
Ok(Box::new(Self {
request_sender,
response_receiver,
index_metadata: None,
search_providers: Default::default(),
search_query: Default::default(),
last_search_id: 0,
last_search_results: vec![],
focus_requested: false,
focus_window_requested: false,
highlighted_index: 0,
visible_item_count: 0,
first_visible_index: 0,
visible_item_height: 0,
help_mode: false,
debug_mode: false,
last_query_change: None,
loading_progress: IndexingStatus::Indexing {
indexed: 0,
total: total_icons_discovered,
},
total_icons_discovered,
icon_receiver,
icon_hashmap: Default::default(),
keybinds: Self::setup_key_binds()?,
}))
}
fn load_icons(&mut self) {
for icon in self.icon_receiver.try_iter() {
let icon_name = match icon {
Icon::Texture {
path: ref _path,
ref name,
texture: ref _texture,
} => name,
Icon::Error {
path: ref _path,
ref name,
ref error,
} => {
warn!("Could not transfer icon '{}': {}", name, error);
name
}
};
self.icon_hashmap
.insert(icon_name.to_string(), icon.clone());
let icons_loaded = self.icon_hashmap.len();
self.loading_progress = if icons_loaded >= self.total_icons_discovered {
IndexingStatus::Done
} else {
IndexingStatus::Indexing {
indexed: icons_loaded,
total: self.total_icons_discovered,
}
}
}
}
pub(crate) fn launch_search(&self, provider: &SearchProvider, search_term: &str) {
let url = &provider.url;
let encoded_term = search_term.replace(' ', "+");
let final_url = url.replace("{{ word | urlencode }}", &encoded_term);
let browser_cmd = provider.browser.as_deref().unwrap_or("xdg-open");
if let Err(e) = std::process::Command::new(browser_cmd)
.arg(&final_url)
.spawn()
{
error!("Failed to open URL {}: {}", final_url, e);
}
}
pub fn run(listener: Listener) -> Result<(), eframe::Error> {
info!("Starting the SearchIndex...");
let (request_sender, request_receiver) = channel();
let (response_sender, response_receiver) = channel();
info!("Starting the search indexer...");
let mut search_index = SearchIndex::new(
request_sender.clone(),
request_receiver,
response_sender.clone(),
);
thread::spawn(move || {
search_index.run();
});
thread::spawn(move || {
loop {
if let Some(mut conn) = listener.accept()
&& let Some(msg) = conn.recv()
{
match msg {
existing_instance::Msg::Nudge => {
if let Err(e) = response_sender.send(Box::new(Response::FocusWindow)) {
warn!("Could not nudge existing instance: {}", e)
}
}
_ => (),
}
}
}
});
let default_size = Vec2::new(800.0, 600.0);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size(default_size)
.with_resizable(true),
centered: true,
..Default::default()
};
eframe::run_native(
"Free Launch",
options,
Box::new(|cc| {
cc.egui_ctx.set_pixels_per_point(
cc.egui_ctx
.native_pixels_per_point()
.unwrap_or(DEFAULT_PIXELS_PER_POINT),
);
egui_extras::install_image_loaders(&cc.egui_ctx);
Ok(FreeLaunch::new(
request_sender,
response_receiver,
&cc.egui_ctx,
)?)
}),
)
}
pub(crate) fn previous_item(&mut self) {
self.highlighted_index = self.highlighted_index.saturating_sub(1);
}
pub(crate) fn next_item(&mut self) {
self.highlighted_index = self.highlighted_index.saturating_add(1);
}
pub(crate) fn page_up(&mut self) {
if self.visible_item_count == 0 {
return;
}
if self.highlighted_index > self.first_visible_index {
self.highlighted_index = self.first_visible_index;
} else {
let scroll_amount = self.visible_item_count.min(self.first_visible_index);
self.first_visible_index = self.first_visible_index.saturating_sub(scroll_amount);
self.highlighted_index = self.highlighted_index.saturating_sub(scroll_amount);
}
self.highlighted_index = self.highlighted_index.min(self.visible_item_count);
}
pub(crate) fn page_down(&mut self) {
if self.visible_item_count == 0 {
return;
}
let last_visible_index =
(self.first_visible_index + self.visible_item_count - 1).min(self.visible_item_count);
if self.highlighted_index < last_visible_index {
self.highlighted_index = last_visible_index;
} else {
let scroll_amount = self.visible_item_count;
self.first_visible_index =
(self.first_visible_index + scroll_amount).min(self.visible_item_count);
self.highlighted_index =
(self.highlighted_index + scroll_amount).min(self.visible_item_count);
}
}
pub(crate) fn invoke_default_on_highlighted_item(&self) {
self.invoke_default_on_item_at(self.highlighted_index);
}
pub(crate) fn invoke_default_on_item_at(&self, index: usize) {
if let Some(item) = self.last_search_results.get(index) {
let item = item.lock();
if let ActionResult::Error(e) = item.invoke_default(&self.search_query) {
warn!("Could not invoke default action: {e}")
}
}
}
pub(crate) fn invoke_secondary_on_highlighted_item(&self) {
self.invoke_secondary_on_item_at(self.highlighted_index);
}
pub(crate) fn invoke_secondary_on_item_at(&self, index: usize) {
if let Some(item) = self.last_search_results.get(index) {
let item = item.lock();
if let ActionResult::Error(e) = item.invoke_secondary(&self.search_query) {
warn!("Could not invoke secondary action: {e}")
}
}
}
pub(crate) fn request_search(&mut self) {
self.last_search_id += 1;
info!(
"Requesting search {} for query: {}",
self.last_search_id, self.search_query
);
self.request_sender
.send(Box::new(Request::Search {
id: self.last_search_id,
query: self.search_query.clone(),
}))
.expect("should be able to send search request");
}
pub(crate) fn get_matching_search_provider(&self) -> Option<(&SearchProvider, String)> {
let query = self.search_query.trim();
for provider in &self.search_providers {
if let Some(_alias) = provider.get_matching_alias(query) {
let words: Vec<&str> = query.split_whitespace().collect();
if words.len() > 1 {
let search_term = words[1..].join(" ");
return Some((provider, search_term.clone()));
}
}
}
None
}
fn handle_response(&mut self) -> bool {
if let Ok(response) = self.response_receiver.try_recv() {
match *response {
Response::IndexMetadata { metadata } => {
info!("Received a metadata update");
self.index_metadata = Some(metadata)
}
Response::SearchProviders { providers } => {
info!("Received a list of {} search providers", providers.len());
self.search_providers = providers
}
Response::SearchResults {
id,
results,
matches,
total_items,
} => {
info!(
"Received {} search results out of {}/{} for search with id {}",
results.len(),
matches,
total_items,
id,
);
self.last_search_id = id;
self.last_search_results = results;
}
Response::FocusWindow => self.focus_window(),
}
true
} else {
false
}
}
pub(crate) fn setup_key_binds() -> Result<KeybindActions> {
let mut keybinds = Keybinds::default();
keybinds.bind("Ctrl+,", Actions::ToggleConfigScreen)?;
keybinds.bind("Ctrl+H", Actions::ToggleHelpScreen)?;
keybinds.bind("Ctrl+?", Actions::ToggleHelpScreen)?;
keybinds.bind("Ctrl+U", Actions::ClearSearch)?;
keybinds.bind("Ctrl+Q", Actions::Quit)?;
keybinds.bind("Ctrl+W", Actions::Quit)?;
keybinds.bind("Escape", Actions::ReturnToMainScreenOrClearOrQuit)?;
keybinds.bind("Down", Actions::EntryNext)?;
keybinds.bind("Up", Actions::EntryPrevious)?;
keybinds.bind("PageDown", Actions::EntryPageNext)?;
keybinds.bind("PageUp", Actions::EntryPagePrevious)?;
keybinds.bind("Enter", Actions::InvokeDefaultAndQuit)?;
keybinds.bind("Alt+Enter", Actions::InvokeDefault)?;
keybinds.bind("Shift+Enter", Actions::InvokeSecondaryAndQuit)?;
keybinds.bind("Shift+Alt+Enter", Actions::InvokeSecondary)?;
Ok(keybinds)
}
fn execute_actions(&mut self, actions: Vec<Actions>) -> Option<egui::ViewportCommand> {
let mut returned_egui_action = None;
for action in actions {
debug!("processing action: {:?}", action);
match action {
Actions::ToggleConfigScreen => todo!(),
Actions::ToggleHelpScreen => {
self.help_mode = !self.help_mode;
}
Actions::ToggleDebugMode => {
self.debug_mode = !self.debug_mode;
}
Actions::ClearSearch => todo!(),
Actions::Quit => {
return Some(egui::ViewportCommand::Close);
}
Actions::ReturnToMainScreenOrClearOrQuit => {
if self.help_mode {
self.help_mode = false;
} else if self.debug_mode {
self.debug_mode = false;
} else {
if self.search_query.is_empty() {
return Some(egui::ViewportCommand::Close);
} else {
self.search_query.clear();
self.request_search();
self.last_query_change = None;
}
}
}
Actions::EntryNext => {
self.next_item();
}
Actions::EntryPrevious => {
self.previous_item();
}
Actions::EntryPageNext => {
self.page_down();
}
Actions::EntryPagePrevious => {
self.page_up();
}
Actions::InvokeDefault => {
self.invoke_default_on_highlighted_item();
}
Actions::InvokeDefaultAndQuit => {
self.invoke_default_on_highlighted_item();
return Some(egui::ViewportCommand::Close);
}
Actions::InvokeSecondary => {
self.invoke_secondary_on_highlighted_item();
}
Actions::InvokeSecondaryAndQuit => {
self.invoke_secondary_on_highlighted_item();
return Some(egui::ViewportCommand::Close);
}
Actions::InvokeDefaultAt(index) => {
self.invoke_default_on_item_at(index);
}
Actions::InvokeDefaultAtAndQuit(index) => {
self.invoke_default_on_item_at(index);
return Some(egui::ViewportCommand::Close);
}
Actions::InvokeSecondaryAt(index) => {
self.invoke_secondary_on_item_at(index);
}
Actions::InvokeSecondaryAtAndQuit(index) => {
self.invoke_secondary_on_item_at(index);
return Some(egui::ViewportCommand::Close);
}
Actions::FocusWindow => {
self.next_item();
returned_egui_action = Some(egui::ViewportCommand::Focus);
self.focus_window_requested = false;
}
}
}
returned_egui_action
}
fn focus_window(&mut self) {
if !self.focus_window_requested {
self.focus_window_requested = true;
}
}
}
pub(crate) fn debounce_duration(query_length: u64) -> Duration {
let duration = if query_length == 0 {
MIN_DEBOUNCE_DURATION
} else {
(MAX_DEBOUNCE_DURATION / query_length).max(MIN_DEBOUNCE_DURATION)
};
Duration::from_millis(duration)
}
impl eframe::App for FreeLaunch {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut actions = self.key_events_to_actions(ctx);
if self.focus_window_requested {
actions.push(FocusWindow);
}
if let Some(viewport_cmd) = self.execute_actions(actions) {
info!("Sending viewport command: {:?}", viewport_cmd);
ctx.send_viewport_cmd(viewport_cmd);
}
if self
.last_query_change
.map(|change| {
change.elapsed() > debounce_duration(self.search_query.len().try_into().unwrap())
})
.unwrap_or(false)
{
self.request_search();
self.last_query_change = None;
}
if !matches!(self.loading_progress, IndexingStatus::Done) {
self.load_icons();
}
let process_responses_start = Instant::now();
while self.handle_response() {
let response_processing_duration = Duration::from_millis(FRAME_DELAY);
if process_responses_start.elapsed() > response_processing_duration {
info!(
"Stopped processing responses after {} milliseconds",
response_processing_duration.as_millis()
);
break;
}
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let actions = egui::CentralPanel::default().show_inside(ui, |ui| {
if self.help_mode {
self.show_help_screen(ui)
} else if self.debug_mode {
self.show_debug_screen(ui)
} else {
self.show_main_screen(&ctx, ui)
}
});
if let Some(viewport_cmd) = self.execute_actions(actions.inner) {
ctx.send_viewport_cmd(viewport_cmd);
}
}
}