use std::cmp::{max, min};
use std::process::exit;
use std::sync::LazyLock;
use freedesktop_desktop_entry::DesktopEntry;
use iced::keyboard::key::Named;
use iced::keyboard::Key;
use iced::widget::button::{primary, text};
use iced::widget::{button, column, scrollable, text_input, Column};
use iced::{event, window, Element, Event, Length, Task, Theme};
use iced_layershell::{to_layer_message, Application};
use crate::PROGRAM_NAME;
static ENTRY_WIDGET_ID: LazyLock<iced::widget::text_input::Id> =
std::sync::LazyLock::new(|| iced::widget::text_input::Id::new("entry"));
static ITEMS_WIDGET_ID: LazyLock<iced::widget::scrollable::Id> =
std::sync::LazyLock::new(|| iced::widget::scrollable::Id::new("items"));
const VIEWABLE_LIST_ITEM_COUNT: usize = 10;
#[derive(Debug)]
pub struct State {
entry: String,
apps: Vec<DesktopEntry>,
selected_index: usize,
received_focus: bool,
}
#[derive(Debug)]
pub struct Elbey {
state: State,
flags: ElbeyFlags,
}
#[to_layer_message]
#[derive(Debug, Clone)]
pub enum ElbeyMessage {
ModelLoaded(Vec<DesktopEntry>),
EntryUpdate(String),
ExecuteSelected(),
KeyEvent(Key),
GainedFocus,
LostFocus,
}
#[derive(Debug, Clone)]
pub struct ElbeyFlags {
pub apps_loader: fn() -> Vec<DesktopEntry>,
pub app_launcher: fn(&DesktopEntry) -> anyhow::Result<()>, }
impl Application for Elbey {
type Message = ElbeyMessage;
type Flags = ElbeyFlags;
type Theme = Theme;
type Executor = iced::executor::Default;
fn new(flags: ElbeyFlags) -> (Self, Task<ElbeyMessage>) {
let load_task = Task::perform(async {}, move |_| {
ElbeyMessage::ModelLoaded((flags.apps_loader)())
});
(
Self {
state: State {
entry: String::new(),
apps: vec![],
selected_index: 0,
received_focus: false,
},
flags,
},
load_task,
)
}
fn namespace(&self) -> String {
PROGRAM_NAME.to_string()
}
fn view(&self) -> Element<'_, ElbeyMessage> {
let app_elements: Vec<Element<ElbeyMessage>> = self
.state
.apps
.iter()
.filter(|e| Self::text_entry_filter(e, &self.state)) .enumerate()
.filter(|(index, _)| {
(self.state.selected_index..self.state.selected_index + VIEWABLE_LIST_ITEM_COUNT)
.contains(index)
}) .map(|(index, entry)| {
let name = entry.desktop_entry("Name").unwrap_or("err");
let selected = self.state.selected_index == index;
button(name)
.style(move |theme, status| {
if selected {
primary(theme, status)
} else {
text(theme, status)
}
})
.width(Length::Fill)
.on_press(ElbeyMessage::ExecuteSelected())
.into()
})
.collect();
column![
text_input("drun", &self.state.entry)
.id(ENTRY_WIDGET_ID.clone())
.on_input(ElbeyMessage::EntryUpdate)
.width(320),
scrollable(Column::with_children(app_elements))
.width(320)
.id(ITEMS_WIDGET_ID.clone()),
]
.into()
}
fn update(&mut self, message: ElbeyMessage) -> Task<ElbeyMessage> {
match message {
ElbeyMessage::ModelLoaded(items) => {
self.state.apps = items;
text_input::focus(ENTRY_WIDGET_ID.clone())
}
ElbeyMessage::EntryUpdate(entry_text) => {
self.state.entry = entry_text;
self.state.selected_index = 0;
Task::none()
}
ElbeyMessage::ExecuteSelected() => {
if let Some(entry) = self.selected_entry() {
(self.flags.app_launcher)(entry).expect("Failed to launch app");
}
Task::none()
}
ElbeyMessage::KeyEvent(key) => match key {
Key::Named(Named::Escape) => exit(0),
Key::Named(Named::ArrowUp) => self.navigate_items(-1),
Key::Named(Named::ArrowDown) => self.navigate_items(1),
Key::Named(Named::PageUp) => {
self.navigate_items(-(VIEWABLE_LIST_ITEM_COUNT as i32))
}
Key::Named(Named::PageDown) => self.navigate_items(VIEWABLE_LIST_ITEM_COUNT as i32),
Key::Named(Named::Enter) => {
if let Some(entry) = self.selected_entry() {
(self.flags.app_launcher)(entry).expect("Failed to launch app");
}
Task::none()
}
_ => Task::none(),
},
ElbeyMessage::GainedFocus => {
self.state.received_focus = true;
text_input::focus(ENTRY_WIDGET_ID.clone())
}
ElbeyMessage::LostFocus => {
if self.state.received_focus {
exit(0);
}
Task::none()
}
ElbeyMessage::AnchorChange(anchor) => {
dbg!(anchor);
Task::none()
}
ElbeyMessage::SetInputRegion(action_callback) => {
dbg!(action_callback);
Task::none()
}
ElbeyMessage::AnchorSizeChange(anchor, _) => {
dbg!(anchor);
Task::none()
}
ElbeyMessage::LayerChange(layer) => {
dbg!(layer);
Task::none()
}
ElbeyMessage::MarginChange(mc) => {
dbg!(mc);
Task::none()
}
ElbeyMessage::SizeChange(sc) => {
dbg!(sc);
Task::none()
}
ElbeyMessage::VirtualKeyboardPressed { time, key } => {
dbg!(time, key);
Task::none()
}
}
}
fn subscription(&self) -> iced::Subscription<ElbeyMessage> {
event::listen_with(|event, _status, _| match event {
Event::Window(window::Event::Focused) => Some(ElbeyMessage::GainedFocus),
Event::Window(window::Event::Unfocused) => Some(ElbeyMessage::LostFocus),
Event::Keyboard(iced::keyboard::Event::KeyPressed {
modifiers: _,
text: _,
key,
location: _,
modified_key: _,
physical_key: _,
}) => Some(ElbeyMessage::KeyEvent(key)),
_ => None,
})
}
fn theme(&self) -> Self::Theme {
Theme::Nord
}
}
impl Elbey {
fn selected_entry(&self) -> Option<&DesktopEntry> {
self.state
.apps
.iter()
.filter(|e| Self::text_entry_filter(e, &self.state))
.nth(self.state.selected_index)
}
fn navigate_items(&mut self, delta: i32) -> iced::Task<ElbeyMessage> {
if delta < 0 {
self.state.selected_index = max(0, self.state.selected_index as i32 + delta) as usize;
} else {
self.state.selected_index = min(
self.state.apps.len() as i32 - 1,
self.state.selected_index as i32 + delta,
) as usize;
}
Task::none()
}
fn text_entry_filter(entry: &DesktopEntry, model: &State) -> bool {
if let Some(name) = entry.desktop_entry("Name") {
name.to_lowercase().contains(&model.entry.to_lowercase())
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
static EMPTY_LOADER: fn() -> Vec<DesktopEntry> = || vec![];
static TEST_DESKTOP_ENTRY_1: LazyLock<DesktopEntry> =
std::sync::LazyLock::new(|| DesktopEntry::from_appid(String::from("test_app_id_1")));
static TEST_DESKTOP_ENTRY_2: LazyLock<DesktopEntry> =
std::sync::LazyLock::new(|| DesktopEntry::from_appid(String::from("test_app_id_2")));
static TEST_DESKTOP_ENTRY_3: LazyLock<DesktopEntry> =
std::sync::LazyLock::new(|| DesktopEntry::from_appid(String::from("test_app_id_3")));
static TEST_ENTRY_LOADER: fn() -> Vec<DesktopEntry> = || {
vec![
TEST_DESKTOP_ENTRY_1.clone(),
TEST_DESKTOP_ENTRY_2.clone(),
TEST_DESKTOP_ENTRY_3.clone(),
]
};
#[test]
fn test_default_app_launch() {
let test_launcher: fn(&DesktopEntry) -> anyhow::Result<()> = |e| {
assert!(e.appid == "test_app_id_1");
Ok(())
};
let (mut unit, _) = Elbey::new(ElbeyFlags {
apps_loader: TEST_ENTRY_LOADER,
app_launcher: test_launcher,
});
let _ = unit.update(ElbeyMessage::ModelLoaded(TEST_ENTRY_LOADER()));
let _ = unit.update(ElbeyMessage::ExecuteSelected());
}
#[test]
fn test_no_apps_try_launch() {
let test_launcher: fn(&DesktopEntry) -> anyhow::Result<()> = |_e| {
assert!(false); Ok(())
};
let (mut unit, _) = Elbey::new(ElbeyFlags {
apps_loader: TEST_ENTRY_LOADER,
app_launcher: test_launcher,
});
let _ = unit.update(ElbeyMessage::ModelLoaded(EMPTY_LOADER()));
let _result = unit.update(ElbeyMessage::ExecuteSelected());
}
#[test]
fn test_app_navigation() {
let test_launcher: fn(&DesktopEntry) -> anyhow::Result<()> = |e| {
assert!(e.appid == "test_app_id_2");
Ok(())
};
let (mut unit, _) = Elbey::new(ElbeyFlags {
apps_loader: TEST_ENTRY_LOADER,
app_launcher: test_launcher,
});
let _ = unit.update(ElbeyMessage::ModelLoaded(TEST_ENTRY_LOADER()));
let _ = unit.update(ElbeyMessage::KeyEvent(Key::Named(Named::ArrowDown)));
let _ = unit.update(ElbeyMessage::KeyEvent(Key::Named(Named::ArrowDown)));
let _ = unit.update(ElbeyMessage::KeyEvent(Key::Named(Named::ArrowUp)));
let _ = unit.update(ElbeyMessage::ExecuteSelected());
}
}