style: apply rustfmt across workspace
Some checks failed
CI / test (pull_request) Failing after 5m30s
CI / clippy (pull_request) Failing after 5m2s
CI / fmt (pull_request) Successful in 26s

This commit is contained in:
2026-03-15 20:02:12 +01:00
parent 1e233aba4b
commit 2feb3a2d96
15 changed files with 493 additions and 109 deletions

View File

@@ -107,8 +107,7 @@ impl Default for PluginsCfg {
}
pub fn load() -> Config {
let path = dirs::config_dir()
.map(|d| d.join("k-launcher").join("config.toml"));
let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
let Some(path) = path else {
return Config::default();
};

View File

@@ -29,7 +29,9 @@ impl ResultTitle {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32);
impl Score {
@@ -104,7 +106,10 @@ pub struct Kernel {
impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
Self { plugins, max_results }
Self {
plugins,
max_results,
}
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {

View File

@@ -1,5 +1,5 @@
use std::process::{Command, Stdio};
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction};
@@ -77,21 +77,31 @@ impl AppLauncher for UnixAppLauncher {
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
}
LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else { return };
let Some((term_bin, term_args)) = resolve_terminal() else {
return;
};
let _ = unsafe {
Command::new(&term_bin)
.args(&term_args)
.arg("sh").arg("-c").arg(cmd)
.arg("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}

View File

@@ -43,7 +43,9 @@ async fn do_search(
io: &mut ProcessIo,
query: &str,
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
let line = serde_json::to_string(&Query { query: query.to_string() })?;
let line = serde_json::to_string(&Query {
query: query.to_string(),
})?;
io.stdin.write_all(line.as_bytes()).await?;
io.stdin.write_all(b"\n").await?;
io.stdin.flush().await?;
@@ -143,7 +145,9 @@ mod tests {
#[test]
fn query_serializes_correctly() {
let q = Query { query: "firefox".to_string() };
let q = Query {
query: "firefox".to_string(),
};
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
}
@@ -155,21 +159,27 @@ mod tests {
assert_eq!(results[0].id, "1");
assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 80);
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox"));
assert!(
matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")
);
}
#[test]
fn result_parses_copy_action() {
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4"));
assert!(
matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")
);
}
#[test]
fn result_parses_open_path_action() {
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user"));
assert!(
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
);
}
#[test]
@@ -182,7 +192,8 @@ mod tests {
#[test]
fn result_parses_missing_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let json =
r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(results[0].description.is_none());
assert!(results[0].icon.is_none());

View File

@@ -127,11 +127,20 @@ impl eframe::App for KLauncherApp {
ui.set_width(ui.available_width());
for (i, result) in self.results.iter().enumerate() {
let is_selected = i == self.selected;
let bg = if is_selected { SELECTED_BG } else { Color32::TRANSPARENT };
let bg = if is_selected {
SELECTED_BG
} else {
Color32::TRANSPARENT
};
let row_frame = egui::Frame::new()
.fill(bg)
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 6 })
.inner_margin(egui::Margin {
left: 8,
right: 8,
top: 6,
bottom: 6,
})
.corner_radius(egui::CornerRadius::same(4));
row_frame.show(ui, |ui| {

View File

@@ -1,10 +1,9 @@
use std::sync::Arc;
use iced::{
Border, Color, Element, Length, Size, Subscription, Task,
event,
Border, Color, Element, Length, Size, Subscription, Task, event,
keyboard::{Event as KeyEvent, Key, key::Named},
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
window,
};
@@ -126,8 +125,13 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
.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() };
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
});
@@ -147,26 +151,27 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
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(),
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)),
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),
)
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
.width(Length::Fill)
.padding([6, 12])
.style(move |_theme| container::Style {
@@ -209,7 +214,8 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
.into()
});
let mut content_children: Vec<Element<'_, Message>> = vec![search_bar.into(), results_list.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);
}
@@ -261,15 +267,15 @@ pub fn run(
update,
view,
)
.title("K-Launcher")
.subscription(subscription)
.window(window::Settings {
size: Size::new(wc.width, wc.height),
position: window::Position::Centered,
decorations: wc.decorations,
transparent: wc.transparent,
resizable: wc.resizable,
..Default::default()
})
.run()
.title("K-Launcher")
.subscription(subscription)
.window(window::Settings {
size: Size::new(wc.width, wc.height),
position: window::Position::Centered,
decorations: wc.decorations,
transparent: wc.transparent,
resizable: wc.resizable,
..Default::default()
})
.run()
}

View File

@@ -1,8 +1,8 @@
use std::io::Write;
pub fn send_show() -> Result<(), Box<dyn std::error::Error>> {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| "/run/user/1000".to_string());
let runtime_dir =
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
let socket_path = format!("{runtime_dir}/k-launcher.sock");
let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)?;
stream.write_all(b"show\n")?;

View File

