free-launch 0.2.9

A simple fuzzy launcher written in Rust.
Documentation
use egui::Color32;
use egui::Image;
use egui::ImageSource;
use egui::Key;
use egui::ProgressBar;
use egui::Sense;
use iconography::icon_cache::Icon;
use tracing::info;
use tracing::warn;

use crate::free_launch::free_launch::Actions;
use crate::free_launch::free_launch::FreeLaunch;
use crate::free_launch::free_launch::debounce_duration;
use crate::free_launch::search_index::ItemVec;
use crate::launch_entries::launch_entry::LaunchEntry;
use crate::signaling::request::Request;
use crate::signaling::response::IndexingStatus;
use std::collections::HashMap;
use std::time::Instant;

impl FreeLaunch {
    #[must_use]
    pub(crate) fn show_main_screen(
        &mut self,
        ctx: &egui::Context,
        ui: &mut egui::Ui,
    ) -> Vec<Actions> {
        let mut actions_to_return: Vec<Actions> = Default::default();

        // the id to allow us to restore focus to the query bar
        let query_bar_id = egui::Id::new("query_bar");

        ui.vertical(|ui| {
            self.query_bar(query_bar_id, ui);

            ui.add_space(6.0);

            self.progress_indicator(ui);

            ui.add_space(6.0);

            // TODO unify the search templates with the rest of the entries
            // Check if this is a search query first
            if let Some((provider, search_term)) = self.get_matching_search_provider() {
                // Handle Enter key for search queries
                if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                    self.launch_search(provider, &search_term);
                    ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                    return; // Exit early to avoid processing items
                }

                // Show search preview
                ui.horizontal(|ui| {
                    ui.label("🔍");
                    ui.label(format!("Search {} for: {}", provider.name, search_term));
                    ui.label("(Press Enter to search)");
                });
                ui.add_space(6.0);
            }

            let mut match_count = 0;
            let advance_next_item = false;
            let visible_items = 0;

            // Calculate available height for the scroll area
            let available_height = ui.available_height();

            // Estimate item height (frame margin + text height + spacing)
            let text_height = ui.text_style_height(&egui::TextStyle::Body);
            let frame_margin = 4.0 * 2.0; // inner margin is 4.0 on all sides
            let estimated_item_height = text_height + frame_margin + ui.spacing().item_spacing.y;

            // Calculate how many items can fit in the available height
            let calculated_visible_items: u32 = if estimated_item_height > 0.0 {
                (available_height / estimated_item_height).floor() as u32
            } else {
                10 // fallback value
            };

            if self.visible_item_height as u32 != calculated_visible_items {
                self.visible_item_height = calculated_visible_items as usize;
                info!(
                    "Sending calculated displayable items: {}",
                    calculated_visible_items
                );
                self.request_sender
                    .send(Box::new(Request::UpdateWindowInfo {
                        displayable_lines: 0..calculated_visible_items,
                    }))
                    .expect("should be able to send displayable items count to index");
            }

            let launch_entry_actions = FreeLaunch::launch_entry_area(
                ctx,
                ui,
                &mut self.last_search_results,
                self.highlighted_index,
                &self.icon_hashmap,
                &mut match_count,
                advance_next_item,
                visible_items,
            );
            actions_to_return.extend(launch_entry_actions);

            // Update visible item count with the calculated value
            self.visible_item_count = calculated_visible_items.max(1) as usize; // ensure at least 1

            // ensure that the highlighted index does not exceed the match count
            // this keeps us from running past the end of the list, even as we search and narrow down matches
            self.highlighted_index = self.highlighted_index.min(match_count);

            // focus the search box
            if !self.focus_requested {
                ui.memory_mut(|m| m.request_focus(query_bar_id));
            }
        });

