v0.2.0
clean architecture refactor, performance, resilience, DX/UX architecture: - 13 crates with proper domain/application/infrastructure layers - domain crate: newtypes, ports (Plugin, AppLauncher), constants - kernel: pure orchestrator - shared UI state machine (k-launcher-ui-core) - merged plugin-api into domain as ports module - granular file structure (no monolithic lib.rs) - all tests extracted to tests/ directories features: - frecency boost in search results - empty query shows top frecent apps - append-only frecency log with configurable compaction - config-driven styling (all colors, sizes, debounce) - configurable terminal emulator, external plugin timeout - log rotation with max_log_files - loading indicator, descriptive placeholder text - graceful shutdown via iced::exit() + Plugin::shutdown() - --version flag, panic hook, signal handling (SIGINT/SIGTERM) - SpawnInTerminal in external plugin protocol performance: - ~1500 -> ~50 heap allocs per keystroke - reused Matcher, Pattern, char buffer across entries - Arc<str> for shared result fields - pre-filter before fuzzy matching - partial sort for top frecent IDs - cached lowercase names in entries resilience: - parking_lot (no mutex poisoning) - thiserror hierarchy (PluginError, ConfigError, AppError) - all silent error swallowing replaced with tracing::warn - config parse errors logged quality: - named constants (no magic strings/numbers) - named types (no anonymous tuples) - Rgba newtype with validation - domain newtype validation (debug_assert non-empty) - man page, LICENSE (MIT), PKGBUILD, example config - plugin development guide updated - make check (fmt + clippy + test), make dev (RUST_LOG=debug) style: format code for better readability in tests and function signatures fix: update build_entries function signature to ignore frecency parameter fix(review): bugs, arch violations, design smells P1 bugs: - unix_launcher: shell_split respects quoted args (was split_whitespace) - plugin-host: 5s timeout on external plugin search - ui: handle engine init panic, wire error state - ui-egui: read window config instead of always using defaults - plugin-url: use OpenPath action instead of SpawnProcess+xdg-open Architecture: - remove WindowConfig (mirror of WindowCfg); use WindowCfg directly - remove on_select closure from SearchResult (domain leakage) - remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected - apps: record frecency via on_selected instead of embedded closure Design smells: - frecency: extract decay_factor helper, write outside mutex - apps: remove cfg(test) cache_path hack; add new_for_test ctor - apps: stable ResultId using name+exec to prevent collision - files: stable ResultId using full path instead of index - plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone) Update iced dependency in Cargo.toml to disable default features and add additional ones feat(app): enhance engine initialization with EngineHandle and update run function signature feat: production hardening (panic isolation, file logging, apps cache) - Kernel::search wraps each plugin in catch_unwind; panics are logged and return [] - init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/ - AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches - 57 tests pass refactor: remove client module and associated show command logic fix(app): format code for clarity in update function chore: update .gitignore and enhance README with compositor setup instructions chore(docs): remove unused screenshot file feature/prod-ready (#1) Reviewed-on: #1 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants refactor(calc): rename preprocess, extend underscore test assertions feat(calc): strip underscore digit separators feat: update dependencies for improved compatibility and performance feat: add plugin-url for URL handling and open in browser functionality feat: add support for external plugins and enhance plugin management feat: add Makefile for build, run, and installation commands feat: add required features for k-launcher-egui and update dependencies feat: update README and add documentation for installation, configuration, usage, and plugin development feat: enhance configuration management and UI styling, remove unused theme module feat: add k-launcher-config crate for configuration management and integrate with existing components feat: add k-launcher-ui-egui crate for enhanced UI - Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui. - Updated the workspace configuration in `Cargo.toml` to include the new crate. - Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions. - Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI. - Modified the `k-launcher` crate to include a new binary target for the egui-based launcher. - Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components. feat: implement OS bridge and enhance app launcher functionality feat: add FilesPlugin for file searching and integrate into KLauncher feat: implement frecency tracking for app usage and enhance search functionality feat: add CmdPlugin for executing terminal commands and update workspace configuration refactor: update dependencies and improve keyboard event handling in KLauncherApp refactor: simplify theme usage and enhance AppsPlugin structure feat: restructure k-launcher workspace and add core functionality - Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members. - Introduced a README.md file detailing the project philosophy, architecture, and technical specifications. - Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality. - Created a Plugin trait for plugins to implement, allowing for asynchronous search operations. - Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display. - Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively. - Established a theme module for UI styling, focusing on an Aero aesthetic. - Removed unnecessary main.rs files from plugin crates, streamlining the project structure. Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
@@ -1,20 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{
|
||||
Border, Color, Element, Length, Size, Subscription, Task, event,
|
||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||
window,
|
||||
};
|
||||
use iced::{Size, Subscription, Task, event, keyboard::Event as KeyEvent, window};
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_domain::SearchResult;
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_ui_core::LauncherState;
|
||||
|
||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||
pub(crate) static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
|
||||
pub(crate) struct EngineHandle(pub(crate) Arc<Kernel>);
|
||||
|
||||
impl std::fmt::Debug for EngineHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -22,250 +20,22 @@ impl std::fmt::Debug for EngineHandle {
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba(c: &[f32; 4]) -> Color {
|
||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
||||
}
|
||||
|
||||
pub struct KLauncherApp {
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
query: String,
|
||||
results: Arc<Vec<SearchResult>>,
|
||||
selected: usize,
|
||||
cfg: AppearanceCfg,
|
||||
error: Option<String>,
|
||||
search_epoch: u64,
|
||||
}
|
||||
|
||||
impl KLauncherApp {
|
||||
fn new(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
cfg: AppearanceCfg,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
launcher,
|
||||
query: String::new(),
|
||||
results: Arc::new(vec![]),
|
||||
selected: 0,
|
||||
cfg,
|
||||
error: None,
|
||||
search_epoch: 0,
|
||||
}
|
||||
}
|
||||
pub(crate) struct KLauncherApp {
|
||||
pub(crate) inner: LauncherState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
pub(crate) enum Message {
|
||||
QueryChanged(String),
|
||||
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
||||
ResultsReady {
|
||||
epoch: u64,
|
||||
results: Arc<Vec<SearchResult>>,
|
||||
},
|
||||
KeyPressed(KeyEvent),
|
||||
EngineReady(EngineHandle),
|
||||
EngineInitFailed(String),
|
||||
}
|
||||
|
||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::QueryChanged(q) => {
|
||||
state.error = None;
|
||||
state.query = q.clone();
|
||||
state.selected = 0;
|
||||
state.search_epoch += 1;
|
||||
let epoch = state.search_epoch;
|
||||
let engine = state.engine.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
(epoch, engine.search(&q).await)
|
||||
},
|
||||
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
|
||||
)
|
||||
}
|
||||
Message::ResultsReady(epoch, results) => {
|
||||
if epoch == state.search_epoch {
|
||||
state.results = results;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::EngineInitFailed(msg) => {
|
||||
state.error = Some(msg);
|
||||
Task::none()
|
||||
}
|
||||
Message::EngineReady(handle) => {
|
||||
state.engine = handle.0;
|
||||
if !state.query.is_empty() {
|
||||
let q = state.query.clone();
|
||||
return Task::done(Message::QueryChanged(q));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::KeyPressed(event) => {
|
||||
let key = match event {
|
||||
KeyEvent::KeyPressed { key, .. } => key,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let Key::Named(named) = key else {
|
||||
return Task::none();
|
||||
};
|
||||
let len = state.results.len();
|
||||
match named {
|
||||
Named::Escape => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
Named::ArrowDown => {
|
||||
if len > 0 {
|
||||
state.selected = (state.selected + 1).min(len - 1);
|
||||
}
|
||||
}
|
||||
Named::ArrowUp => {
|
||||
if state.selected > 0 {
|
||||
state.selected -= 1;
|
||||
}
|
||||
}
|
||||
Named::Enter => {
|
||||
if let Some(result) = state.results.get(state.selected) {
|
||||
state.engine.on_selected(&result.id);
|
||||
state.launcher.execute(&result.action);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||
let cfg = &state.cfg;
|
||||
let border_color = rgba(&cfg.border_rgba);
|
||||
|
||||
let search_bar = text_input(&cfg.placeholder, &state.query)
|
||||
.id(INPUT_ID.clone())
|
||||
.on_input(Message::QueryChanged)
|
||||
.padding(12)
|
||||
.size(cfg.search_font_size)
|
||||
.style(|theme, _status| {
|
||||
let mut s =
|
||||
iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
|
||||
s.border = Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
};
|
||||
s
|
||||
});
|
||||
|
||||
let row_radius: f32 = cfg.row_radius;
|
||||
let title_size: f32 = cfg.title_size;
|
||||
let desc_size: f32 = cfg.desc_size;
|
||||
|
||||
let result_rows: Vec<Element<'_, Message>> = state
|
||||
.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, result)| {
|
||||
let is_selected = i == state.selected;
|
||||
let bg_color = if is_selected {
|
||||
border_color
|
||||
} else {
|
||||
Color::from_rgba8(255, 255, 255, 0.07)
|
||||
};
|
||||
let icon_el: Element<'_, Message> = match &result.icon {
|
||||
Some(p) if p.ends_with(".svg") => {
|
||||
svg(svg::Handle::from_path(p)).width(24).height(24).into()
|
||||
}
|
||||
Some(p) => image(image::Handle::from_path(p))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.into(),
|
||||
None => Space::new().width(24).height(24).into(),
|
||||
};
|
||||
let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
|
||||
column![
|
||||
text(result.title.as_str()).size(title_size),
|
||||
text(desc)
|
||||
.size(desc_size)
|
||||
.color(Color::from_rgba8(210, 215, 230, 1.0)),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
text(result.title.as_str()).size(title_size).into()
|
||||
};
|
||||
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
|
||||
.width(Length::Fill)
|
||||
.padding([6, 12])
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg_color)),
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: row_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results_list = if state.results.is_empty() && !state.query.is_empty() {
|
||||
scrollable(
|
||||
container(
|
||||
text("No results")
|
||||
.size(title_size)
|
||||
.color(Color::from_rgba8(180, 180, 200, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding([20, 0]),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
} else {
|
||||
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
|
||||
};
|
||||
|
||||
let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
|
||||
container(
|
||||
text(msg.as_str())
|
||||
.size(12.0)
|
||||
.color(Color::from_rgba8(255, 80, 80, 1.0)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 12])
|
||||
.into()
|
||||
});
|
||||
|
||||
let mut content_children: Vec<Element<'_, Message>> =
|
||||
vec![search_bar.into(), results_list.into()];
|
||||
if let Some(err) = maybe_error {
|
||||
content_children.push(err);
|
||||
}
|
||||
let content = column(content_children)
|
||||
.spacing(8)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let bg_color = rgba(&cfg.background_rgba);
|
||||
let border_width = cfg.border_width;
|
||||
let border_radius = cfg.border_radius;
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg_color)),
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: border_width,
|
||||
radius: border_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
||||
event::listen_with(|ev, _status, _id| match ev {
|
||||
iced::Event::Keyboard(ke) => Some(Message::KeyPressed(ke)),
|
||||
@@ -274,18 +44,16 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
debounce_ms: u64,
|
||||
) -> iced::Result {
|
||||
iced::application(
|
||||
move || {
|
||||
let app = KLauncherApp::new(
|
||||
Arc::new(NullSearchEngine),
|
||||
launcher.clone(),
|
||||
appearance_cfg.clone(),
|
||||
);
|
||||
let inner = LauncherState::new(launcher.clone(), appearance_cfg.clone(), debounce_ms);
|
||||
let app = KLauncherApp { inner };
|
||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||
let ef = engine_factory.clone();
|
||||
let init = Task::perform(
|
||||
@@ -301,10 +69,10 @@ pub fn run(
|
||||
);
|
||||
(app, Task::batch([focus, init]))
|
||||
},
|
||||
update,
|
||||
view,
|
||||
crate::update::update,
|
||||
crate::view::view,
|
||||
)
|
||||
.title("K-Launcher")
|
||||
.title(k_launcher_domain::constants::APP_TITLE)
|
||||
.subscription(subscription)
|
||||
.window(window::Settings {
|
||||
size: Size::new(window_cfg.width, window_cfg.height),
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
mod app;
|
||||
mod style;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_config::{AppearanceCfg, WindowCfg};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||
use k_launcher_config::{AppearanceCfg, SearchCfg, WindowCfg};
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_kernel::Kernel;
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> iced::Result {
|
||||
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
|
||||
search_cfg: &SearchCfg,
|
||||
) -> Result<(), String> {
|
||||
app::run(
|
||||
engine_factory,
|
||||
launcher,
|
||||
window_cfg,
|
||||
appearance_cfg,
|
||||
search_cfg.debounce_ms,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
60
crates/k-launcher-ui/src/style.rs
Normal file
60
crates/k-launcher-ui/src/style.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use iced::{Border, Color, widget::container, widget::text_input};
|
||||
|
||||
// ---- layout constants ----
|
||||
pub(crate) const ROW_PADDING: [u16; 2] = [6, 12];
|
||||
pub(crate) const ROW_SPACING: f32 = 2.0;
|
||||
pub(crate) const CONTENT_PADDING: u16 = 12;
|
||||
pub(crate) const CONTENT_SPACING: f32 = 8.0;
|
||||
pub(crate) const EMPTY_STATE_PADDING: [u16; 2] = [20, 0];
|
||||
pub(crate) const ERROR_FONT_SIZE: f32 = 12.0;
|
||||
pub(crate) const ERROR_PADDING: [u16; 2] = [4, 12];
|
||||
|
||||
// ---- helpers ----
|
||||
pub(crate) fn rgba(c: &k_launcher_config::Rgba) -> Color {
|
||||
Color::from_rgba8(c.red_u8(), c.green_u8(), c.blue_u8(), c.alpha())
|
||||
}
|
||||
|
||||
pub(crate) fn search_input_style(
|
||||
theme: &iced::Theme,
|
||||
_status: text_input::Status,
|
||||
) -> text_input::Style {
|
||||
let mut s = text_input::default(theme, text_input::Status::Active);
|
||||
s.border = Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn result_row_style(
|
||||
bg_color: Color,
|
||||
row_radius: f32,
|
||||
) -> impl Fn(&iced::Theme) -> container::Style {
|
||||
move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg_color)),
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: row_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn outer_container_style(
|
||||
bg: Color,
|
||||
border_color: Color,
|
||||
width: f32,
|
||||
radius: f32,
|
||||
) -> impl Fn(&iced::Theme) -> container::Style {
|
||||
move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg)),
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width,
|
||||
radius: radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
77
crates/k-launcher-ui/src/update.rs
Normal file
77
crates/k-launcher-ui/src/update.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{
|
||||
Task,
|
||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||
};
|
||||
|
||||
use k_launcher_ui_core::{Action, Effect};
|
||||
|
||||
use crate::app::{KLauncherApp, Message};
|
||||
|
||||
pub(crate) fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
let action = match message {
|
||||
Message::QueryChanged(q) => Action::QueryChanged(q),
|
||||
Message::ResultsReady { epoch, results } => {
|
||||
let results = Arc::try_unwrap(results).unwrap_or_else(|arc| (*arc).clone());
|
||||
Action::ResultsReady { epoch, results }
|
||||
}
|
||||
Message::EngineInitFailed(msg) => Action::EngineInitFailed(msg),
|
||||
Message::EngineReady(handle) => Action::EngineReady(handle.0),
|
||||
Message::KeyPressed(event) => match map_key_event(event) {
|
||||
Some(a) => a,
|
||||
None => return Task::none(),
|
||||
},
|
||||
};
|
||||
|
||||
let effect = state.inner.handle(action);
|
||||
execute_effect(state, effect)
|
||||
}
|
||||
|
||||
fn map_key_event(event: KeyEvent) -> Option<Action> {
|
||||
let key = match event {
|
||||
KeyEvent::KeyPressed { key, .. } => key,
|
||||
_ => return None,
|
||||
};
|
||||
let Key::Named(named) = key else {
|
||||
return None;
|
||||
};
|
||||
match named {
|
||||
Named::Escape => Some(Action::Exit),
|
||||
Named::ArrowDown => Some(Action::MoveDown),
|
||||
Named::ArrowUp => Some(Action::MoveUp),
|
||||
Named::Enter => Some(Action::LaunchSelected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_effect(state: &KLauncherApp, effect: Effect) -> Task<Message> {
|
||||
match effect {
|
||||
Effect::SearchAfterDebounce {
|
||||
query,
|
||||
debounce_ms,
|
||||
epoch,
|
||||
} => {
|
||||
let Some(engine) = state.inner.engine().cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
|
||||
(epoch, engine.search(&query).await)
|
||||
},
|
||||
|(epoch, results)| Message::ResultsReady {
|
||||
epoch,
|
||||
results: Arc::new(results),
|
||||
},
|
||||
)
|
||||
}
|
||||
Effect::LaunchAndExit(action) => {
|
||||
state.inner.launcher().execute(&action);
|
||||
iced::exit()
|
||||
}
|
||||
Effect::Exit => iced::exit(),
|
||||
Effect::TriggerSearch(q) => Task::done(Message::QueryChanged(q)),
|
||||
Effect::None => Task::none(),
|
||||
}
|
||||
}
|
||||
164
crates/k-launcher-ui/src/view.rs
Normal file
164
crates/k-launcher-ui/src/view.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{
|
||||
Element, Length,
|
||||
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||
};
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::SearchResult;
|
||||
|
||||
use crate::app::{INPUT_ID, KLauncherApp, Message};
|
||||
use crate::style;
|
||||
|
||||
pub(crate) fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||
let cfg = state.inner.cfg();
|
||||
|
||||
let mut content_children: Vec<Element<'_, Message>> =
|
||||
vec![search_bar(cfg, state.inner.query()), results_section(state)];
|
||||
if let Some(err) = state.inner.error() {
|
||||
content_children.push(error_bar(err, cfg));
|
||||
}
|
||||
|
||||
let content = column(content_children)
|
||||
.spacing(style::CONTENT_SPACING)
|
||||
.padding(style::CONTENT_PADDING)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(style::outer_container_style(
|
||||
style::rgba(&cfg.background_rgba),
|
||||
style::rgba(&cfg.border_rgba),
|
||||
cfg.border_width,
|
||||
cfg.border_radius,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn search_bar<'a>(cfg: &'a AppearanceCfg, query: &'a str) -> Element<'a, Message> {
|
||||
text_input(&cfg.placeholder, query)
|
||||
.id(INPUT_ID.clone())
|
||||
.on_input(Message::QueryChanged)
|
||||
.padding(style::CONTENT_PADDING)
|
||||
.size(cfg.search_font_size)
|
||||
.style(style::search_input_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn results_section<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||
if state.inner.is_loading() {
|
||||
loading_state(state.inner.cfg())
|
||||
} else if state.inner.results().is_empty() && !state.inner.query().is_empty() {
|
||||
empty_state(state.inner.cfg())
|
||||
} else {
|
||||
result_list(state)
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||
container(
|
||||
text("Loading...")
|
||||
.size(cfg.title_size)
|
||||
.color(style::rgba(&cfg.no_results_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding(style::EMPTY_STATE_PADDING)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn empty_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||
scrollable(
|
||||
container(
|
||||
text("No results")
|
||||
.size(cfg.title_size)
|
||||
.color(style::rgba(&cfg.no_results_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding(style::EMPTY_STATE_PADDING),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_list<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||
let cfg = state.inner.cfg();
|
||||
let selected = state.inner.selected();
|
||||
let rows: Vec<Element<'_, Message>> = state
|
||||
.inner
|
||||
.results()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, result)| result_row(result, i == selected, cfg))
|
||||
.collect();
|
||||
|
||||
scrollable(column(rows).spacing(style::ROW_SPACING).width(Length::Fill))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_row<'a>(
|
||||
result: &'a SearchResult,
|
||||
is_selected: bool,
|
||||
cfg: &AppearanceCfg,
|
||||
) -> Element<'a, Message> {
|
||||
let bg_color = if is_selected {
|
||||
style::rgba(&cfg.selected_row_rgba)
|
||||
} else {
|
||||
style::rgba(&cfg.unselected_row_rgba)
|
||||
};
|
||||
|
||||
container(
|
||||
row![result_icon(&result.icon, cfg), title_column(result, cfg)]
|
||||
.spacing(style::CONTENT_SPACING)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(style::ROW_PADDING)
|
||||
.style(style::result_row_style(bg_color, cfg.row_radius))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_icon<'a>(icon_path: &'a Option<Arc<str>>, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
let size = cfg.icon_size;
|
||||
match icon_path {
|
||||
Some(p) if p.ends_with(".svg") => svg(svg::Handle::from_path(p.as_ref()))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.into(),
|
||||
Some(p) => image(image::Handle::from_path(p.as_ref()))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.into(),
|
||||
None => Space::new().width(size).height(size).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_column<'a>(result: &'a SearchResult, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
if let Some(desc) = &result.description {
|
||||
column![
|
||||
text(result.title.as_str()).size(cfg.title_size),
|
||||
text(desc.as_ref())
|
||||
.size(cfg.desc_size)
|
||||
.color(style::rgba(&cfg.description_rgba)),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
text(result.title.as_str()).size(cfg.title_size).into()
|
||||
}
|
||||
}
|
||||
|
||||
fn error_bar<'a>(msg: &'a str, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
container(
|
||||
text(msg)
|
||||
.size(style::ERROR_FONT_SIZE)
|
||||
.color(style::rgba(&cfg.error_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(style::ERROR_PADDING)
|
||||
.into()
|
||||
}
|
||||
Reference in New Issue
Block a user