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:
9
crates/k-launcher-ui-core/Cargo.toml
Normal file
9
crates/k-launcher-ui-core/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "k-launcher-ui-core"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-launcher-config = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
k-launcher-kernel = { workspace = true }
|
||||
171
crates/k-launcher-ui-core/src/lib.rs
Normal file
171
crates/k-launcher-ui-core/src/lib.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_domain::{LaunchAction, SearchResult};
|
||||
use k_launcher_kernel::Kernel;
|
||||
|
||||
pub struct LauncherState {
|
||||
query: String,
|
||||
results: Vec<SearchResult>,
|
||||
selected: usize,
|
||||
engine: Option<Arc<Kernel>>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
cfg: AppearanceCfg,
|
||||
debounce_ms: u64,
|
||||
search_epoch: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub enum Action {
|
||||
QueryChanged(String),
|
||||
MoveDown,
|
||||
MoveUp,
|
||||
LaunchSelected,
|
||||
Exit,
|
||||
EngineReady(Arc<Kernel>),
|
||||
EngineInitFailed(String),
|
||||
ResultsReady {
|
||||
epoch: u64,
|
||||
results: Vec<SearchResult>,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum Effect {
|
||||
SearchAfterDebounce {
|
||||
query: String,
|
||||
debounce_ms: u64,
|
||||
epoch: u64,
|
||||
},
|
||||
LaunchAndExit(LaunchAction),
|
||||
Exit,
|
||||
TriggerSearch(String),
|
||||
None,
|
||||
}
|
||||
|
||||
impl LauncherState {
|
||||
pub fn new(launcher: Arc<dyn AppLauncher>, cfg: AppearanceCfg, debounce_ms: u64) -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
results: vec![],
|
||||
selected: 0,
|
||||
engine: None,
|
||||
launcher,
|
||||
cfg,
|
||||
debounce_ms,
|
||||
search_epoch: 0,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, action: Action) -> Effect {
|
||||
match action {
|
||||
Action::QueryChanged(q) => {
|
||||
self.query = q;
|
||||
self.selected = 0;
|
||||
self.error = None;
|
||||
|
||||
let Some(_engine) = &self.engine else {
|
||||
return Effect::None;
|
||||
};
|
||||
|
||||
self.search_epoch += 1;
|
||||
Effect::SearchAfterDebounce {
|
||||
query: self.query.clone(),
|
||||
debounce_ms: self.debounce_ms,
|
||||
epoch: self.search_epoch,
|
||||
}
|
||||
}
|
||||
|
||||
Action::MoveDown => {
|
||||
let len = self.results.len();
|
||||
if len > 0 {
|
||||
self.selected = (self.selected + 1).min(len - 1);
|
||||
}
|
||||
Effect::None
|
||||
}
|
||||
|
||||
Action::MoveUp => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
Effect::None
|
||||
}
|
||||
|
||||
Action::LaunchSelected => {
|
||||
if let Some(result) = self.results.get(self.selected) {
|
||||
if let Some(engine) = &self.engine {
|
||||
engine.on_selected(&result.id);
|
||||
}
|
||||
let action = result.action.clone();
|
||||
self.shutdown_engine();
|
||||
return Effect::LaunchAndExit(action);
|
||||
}
|
||||
self.shutdown_engine();
|
||||
Effect::Exit
|
||||
}
|
||||
|
||||
Action::Exit => {
|
||||
self.shutdown_engine();
|
||||
Effect::Exit
|
||||
}
|
||||
|
||||
Action::EngineReady(kernel) => {
|
||||
self.engine = Some(kernel);
|
||||
Effect::TriggerSearch(self.query.clone())
|
||||
}
|
||||
|
||||
Action::EngineInitFailed(msg) => {
|
||||
self.error = Some(msg);
|
||||
Effect::None
|
||||
}
|
||||
|
||||
Action::ResultsReady { epoch, results } => {
|
||||
if epoch == self.search_epoch {
|
||||
self.results = results;
|
||||
}
|
||||
Effect::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
|
||||
pub fn results(&self) -> &[SearchResult] {
|
||||
&self.results
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> usize {
|
||||
self.selected
|
||||
}
|
||||
|
||||
pub fn cfg(&self) -> &AppearanceCfg {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
self.error.as_deref()
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
self.engine.is_none() && self.error.is_none()
|
||||
}
|
||||
|
||||
pub fn engine(&self) -> Option<&Arc<Kernel>> {
|
||||
self.engine.as_ref()
|
||||
}
|
||||
|
||||
pub fn launcher(&self) -> &Arc<dyn AppLauncher> {
|
||||
&self.launcher
|
||||
}
|
||||
|
||||
pub fn search_epoch(&self) -> u64 {
|
||||
self.search_epoch
|
||||
}
|
||||
|
||||
fn shutdown_engine(&self) {
|
||||
if let Some(engine) = &self.engine {
|
||||
engine.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
158
crates/k-launcher-ui-core/tests/state.rs
Normal file
158
crates/k-launcher-ui-core/tests/state.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_domain::*;
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_ui_core::{Action, Effect, LauncherState};
|
||||
|
||||
struct NoopLauncher;
|
||||
|
||||
impl AppLauncher for NoopLauncher {
|
||||
fn execute(&self, _action: &LaunchAction) {}
|
||||
}
|
||||
|
||||
fn make_state() -> LauncherState {
|
||||
LauncherState::new(Arc::new(NoopLauncher), AppearanceCfg::default(), 50)
|
||||
}
|
||||
|
||||
fn make_result(id: &str) -> SearchResult {
|
||||
SearchResult {
|
||||
id: ResultId::new(id),
|
||||
title: ResultTitle::new(id),
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(100),
|
||||
action: LaunchAction::CopyToClipboard(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_state_with_engine() -> LauncherState {
|
||||
let kernel = Arc::new(Kernel::new(vec![], 10));
|
||||
let mut state = make_state();
|
||||
let _ = state.handle(Action::EngineReady(kernel));
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_down_clamps_to_last_result() {
|
||||
let mut state = make_state();
|
||||
state.handle(Action::ResultsReady {
|
||||
epoch: 0,
|
||||
results: vec![make_result("a"), make_result("b"), make_result("c")],
|
||||
});
|
||||
|
||||
state.handle(Action::MoveDown);
|
||||
state.handle(Action::MoveDown);
|
||||
state.handle(Action::MoveDown);
|
||||
state.handle(Action::MoveDown);
|
||||
|
||||
assert_eq!(state.selected(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_up_does_not_go_below_zero() {
|
||||
let mut state = make_state();
|
||||
state.handle(Action::ResultsReady {
|
||||
epoch: 0,
|
||||
results: vec![make_result("a"), make_result("b")],
|
||||
});
|
||||
|
||||
state.handle(Action::MoveUp);
|
||||
state.handle(Action::MoveUp);
|
||||
|
||||
assert_eq!(state.selected(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_changed_resets_selected() {
|
||||
let mut state = make_state_with_engine();
|
||||
state.handle(Action::ResultsReady {
|
||||
epoch: state.search_epoch(),
|
||||
results: vec![make_result("a"), make_result("b"), make_result("c")],
|
||||
});
|
||||
state.handle(Action::MoveDown);
|
||||
state.handle(Action::MoveDown);
|
||||
assert_eq!(state.selected(), 2);
|
||||
|
||||
state.handle(Action::QueryChanged("new".to_string()));
|
||||
assert_eq!(state.selected(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_ready_returns_trigger_search() {
|
||||
let mut state = make_state();
|
||||
let kernel = Arc::new(Kernel::new(vec![], 10));
|
||||
let effect = state.handle(Action::EngineReady(kernel));
|
||||
|
||||
assert!(matches!(effect, Effect::TriggerSearch(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_selected_with_no_results_returns_exit() {
|
||||
let mut state = make_state_with_engine();
|
||||
let effect = state.handle(Action::LaunchSelected);
|
||||
|
||||
assert!(matches!(effect, Effect::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_ready_with_wrong_epoch_is_ignored() {
|
||||
let mut state = make_state_with_engine();
|
||||
let current_epoch = state.search_epoch();
|
||||
|
||||
state.handle(Action::ResultsReady {
|
||||
epoch: current_epoch + 999,
|
||||
results: vec![make_result("stale")],
|
||||
});
|
||||
|
||||
assert!(state.results().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_changed_without_engine_returns_none() {
|
||||
let mut state = make_state();
|
||||
let effect = state.handle(Action::QueryChanged("hello".to_string()));
|
||||
|
||||
assert!(matches!(effect, Effect::None));
|
||||
assert_eq!(state.query(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_changed_with_engine_returns_search_after_debounce() {
|
||||
let mut state = make_state_with_engine();
|
||||
let effect = state.handle(Action::QueryChanged("test".to_string()));
|
||||
|
||||
match effect {
|
||||
Effect::SearchAfterDebounce {
|
||||
query,
|
||||
debounce_ms,
|
||||
epoch,
|
||||
} => {
|
||||
assert_eq!(query, "test");
|
||||
assert_eq!(debounce_ms, 50);
|
||||
assert_eq!(epoch, state.search_epoch());
|
||||
}
|
||||
_ => panic!("expected SearchAfterDebounce"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_selected_with_results_returns_launch_and_exit() {
|
||||
let mut state = make_state_with_engine();
|
||||
state.handle(Action::ResultsReady {
|
||||
epoch: state.search_epoch(),
|
||||
results: vec![make_result("app1")],
|
||||
});
|
||||
|
||||
let effect = state.handle(Action::LaunchSelected);
|
||||
assert!(matches!(effect, Effect::LaunchAndExit(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_init_failed_sets_error() {
|
||||
let mut state = make_state();
|
||||
state.handle(Action::EngineInitFailed("boom".to_string()));
|
||||
|
||||
assert_eq!(state.error(), Some("boom"));
|
||||
}
|
||||
Reference in New Issue
Block a user