@@ -5,9 +5,9 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
use k_launcher_plugin_host::ExternalPlugin;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
@@ -36,14 +36,27 @@ fn run_ui() -> iced::Result {
let frecency = FrecencyStore::load();
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::new())); }
if cfg.plugins.calc { plugins.push(Arc::new(CalcPlugin::new())); }
if cfg.plugins.files { plugins.push(Arc::new(FilesPlugin::new())); }
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)));
if cfg.plugins.cmd {
plugins.push(Arc::new(CmdPlugin::new()));
}
if cfg.plugins.calc {
plugins.push(Arc::new(CalcPlugin::new()));
}
if cfg.plugins.files {
plugins.push(Arc::new(FilesPlugin::new()));
}
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(
FsDesktopEntrySource::new(),
frecency,
)));
}
for ext in &cfg.plugins.external {
plugins.push(Arc::new(ExternalPlugin::new(&ext.name, &ext.path, ext.args.clone())));
plugins.push(Arc::new(ExternalPlugin::new(
&ext.name,
&ext.path,
ext.args.clone(),
)));
}
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =

View File

@@ -2,9 +2,9 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
@@ -12,12 +12,15 @@ use plugin_files::FilesPlugin;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![
Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
], 8));
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
vec![
Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
],
8,
));
k_launcher_ui_egui::run(kernel, launcher)?;
Ok(())
}

View File

@@ -24,7 +24,10 @@ impl FrecencyStore {
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Arc::new(Self { path, data: Mutex::new(data) })
Arc::new(Self {
path,
data: Mutex::new(data),
})
}
#[cfg(test)]
@@ -53,7 +56,10 @@ impl FrecencyStore {
.unwrap_or_default()
.as_secs();
let mut data = self.data.lock().unwrap();
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 });
let entry = data.entry(id.to_string()).or_insert(Entry {
count: 0,
last_used: 0,
});
entry.count += 1;
entry.last_used = now;
if let Some(parent) = self.path.parent() {
@@ -72,7 +78,13 @@ impl FrecencyStore {
.unwrap_or_default()
.as_secs();
let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
let decay = if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
};
entry.count * decay
}
@@ -86,7 +98,13 @@ impl FrecencyStore {
.iter()
.map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
let decay = if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
};
(id.clone(), entry.count * decay)
})
.collect();

View File

@@ -91,7 +91,10 @@ impl AppsPlugin {
let id = format!("app-{}", e.name.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
let icon = e
.icon
.as_ref()
.and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
@@ -117,13 +120,16 @@ impl AppsPlugin {
}
fn initials(name_lc: &str) -> String {
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
name_lc
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
}
fn score_match(name: &str, query: &str) -> Option<u32> {
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
let mut matcher = Matcher::new(Config::DEFAULT);
@@ -136,7 +142,11 @@ fn score_match(name: &str, query: &str) -> Option<u32> {
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) { 20 } else { 0 };
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else {
None
@@ -162,7 +172,9 @@ impl Plugin for AppsPlugin {
async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return self.frecency.top_ids(5)
return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = self.entries.get(id)?;
@@ -185,7 +197,10 @@ impl Plugin for AppsPlugin {
.values()
.filter_map(|e| {
let score = score_match(e.name.as_str(), query).or_else(|| {
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
e.keywords_lc
.iter()
.any(|k| k.contains(&query_lc))
.then_some(50)
})?;
Some(SearchResult {
id: ResultId::new(&e.id),
@@ -238,7 +253,14 @@ mod tests {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect()))
.map(|(n, e, kw)| {
(
n.to_string(),
e.to_string(),
None,
kw.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect(),
}
}
@@ -261,20 +283,29 @@ mod tests {
#[tokio::test]
async fn apps_prefix_match() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty());
}
@@ -322,9 +353,15 @@ mod tests {
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(!results.is_empty(), "nucleo should fuzzy-match 'frefox' to 'Firefox'");
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}

View File

@@ -1,7 +1,7 @@
use std::path::Path;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
use crate::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
pub struct FsDesktopEntrySource;
@@ -154,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => {
category = value.trim()
category = value
.trim()
.split(';')
.find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim()));
}
"Keywords" if keywords.is_empty() => {
keywords = value.trim()
keywords = value
.trim()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
@@ -202,7 +204,10 @@ mod exec_tests {
#[test]
fn preserves_quoted_value() {
assert_eq!(clean_exec(r#"app --arg="value" %U"#), r#"app --arg="value""#);
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]

View File

@@ -22,8 +22,8 @@ fn strip_numeric_separators(expr: &str) -> String {
}
const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan",
"ln", "log2", "log10", "exp", "abs", "ceil", "floor", "round",
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ceil", "floor", "round",
];
fn should_eval(query: &str) -> bool {
@@ -36,8 +36,8 @@ fn should_eval(query: &str) -> bool {
|| MATH_FNS.iter().any(|f| q.starts_with(f))
}
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> =
LazyLock::new(|| {
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|| {
use evalexpr::*;
context_map! {
"pi" => float std::f64::consts::PI,
@@ -59,7 +59,8 @@ static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
}
.expect("static math context must be valid")
});
},
);
#[async_trait]
impl Plugin for CalcPlugin {

View File

@@ -76,11 +76,7 @@ impl Plugin for FilesPlugin {
let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = full_path.is_dir();
let title = if is_dir {
format!("{name}/")
} else {
name
};
let title = if is_dir { format!("{name}/") } else { name };
let path_str = full_path.to_string_lossy().to_string();
SearchResult {
id: ResultId::new(format!("file-{i}")),