        actions_to_return
    }

    fn query_bar(&mut self, search_box_id: egui::Id, ui: &mut egui::Ui) {
        // Text input box, not using `text_edit_singleline` since we need to set an id
        let search_query_response = ui.add(
            egui::TextEdit::singleline(&mut self.search_query)
                .id(search_box_id)
                .desired_width(ui.available_width()),
        );
        if search_query_response.changed() {
            // debounce here
            if self.search_query.is_empty()
                || self
                    .last_query_change
                    .get_or_insert_with(|| Instant::now())
                    .elapsed()
                    > debounce_duration(self.search_query.len().try_into().unwrap())
            {
                self.request_search();
                self.last_query_change = None;
            } else {
                // reset the debounce interval here
                self.last_query_change = Some(Instant::now());
            }
        }
    }

    fn progress_indicator(&mut self, ui: &mut egui::Ui) {
        match self.loading_progress {
            IndexingStatus::Preparing => {
                ui.label("Preparing to index...");
            }
            IndexingStatus::Indexing { indexed, total } => {
                let progress = (indexed as f32) / (total as f32);
                ui.add(ProgressBar::new(progress));
            }
            IndexingStatus::Done => {
                ui.label("Done indexing");
            }
        }
    }

    // TODO consider combining matching_items and highlighted_index
    #[must_use]
    fn launch_entry_area(
        ctx: &egui::Context,
        ui: &mut egui::Ui,
        matching_items: &ItemVec,
        highlighted_index: usize,
        icon_hashmap: &HashMap<String, Icon>,
        match_count: &mut usize,
        mut advance_next_item: bool,
        mut visible_items: i32,
    ) -> Vec<Actions> {
        let mut actions_to_return: Vec<Actions> = Default::default();

        egui::ScrollArea::vertical().show(ui, |ui| {
            for (index, selectable_item) in matching_items
                .iter()
                // we shouldn't need to do a take here as we should only be sending the correct number
                // .take(calculated_visible_items)
                .enumerate()
            {
                // let item_is_selected = selectable_item.is_selected();
                // TODO handle selected inside LaunchEntries
                let item_is_selected = false;
                let item: &LaunchEntry = &selectable_item.lock();

                // save the index for later
                *match_count = index;

                // Track visible items (simplified - assumes all items are visible in scroll area)
                visible_items += 1;

                let visuals = &ui.style().visuals;
                let fill = if index == highlighted_index {
                    // TODO move the tab and enter actions to the new action handling system

                    if ctx.input(|i| i.key_pressed(Key::Tab)) {
                        // TODO handle selected inside LaunchEntries
                        // selectable_item.toggle_selected();
                        advance_next_item = true;
                    }

                    visuals.selection.bg_fill
                } else if item_is_selected {
                    // visuals.widgets.active.bg_fill
                    Color32::YELLOW
                } else {
                    visuals.widgets.inactive.bg_fill
                };

                let frame_response = egui::Frame::new()
                    // Create a frame with background color for selected items
                    .fill(fill)
                    .inner_margin(egui::Margin::same(4))
                    .show(ui, |ui| {
                        ui.set_min_width(ui.available_width());
                        ui.horizontal(|ui| {
                            // Display icon using the icon name from the launch entry
                            if let Some(icon_name) = item.icon_name()
                                && let Some(icon) = icon_hashmap.get(icon_name)
                            {
                                match icon {
                                    Icon::Texture {
                                        path: _path,
                                        name: _name,
                                        texture,
                                    } => {
                                        ui.add(Image::new(ImageSource::Texture(
                                            egui::load::SizedTexture::new(
                                                texture.id(),
                                                egui::Vec2::new(16.0, 16.0),
                                            ),
                                        )));
                                    }
                                    Icon::Error {
                                        path: _path,
                                        name,
                                        error,
                                    } => {
                                        warn!("Could not load icon '{}': {}", name, error);
                                        ui.label("");
                                    }
                                }
                            } else {
                                ui.label(""); // Default icon
                            }

                            ui.label(&item.name);
                            if let Some(comment) = &item.comment() {
                                ui.label(format!("- {comment}"));
                            }
                            ui.label(item.sort_key().to_string())
                        })
                    });

                // Make the frame respond to clicks
                let response = frame_response.response.interact(Sense::click());

                // Handle mouse clicks on the item
                if response.clicked() {
                    // Check if shift is held for secondary action
                    if ui.input(|i| i.modifiers.shift) {
                        actions_to_return.push(Actions::InvokeSecondaryAtAndQuit(index));
                    } else {
                        actions_to_return.push(Actions::InvokeDefaultAtAndQuit(index));
                    }
                }
            }
        });

        actions_to_return
    }
}