free_launch/
ui.rs

1use gdk4::Display;
2use gtk4::prelude::*;
3use gtk4::{
4    Application, ApplicationWindow, Box, Entry, Image, Label as GtkLabel, ListBox, ListBoxRow,
5    ScrolledWindow, glib,
6};
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::rc::Rc;
10
11use crate::item_list::load_all_items;
12use crate::launch_item::LaunchItem;
13
14// Custom ListBoxRow that holds a LaunchItem
15#[derive(Debug)]
16pub struct LaunchItemRow {
17    row: ListBoxRow,
18    item: LaunchItem,
19}
20
21impl LaunchItemRow {
22    pub fn new(item: LaunchItem) -> Self {
23        let row = ListBoxRow::new();
24        let hbox = Box::new(gtk4::Orientation::Horizontal, 12);
25        hbox.set_margin_top(8);
26        hbox.set_margin_bottom(8);
27        hbox.set_margin_start(12);
28        hbox.set_margin_end(12);
29
30        // Create icon
31        let icon = if let Some(icon_name) = &item.icon_name {
32            Image::from_icon_name(icon_name)
33        } else {
34            Image::from_icon_name("application-x-executable")
35        };
36        icon.set_pixel_size(32);
37
38        // Create text container
39        let vbox = Box::new(gtk4::Orientation::Vertical, 2);
40
41        let name_label = GtkLabel::new(Some(&item.name));
42        name_label.set_halign(gtk4::Align::Start);
43        name_label.set_markup(&format!("<b>{}</b>", glib::markup_escape_text(&item.name)));
44
45        let desc_label = GtkLabel::new(Some(&item.description));
46        desc_label.set_halign(gtk4::Align::Start);
47        desc_label.add_css_class("dim-label");
48        desc_label.set_ellipsize(gtk4::pango::EllipsizeMode::End);
49
50        vbox.append(&name_label);
51        vbox.append(&desc_label);
52
53        hbox.append(&icon);
54        hbox.append(&vbox);
55
56        row.set_child(Some(&hbox));
57
58        Self { row, item }
59    }
60
61    pub fn list_box_row(&self) -> &ListBoxRow {
62        &self.row
63    }
64
65    pub fn launch_item(&self) -> &LaunchItem {
66        &self.item
67    }
68}
69
70pub fn build_ui(app: &Application) {
71    // Create a window and set the application
72    let window = ApplicationWindow::builder()
73        .application(app)
74        .title("Free Launch")
75        .default_width(600)
76        .default_height(400)
77        .resizable(true)
78        .build();
79
80    // Center the window on the primary display
81    center_window(&window);
82
83    // Create main container
84    let content_box = Box::new(gtk4::Orientation::Vertical, 12);
85    content_box.set_margin_top(20);
86    content_box.set_margin_bottom(20);
87    content_box.set_margin_start(20);
88    content_box.set_margin_end(20);
89
90    // Create search entry
91    let search_entry = Entry::builder()
92        .placeholder_text("Type to search applications...")
93        .build();
94
95    // Create scrolled window for the list
96    let scrolled_window = ScrolledWindow::builder()
97        .hscrollbar_policy(gtk4::PolicyType::Never)
98        .vscrollbar_policy(gtk4::PolicyType::Automatic)
99        .vexpand(true)
100        .build();
101
102    // Create list box for items
103    let list_box = ListBox::new();
104    list_box.set_selection_mode(gtk4::SelectionMode::Single);
105    scrolled_window.set_child(Some(&list_box));
106
107    // Load all available items
108    let items = Rc::new(RefCell::new(load_all_items()));
109    let filtered_items = Rc::new(RefCell::new(Vec::new()));
110    let launch_rows = Rc::new(RefCell::new(HashMap::<u64, LaunchItemRow>::new()));
111
112    // Populate initial list
113    populate_list(
114        &list_box,
115        &items.borrow(),
116        &mut filtered_items.borrow_mut(),
117        &mut launch_rows.borrow_mut(),
118    );
119
120    // Select first item by default
121    if let Some(first_row) = list_box.first_child() {
122        if let Ok(row) = first_row.downcast::<ListBoxRow>() {
123            list_box.select_row(Some(&row));
124        }
125    }
126
127    // Set up search functionality
128    let list_box_clone = list_box.clone();
129    let items_clone = items.clone();
130    let filtered_items_clone = filtered_items.clone();
131    let launch_rows_clone = launch_rows.clone();
132
133    search_entry.connect_changed(move |entry| {
134        let query = entry.text().to_lowercase();
135        filter_and_update_list(
136            &list_box_clone,
137            &items_clone.borrow(),
138            &mut filtered_items_clone.borrow_mut(),
139            &mut launch_rows_clone.borrow_mut(),
140            &query,
141        );
142    });
143
144    // Add widgets to container
145    content_box.append(&search_entry);
146    content_box.append(&scrolled_window);
147    window.set_child(Some(&content_box));
148
149    // Set up click handler for list box
150    setup_list_box_click_handler(&list_box, &window, launch_rows.clone());
151
152    // Set up keyboard shortcuts
153    setup_keyboard_shortcuts(&window, &search_entry, &list_box, launch_rows.clone());
154    setup_search_entry_shortcuts(&search_entry, &list_box, &window, launch_rows.clone());
155    setup_search_entry_activate(&search_entry, &list_box, &window, launch_rows);
156
157    // Focus the search entry
158    search_entry.grab_focus();
159
160    // Present window
161    window.present();
162}
163
164fn center_window(window: &ApplicationWindow) {
165    // Get the default display
166    if let Some(display) = Display::default() {
167        // Get the primary monitor
168        if let Some(monitor) = display.monitors().item(0) {
169            if let Some(monitor) = monitor.downcast_ref::<gdk4::Monitor>() {
170                let geometry = monitor.geometry();
171                let window_width = geometry.width() / 2;
172                let window_height = geometry.height() / 2;
173
174                // Note: GTK4 doesn't allow direct positioning, but we can suggest
175                // the window manager to center it by setting the default size
176                window.set_default_size(window_width, window_height);
177            }
178        }
179    }
180}
181
182fn populate_list(
183    list_box: &ListBox,
184    items: &[LaunchItem],
185    filtered_items: &mut Vec<LaunchItem>,
186    launch_rows: &mut HashMap<u64, LaunchItemRow>,
187) {
188    // Clear existing items
189    while let Some(child) = list_box.first_child() {
190        list_box.remove(&child);
191    }
192
193    filtered_items.clear();
194    filtered_items.extend_from_slice(items);
195    launch_rows.clear();
196
197    // Add items to list
198    for item in items {
199        let launch_row = LaunchItemRow::new(item.clone());
200        let row_ptr = launch_row.list_box_row().as_ptr() as u64;
201        list_box.append(launch_row.list_box_row());
202        launch_rows.insert(row_ptr, launch_row);
203    }
204}
205
206fn filter_and_update_list(
207    list_box: &ListBox,
208    items: &[LaunchItem],
209    filtered_items: &mut Vec<LaunchItem>,
210    launch_rows: &mut HashMap<u64, LaunchItemRow>,
211    query: &str,
212) {
213    // Clear existing items
214    while let Some(child) = list_box.first_child() {
215        list_box.remove(&child);
216    }
217
218    filtered_items.clear();
219    launch_rows.clear();
220
221    // Filter items based on query
222    for item in items {
223        if query.is_empty()
224            || item.name.to_lowercase().contains(query)
225            || item.description.to_lowercase().contains(query)
226        {
227            filtered_items.push(item.clone());
228        }
229    }
230
231    // Add filtered items to list
232    for item in filtered_items.iter() {
233        let launch_row = LaunchItemRow::new(item.clone());
234        let row_ptr = launch_row.list_box_row().as_ptr() as u64;
235        list_box.append(launch_row.list_box_row());
236        launch_rows.insert(row_ptr, launch_row);
237    }
238
239    // Select first item if available
240    if let Some(first_row) = list_box.first_child() {
241        if let Ok(row) = first_row.downcast::<ListBoxRow>() {
242            list_box.select_row(Some(&row));
243        }
244    }
245}
246
247fn setup_keyboard_shortcuts(
248    window: &ApplicationWindow,
249    search_entry: &Entry,
250    list_box: &ListBox,
251    launch_rows: Rc<RefCell<HashMap<u64, LaunchItemRow>>>,
252) {
253    // Create event controller for key events
254    let key_controller = gtk4::EventControllerKey::new();
255
256    // Clone references for the closure
257    let window_clone = window.clone();
258    let list_box_clone = list_box.clone();
259    let launch_rows_clone = launch_rows.clone();
260
261    key_controller.connect_key_pressed(move |_controller, key, _code, modifier| {
262        // Check for Ctrl+Q or Ctrl+W to quit
263        if modifier.contains(gdk4::ModifierType::CONTROL_MASK) {
264            match key {
265                gdk4::Key::q | gdk4::Key::Q | gdk4::Key::w | gdk4::Key::W => {
266                    window_clone.close();
267                    return glib::Propagation::Stop;
268                }
269                _ => {}
270            }
271        }
272
273        // Handle navigation keys
274        match key {
275            gdk4::Key::Down => {
276                if let Some(selected) = list_box_clone.selected_row() {
277                    if let Some(next) = selected.next_sibling() {
278                        if let Ok(next_row) = next.downcast::<ListBoxRow>() {
279                            list_box_clone.select_row(Some(&next_row));
280                        }
281                    }
282                }
283                return glib::Propagation::Stop;
284            }
285            gdk4::Key::Up => {
286                if let Some(selected) = list_box_clone.selected_row() {
287                    if let Some(prev) = selected.prev_sibling() {
288                        if let Ok(prev_row) = prev.downcast::<ListBoxRow>() {
289                            list_box_clone.select_row(Some(&prev_row));
290                        }
291                    }
292                }
293                return glib::Propagation::Stop;
294            }
295            gdk4::Key::Page_Up => {
296                handle_page_up(&list_box_clone);
297                return glib::Propagation::Stop;
298            }
299            gdk4::Key::Page_Down => {
300                handle_page_down(&list_box_clone);
301                return glib::Propagation::Stop;
302            }
303            gdk4::Key::Return | gdk4::Key::KP_Enter => {
304                // Launch selected item
305                eprintln!("Launching item...");
306                if let Some(selected) = list_box_clone.selected_row() {
307                    eprintln!("Found a selected item...");
308                    let row_ptr = selected.as_ptr() as u64;
309                    if let Some(launch_row) = launch_rows_clone.borrow().get(&row_ptr) {
310                        eprintln!("Calling launch on item...");
311                        launch_row.launch_item().launch();
312                        std::process::exit(0);
313                    }
314                }
315                window_clone.close();
316                return glib::Propagation::Stop;
317            }
318            gdk4::Key::Escape => {
319                window_clone.close();
320                return glib::Propagation::Stop;
321            }
322            _ => {}
323        }
324
325        glib::Propagation::Proceed
326    });
327
328    window.add_controller(key_controller);
329}
330
331fn setup_search_entry_shortcuts(
332    search_entry: &Entry,
333    list_box: &ListBox,
334    window: &ApplicationWindow,
335    launch_rows: Rc<RefCell<HashMap<u64, LaunchItemRow>>>,
336) {
337    let key_controller = gtk4::EventControllerKey::new();
338
339    let list_box_clone = list_box.clone();
340    let window_clone = window.clone();
341    let launch_rows_clone = launch_rows.clone();
342
343    key_controller.connect_key_pressed(move |_controller, key, _code, _modifier| {
344        match key {
345            gdk4::Key::Return | gdk4::Key::KP_Enter => {
346                // Launch selected item
347                if let Some(selected) = list_box_clone.selected_row() {
348                    let row_ptr = selected.as_ptr() as u64;
349                    if let Some(launch_row) = launch_rows_clone.borrow().get(&row_ptr) {
350                        launch_row.launch_item().launch();
351                        std::process::exit(0);
352                    }
353                }
354                return glib::Propagation::Stop;
355            }
356            gdk4::Key::Down => {
357                if let Some(selected) = list_box_clone.selected_row() {
358                    if let Some(next) = selected.next_sibling() {
359                        if let Ok(next_row) = next.downcast::<ListBoxRow>() {
360                            list_box_clone.select_row(Some(&next_row));
361                        }
362                    }
363                }
364                return glib::Propagation::Stop;
365            }
366            gdk4::Key::Up => {
367                if let Some(selected) = list_box_clone.selected_row() {
368                    if let Some(prev) = selected.prev_sibling() {
369                        if let Ok(prev_row) = prev.downcast::<ListBoxRow>() {
370                            list_box_clone.select_row(Some(&prev_row));
371                        }
372                    }
373                }
374                return glib::Propagation::Stop;
375            }
376            gdk4::Key::Page_Up => {
377                handle_page_up(&list_box_clone);
378                return glib::Propagation::Stop;
379            }
380            gdk4::Key::Page_Down => {
381                handle_page_down(&list_box_clone);
382                return glib::Propagation::Stop;
383            }
384            gdk4::Key::Escape => {
385                window_clone.close();
386                return glib::Propagation::Stop;
387            }
388            _ => {}
389        }
390
391        glib::Propagation::Proceed
392    });
393
394    search_entry.add_controller(key_controller);
395}
396
397fn setup_list_box_click_handler(
398    list_box: &ListBox,
399    window: &ApplicationWindow,
400    launch_rows: Rc<RefCell<HashMap<u64, LaunchItemRow>>>,
401) {
402    let window_clone = window.clone();
403    let launch_rows_clone = launch_rows.clone();
404
405    list_box.connect_row_activated(move |_list_box, row| {
406        let row_ptr = row.as_ptr() as u64;
407        if let Some(launch_row) = launch_rows_clone.borrow().get(&row_ptr) {
408            launch_row.launch_item().launch();
409            std::process::exit(0);
410        }
411    });
412}
413
414fn setup_search_entry_activate(
415    search_entry: &Entry,
416    list_box: &ListBox,
417    window: &ApplicationWindow,
418    launch_rows: Rc<RefCell<HashMap<u64, LaunchItemRow>>>,
419) {
420    let list_box_clone = list_box.clone();
421    let window_clone = window.clone();
422    let launch_rows_clone = launch_rows.clone();
423
424    search_entry.connect_activate(move |_entry| {
425        // Launch selected item when Enter is pressed in search entry
426        if let Some(selected) = list_box_clone.selected_row() {
427            let row_ptr = selected.as_ptr() as u64;
428            if let Some(launch_row) = launch_rows_clone.borrow().get(&row_ptr) {
429                launch_row.launch_item().launch();
430                std::process::exit(0);
431            }
432        }
433    });
434}
435
436fn handle_page_up(list_box: &ListBox) {
437    // Get the first and last visible rows
438    let (first_visible, last_visible) = get_visible_row_range(list_box);
439
440    if first_visible.is_none() || last_visible.is_none() {
441        return; // No items visible
442    }
443
444    let selected = list_box.selected_row();
445    let first_child = list_box.first_child();
446
447    // If no selection or already at first item, try to scroll up
448    let first_child_as_row = first_child.and_then(|w| w.downcast::<ListBoxRow>().ok());
449    if selected.is_none() || (selected == first_child_as_row) {
450        // Calculate how many items to scroll by (one page worth)
451        let visible_count = count_visible_rows(list_box);
452        if visible_count > 0 {
453            scroll_up_by_page(list_box, visible_count);
454        }
455    } else {
456        // Move selection to first visible item
457        if let Some(first_visible_row) = first_visible {
458            list_box.select_row(Some(&first_visible_row));
459        }
460    }
461}
462
463fn handle_page_down(list_box: &ListBox) {
464    // Get the first and last visible rows
465    let (first_visible, last_visible) = get_visible_row_range(list_box);
466
467    if first_visible.is_none() || last_visible.is_none() {
468        return; // No items visible
469    }
470
471    let selected = list_box.selected_row();
472    let last_child = get_last_child(list_box);
473
474    // If no selection or already at last item, try to scroll down
475    if selected.is_none() || (selected == last_child) {
476        // Calculate how many items to scroll by (one page worth)
477        let visible_count = count_visible_rows(list_box);
478        if visible_count > 0 {
479            scroll_down_by_page(list_box, visible_count);
480        }
481    } else {
482        // Move selection to last visible item
483        if let Some(last_visible_row) = last_visible {
484            list_box.select_row(Some(&last_visible_row));
485        }
486    }
487}
488
489fn get_visible_row_range(list_box: &ListBox) -> (Option<ListBoxRow>, Option<ListBoxRow>) {
490    // Get the scrolled window parent to check visibility
491    let mut parent = list_box.parent();
492    let scrolled_window = loop {
493        match parent {
494            Some(ref p) if p.type_().name() == "GtkScrolledWindow" => {
495                break p.clone();
496            }
497            Some(p) => {
498                parent = p.parent();
499            }
500            None => return (None, None),
501        }
502    };
503
504    let allocation = scrolled_window.allocation();
505    let scroll_height = allocation.height();
506
507    let mut first_visible = None;
508    let mut last_visible = None;
509    let mut current_child = list_box.first_child();
510
511    while let Some(child) = current_child {
512        if let Some(row) = child.downcast_ref::<ListBoxRow>() {
513            let row_allocation = row.allocation();
514            let row_y = row_allocation.y();
515            let row_height = row_allocation.height();
516
517            // Check if row is visible (intersects with scrolled window viewport)
518            if row_y + row_height > 0 && row_y < scroll_height {
519                if first_visible.is_none() {
520                    first_visible = Some(row.clone());
521                }
522                last_visible = Some(row.clone());
523            }
524        }
525        current_child = child.next_sibling();
526    }
527
528    (first_visible, last_visible)
529}
530
531fn count_visible_rows(list_box: &ListBox) -> i32 {
532    let (first_visible, _) = get_visible_row_range(list_box);
533    if first_visible.is_none() {
534        return 0;
535    }
536
537    // Get the scrolled window parent to check visibility
538    let mut parent = list_box.parent();
539    let scrolled_window = loop {
540        match parent {
541            Some(ref p) if p.type_().name() == "GtkScrolledWindow" => {
542                break p.clone();
543            }
544            Some(p) => {
545                parent = p.parent();
546            }
547            None => return 0,
548        }
549    };
550
551    let allocation = scrolled_window.allocation();
552    let scroll_height = allocation.height();
553
554    let mut count = 0;
555    let mut current_child = list_box.first_child();
556
557    while let Some(child) = current_child {
558        if let Some(row) = child.downcast_ref::<ListBoxRow>() {
559            let row_allocation = row.allocation();
560            let row_y = row_allocation.y();
561            let row_height = row_allocation.height();
562
563            // Check if row is visible
564            if row_y + row_height > 0 && row_y < scroll_height {
565                count += 1;
566            }
567        }
568        current_child = child.next_sibling();
569    }
570
571    count
572}
573
574fn scroll_up_by_page(list_box: &ListBox, page_size: i32) {
575    let selected = list_box.selected_row();
576    let mut target_row = None;
577    let mut steps = 0;
578
579    if let Some(current) = selected {
580        let mut current_row = Some(current);
581
582        // Move up by page_size steps or until we reach the first item
583        while steps < page_size && current_row.is_some() {
584            if let Some(prev) = current_row.as_ref().and_then(|r| r.prev_sibling()) {
585                if let Ok(prev_row) = prev.downcast::<ListBoxRow>() {
586                    current_row = Some(prev_row);
587                    steps += 1;
588                } else {
589                    break;
590                }
591            } else {
592                break;
593            }
594        }
595
596        target_row = current_row;
597    } else {
598        // No selection, select first item
599        if let Some(first) = list_box.first_child() {
600            if let Ok(first_row) = first.downcast::<ListBoxRow>() {
601                target_row = Some(first_row);
602            }
603        }
604    }
605
606    if let Some(row) = target_row {
607        list_box.select_row(Some(&row));
608    }
609}
610
611fn scroll_down_by_page(list_box: &ListBox, page_size: i32) {
612    let selected = list_box.selected_row();
613    let mut target_row = None;
614    let mut steps = 0;
615
616    if let Some(current) = selected {
617        let mut current_row = Some(current);
618
619        // Move down by page_size steps or until we reach the last item
620        while steps < page_size && current_row.is_some() {
621            if let Some(next) = current_row.as_ref().and_then(|r| r.next_sibling()) {
622                if let Ok(next_row) = next.downcast::<ListBoxRow>() {
623                    current_row = Some(next_row);
624                    steps += 1;
625                } else {
626                    break;
627                }
628            } else {
629                break;
630            }
631        }
632
633        target_row = current_row;
634    } else {
635        // No selection, select last item
636        target_row = get_last_child(list_box);
637    }
638
639    if let Some(row) = target_row {
640        list_box.select_row(Some(&row));
641    }
642}
643
644fn get_last_child(list_box: &ListBox) -> Option<ListBoxRow> {
645    let mut current = list_box.first_child();
646    let mut last_row = None;
647
648    while let Some(child) = current {
649        let next_sibling = child.next_sibling();
650        if let Ok(row) = child.downcast::<ListBoxRow>() {
651            last_row = Some(row);
652        }
653        current = next_sibling;
654    }
655
656    last_row
657}