add support for location

This commit is contained in:
Alexander Mohr 2025-04-21 19:30:07 +02:00
parent 3e34d809be
commit 2453bbaf72
7 changed files with 151 additions and 120 deletions

View file

@ -17,7 +17,7 @@ Allow blur for Worf
layerrule = blur, worf
```
## Additional functionality compared to Wofi (planed)
## Additional functionality compared to Wofi
* Support passing 'hidden' parameters that are not visible in the launcher but will be returned to the application
* Window switcher for hyprland
* All arguments expect show are supported by config and args
@ -32,6 +32,12 @@ layerrule = blur, worf
* `label`: Allows styling the label
* `row`: Allows styling to row, mainly used to disable hover effects
## Library
The launcher and UI can be used to build any launcher, as the ui, config and run logic is available as a separate crate.
This library is not available publicly yet as the interface is not stable enough.
## Breaking changes to Wofi
* Runtime behaviour is not guaranteed to be the same and won't ever be, this includes error messages and themes.
* Themes in general are mostly compatible. Worf is using the same entity ids,
@ -39,6 +45,7 @@ layerrule = blur, worf
* Configuration files are not 100% compatible, Worf is using toml files instead, for most part this only means strings have to be quoted
* Color files are not supported
* `line_wrap` is now called `line-wrap`
* Wofi has a C-API, that is not and won't be supported.
## Dropped arguments
* `mode`, use show
@ -48,8 +55,3 @@ layerrule = blur, worf
### Dropped configuration options
* stylesheet -> use style instead
* color / colors -> GTK4 does not support color files
## Not supported
* Wofi has a C-API, that is not and won't be supported.

12
examples/dmenu.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
# A list of options, one per line
options="Option 1
Option 2
Option 3"
# Pipe options to wofi and capture the selection
selection=$(echo "$options" | cargo run -- --show dmenu)
# Do something with the selection
echo "You selected: $selection"

View file

@ -2,7 +2,7 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::{env, fmt, fs};
use anyhow::{Error, anyhow};
use anyhow::anyhow;
use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -23,6 +23,14 @@ impl fmt::Display for ConfigurationError {
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
pub enum Anchor {
Top,
Left,
Bottom,
Right,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)]
pub enum MatchMethod {
Fuzzy,
@ -85,6 +93,20 @@ pub enum ArgsError {
InvalidParameter(String),
}
impl FromStr for Anchor {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"top" => Ok(Anchor::Top),
"left" => Ok(Anchor::Left),
"bottom" => Ok(Anchor::Bottom),
"right" => Ok(Anchor::Right),
other => Err(format!("Invalid anchor: {}", other)),
}
}
}
impl FromStr for Mode {
type Err = ArgsError;
@ -201,8 +223,11 @@ pub struct Config {
#[clap(short = 'q', long = "parse-search")]
pub parse_search: Option<bool>,
#[clap(short = 'l', long = "location")]
pub location: Option<String>,
/// set where the window is displayed.
/// can be used to anchor a window to an edge by
/// setting top,left for example
#[clap(short = 'l', long = "location", value_delimiter = ',', value_parser = clap::builder::ValueParser::new(Anchor::from_str))]
pub location: Option<Vec<Anchor>>,
#[clap(short = 'a', long = "no-actions")]
pub no_actions: Option<bool>,
@ -508,7 +533,6 @@ pub fn default_line_wrap() -> Option<WrapMode> {
// max_lines = lines;
// columns = strtol(config_get(config, "columns", "1"), NULL, 10);
// sort_order = config_get_mnemonic(config, "sort_order", "default", 2, "default", "alphabetical");
// line_wrap = config_get_mnemonic(config, "line_wrap", "off", 4, "off", "word", "char", "word_char") - 1;
// bool global_coords = strcmp(config_get(config, "global_coords", "false"), "true") == 0;
// hide_search = strcmp(config_get(config, "hide_search", "false"), "true") == 0;
// char* search = map_get(config, "search");
@ -700,9 +724,9 @@ pub fn load_config(args_opt: Option<Config>) -> Result<Config, ConfigurationErro
Mode::Run => merge_result.prompt = Some("run".to_owned()),
Mode::Drun => merge_result.prompt = Some("drun".to_owned()),
Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()),
Mode::Math => merge_result.prompt = Some("math".to_owned()),
Mode::File => merge_result.prompt = Some("file".to_owned()),
Mode::Auto => merge_result.prompt = Some("auto".to_owned()),
Mode::Math => merge_result.prompt = Some("math".to_owned()),
Mode::File => merge_result.prompt = Some("file".to_owned()),
Mode::Auto => merge_result.prompt = Some("auto".to_owned()),
},
}
}

View file

@ -1,70 +1,26 @@
use anyhow::anyhow;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::{env, fs, string};
use freedesktop_file_parser::DesktopFile;
use gdk4::Display;
use gtk4::prelude::*;
use gtk4::{IconLookupFlags, IconTheme, TextDirection};
use home::home_dir;
use log::{debug, info, warn};
use log;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::{env, fs, string};
#[derive(Debug)]
pub enum DesktopError {
MissingIcon,
}
//
// #[derive(Clone)]
// pub struct IconResolver {
// cache: HashMap<String, String>,
// }
//
// impl Default for IconResolver {
// #[must_use]
// fn default() -> IconResolver {
// Self::new()
// }
// }
//
// impl IconResolver {
// #[must_use]
// pub fn new() -> IconResolver {
// IconResolver {
// cache: HashMap::new(),
// }
// }
//
// pub fn icon_path_no_cache(&self, icon_name: &str) -> Result<String, DesktopError> {
// let icon = fetch_icon_from_theme(icon_name)
// .or_else(|_|
// fetch_icon_from_common_dirs(icon_name)
// .or_else(|_| default_icon()));
//
// icon
// }
//
// pub fn icon_path(&mut self, icon_name: &str) -> String {
// if let Some(icon_path) = self.cache.get(icon_name) {
// return icon_path.to_owned();
// }
//
// let icon = self.icon_path_no_cache(icon_name);
//
// self.cache
// .entry(icon_name.to_owned())
// .or_insert_with(|| icon.unwrap_or_default())
// .to_owned()
// }
// }
/// # Errors
///
/// Will return `Err` if no icon can be found
pub fn default_icon() -> Result<String, DesktopError> {
fetch_icon_from_theme("image-missing").map_err(|e| DesktopError::MissingIcon)
fetch_icon_from_theme("image-missing").map_err(|_| DesktopError::MissingIcon)
}
fn fetch_icon_from_theme(icon_name: &str) -> Result<String, DesktopError> {
@ -177,7 +133,7 @@ pub fn find_desktop_files() -> Vec<DesktopFile> {
.filter_map(|icon_dir| find_file_case_insensitive(&icon_dir, regex))
.flat_map(|desktop_files| {
desktop_files.into_iter().filter_map(|desktop_file| {
debug!("loading desktop file {desktop_file:?}");
log::debug!("loading desktop file {desktop_file:?}");
fs::read_to_string(desktop_file)
.ok()
.and_then(|content| freedesktop_file_parser::parse(&content).ok())

View file

@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -15,13 +14,17 @@ use gtk4::prelude::{
ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt,
GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt,
};
use gtk4::{Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, NaturalWrapMode};
use gtk4::{
Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label,
ListBox, ListBoxRow, NaturalWrapMode, Ordering, PolicyType, ScrolledWindow, SearchEntry,
Widget, gdk,
};
use gtk4::{Application, ApplicationWindow, CssProvider, Orientation};
use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell};
use log;
use crate::config;
use crate::config::{Animation, Config, MatchMethod, WrapMode};
use crate::config::{Anchor, Animation, Config, MatchMethod, WrapMode};
type ArcMenuMap<T> = Arc<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>;
type ArcProvider<T> = Arc<Mutex<dyn ItemProvider<T>>>;
@ -32,6 +35,17 @@ pub trait ItemProvider<T: std::clone::Clone> {
fn get_sub_elements(&mut self, item: &MenuItem<T>) -> Option<Vec<MenuItem<T>>>;
}
impl From<&Anchor> for Edge {
fn from(value: &Anchor) -> Self {
match value {
Anchor::Top => Edge::Top,
Anchor::Left => Edge::Left,
Anchor::Bottom => Edge::Bottom,
Anchor::Right => Edge::Right,
}
}
}
impl From<config::Orientation> for Orientation {
fn from(orientation: config::Orientation) -> Self {
match orientation {
@ -44,9 +58,9 @@ impl From<config::Orientation> for Orientation {
impl From<&WrapMode> for NaturalWrapMode {
fn from(value: &WrapMode) -> Self {
match value {
WrapMode::None => {NaturalWrapMode::None},
WrapMode::Word => {NaturalWrapMode::Word},
WrapMode::Inherit => {NaturalWrapMode::Inherit},
WrapMode::None => NaturalWrapMode::None,
WrapMode::Word => NaturalWrapMode::Word,
WrapMode::Inherit => NaturalWrapMode::Inherit,
}
}
}
@ -141,8 +155,11 @@ fn build_ui<T, P>(
window.set_namespace(Some("worf"));
}
/// todo make this configurable
//window.set_anchor(Edge::Top, true);
config.location.as_ref().map(|location| {
for anchor in location {
window.set_anchor(anchor.into(), true);
}
});
let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0);
outer_box.set_widget_name("outer-box");
@ -816,7 +833,7 @@ fn create_menu_row<T: Clone + 'static>(
}
let label = Label::new(Some(menu_item.label.as_str()));
let wrap_mode : NaturalWrapMode = if let Some(config_wrap) = &config.line_wrap {
let wrap_mode: NaturalWrapMode = if let Some(config_wrap) = &config.line_wrap {
config_wrap.into()
} else {
NaturalWrapMode::Word
@ -951,7 +968,9 @@ fn percent_or_absolute(value: Option<&String>, base_value: i32) -> Option<i32> {
// highly unlikely that we are dealing with > i64 items
#[allow(clippy::cast_possible_wrap)]
pub fn sort_menu_items_alphabetically_honor_initial_score<T: std::clone::Clone>(items: &mut [MenuItem<T>]) {
pub fn sort_menu_items_alphabetically_honor_initial_score<T: std::clone::Clone>(
items: &mut [MenuItem<T>],
) {
let mut regular_score = items.len() as i64;
items.sort_by(|l, r| l.label.cmp(&r.label));

View file

@ -1,21 +1,39 @@
use std::os::unix::prelude::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::{env, fmt, fs, io};
use anyhow::Context;
use freedesktop_file_parser::EntryType;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::config::{Config, expand_path};
use crate::desktop::{
default_icon, find_desktop_files, get_locale_variants, lookup_name_with_locale,
};
use crate::gui;
use crate::gui::{ItemProvider, MenuItem};
use crate::{config, desktop, gui};
use anyhow::{Context, Error, anyhow};
use freedesktop_file_parser::EntryType;
use gtk4::Image;
use libc::option;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::prelude::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::{env, fs, io};
#[derive(Debug)]
pub enum ModeError {
UpdateCacheError(String),
MissingAction,
RunError(String),
MissingCache,
}
impl fmt::Display for ModeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ModeError::UpdateCacheError(s) => write!(f, "UpdateCacheError {s}"),
ModeError::MissingAction => write!(f, "MissingAction"),
ModeError::RunError(s) => write!(f, "RunError, {s}"),
ModeError::MissingCache => write!(f, "MissingCache"),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct DRunCache {
@ -139,7 +157,7 @@ impl<T: Clone> ItemProvider<T> for DRunProvider<T> {
self.items.clone()
}
fn get_sub_elements(&mut self, item: &MenuItem<T>) -> Option<Vec<MenuItem<T>>> {
fn get_sub_elements(&mut self, _: &MenuItem<T>) -> Option<Vec<MenuItem<T>>> {
None
}
}
@ -283,9 +301,7 @@ struct MathProvider<T: Clone> {
impl<T: std::clone::Clone> MathProvider<T> {
fn new(menu_item_data: T) -> Self {
Self {
menu_item_data,
}
Self { menu_item_data }
}
fn contains_math_functions_or_starts_with_number(input: &str) -> bool {
@ -327,7 +343,7 @@ impl<T: Clone> ItemProvider<T> for MathProvider<T> {
}
}
fn get_sub_elements(&mut self, item: &MenuItem<T>) -> Option<Vec<MenuItem<T>>> {
fn get_sub_elements(&mut self, _: &MenuItem<T>) -> Option<Vec<MenuItem<T>>> {
None
}
}
@ -366,7 +382,9 @@ impl ItemProvider<AutoRunType> for AutoItemProvider {
let trimmed_search = search.trim();
if trimmed_search.is_empty() {
self.drun_provider.get_elements(search_opt)
} else if MathProvider::<AutoRunType>::contains_math_functions_or_starts_with_number(trimmed_search) {
} else if MathProvider::<AutoRunType>::contains_math_functions_or_starts_with_number(
trimmed_search,
) {
self.math_provider.get_elements(search_opt)
} else if trimmed_search.starts_with("$")
|| trimmed_search.starts_with("/")
@ -392,7 +410,7 @@ impl ItemProvider<AutoRunType> for AutoItemProvider {
/// # Errors
///
/// Will return `Err` if it was not able to spawn the process
pub fn d_run(config: &Config) -> anyhow::Result<()> {
pub fn d_run(config: &Config) -> Result<(), ModeError> {
let provider = DRunProvider::new("".to_owned());
let cache_path = provider.cache_path.clone();
let mut cache = provider.cache.clone();
@ -400,9 +418,7 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> {
// todo ues a arc instead of cloning the config
let selection_result = gui::show(config.clone(), provider);
match selection_result {
Ok(s) => {
update_drun_cache_and_run(cache_path, &mut cache, s)?;
}
Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s)?,
Err(_) => {
log::error!("No item selected");
}
@ -411,7 +427,7 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> {
Ok(())
}
pub fn auto(config: &Config) -> anyhow::Result<()> {
pub fn auto(config: &Config) -> Result<(), ModeError> {
let provider = AutoItemProvider::new();
let cache_path = provider.drun_provider.cache_path.clone();
let mut cache = provider.drun_provider.cache.clone();
@ -429,12 +445,9 @@ pub fn auto(config: &Config) -> anyhow::Result<()> {
}
AutoRunType::File => {
if let Some(action) = selection_result.action {
spawn_fork(&action, selection_result.working_dir.as_ref())?
spawn_fork(&action, selection_result.working_dir.as_ref())?;
}
}
_ => {
todo!("not supported yet");
}
}
}
}
@ -446,7 +459,7 @@ pub fn auto(config: &Config) -> anyhow::Result<()> {
Ok(())
}
pub fn file(config: &Config) -> Result<(), String> {
pub fn file(config: &Config) -> Result<(), ModeError> {
let provider = FileItemProvider::new("".to_owned());
// todo ues a arc instead of cloning the config
@ -454,7 +467,7 @@ pub fn file(config: &Config) -> Result<(), String> {
match selection_result {
Ok(s) => {
if let Some(action) = s.action {
spawn_fork(&action, s.working_dir.as_ref()).map_err(|e| e.to_string())?;
spawn_fork(&action, s.working_dir.as_ref())?;
}
}
Err(_) => {
@ -465,14 +478,13 @@ pub fn file(config: &Config) -> Result<(), String> {
Ok(())
}
pub fn math(config: &Config) -> Result<(), String> {
pub fn math(config: &Config) -> Result<(), ModeError> {
let provider = MathProvider::new("".to_owned());
// todo ues a arc instead of cloning the config
let selection_result = gui::show(config.clone(), provider);
match selection_result {
Ok(_) => {
}
Ok(_) => {}
Err(_) => {
log::error!("No item selected");
}
@ -481,11 +493,15 @@ pub fn math(config: &Config) -> Result<(), String> {
Ok(())
}
pub fn dmenu(_: &Config) -> Result<(), ModeError> {
Ok(())
}
fn update_drun_cache_and_run<T: Clone>(
cache_path: Option<PathBuf>,
cache: &mut HashMap<String, i64>,
selection_result: MenuItem<T>,
) -> Result<(), Error> {
) -> Result<(), ModeError> {
if let Some(cache_path) = cache_path {
*cache.entry(selection_result.label).or_insert(0) += 1;
if let Err(e) = save_cache_file(&cache_path, &cache) {
@ -496,7 +512,7 @@ fn update_drun_cache_and_run<T: Clone>(
if let Some(action) = selection_result.action {
spawn_fork(&action, selection_result.working_dir.as_ref())
} else {
Err(anyhow::anyhow!("cannot find drun action"))
Err(ModeError::MissingAction)
}
}
@ -520,12 +536,13 @@ fn save_cache_file(path: &PathBuf, data: &HashMap<String, i64>) -> anyhow::Resul
fs::write(path, toml_string).map_err(|e| anyhow::anyhow!(e))
}
fn load_cache_file(cache_path: Option<&PathBuf>) -> anyhow::Result<HashMap<String, i64>> {
fn load_cache_file(cache_path: Option<&PathBuf>) -> Result<HashMap<String, i64>, ModeError> {
let Some(path) = cache_path else {
return Err(anyhow!("Cache is missing"));
return Err(ModeError::MissingCache);
};
let toml_content = fs::read_to_string(path)?;
let toml_content =
fs::read_to_string(path).map_err(|e| ModeError::UpdateCacheError(format!("{e}")))?;
let parsed: toml::Value = toml_content.parse().expect("Failed to parse TOML");
let mut result: HashMap<String, i64> = HashMap::new();
@ -555,17 +572,18 @@ fn create_file_if_not_exists(path: &PathBuf) -> anyhow::Result<()> {
}
}
fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> {
fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> Result<(), ModeError> {
// todo fix actions ??
// todo graphical disk map icon not working
let parts = cmd.split(' ').collect::<Vec<_>>();
if parts.is_empty() {
return Err(anyhow!("empty command passed"));
return Err(ModeError::MissingAction);
}
if let Some(dir) = working_dir {
env::set_current_dir(dir)?;
env::set_current_dir(dir)
.map_err(|e| ModeError::RunError(format!("cannot set workdir {e}")))?
}
let exec = parts[0].replace('"', "");

View file

@ -13,7 +13,7 @@ fn main() -> anyhow::Result<()> {
.init();
let args = config::parse_args();
let mut config = config::load_config(Some(args)).map_err(|e| anyhow!(e))?;
let config = config::load_config(Some(args)).map_err(|e| anyhow!(e))?;
if let Some(show) = &config.show {
match show {
@ -21,10 +21,10 @@ fn main() -> anyhow::Result<()> {
todo!("run not implemented")
}
Mode::Drun => {
mode::d_run(&config)?;
mode::d_run(&config).map_err(|e| anyhow!(e))?;
}
Mode::Dmenu => {
todo!("dmenu not implemented")
mode::dmenu(&config).map_err(|e| anyhow!(e))?;
}
Mode::File => {
mode::file(&config).map_err(|e| anyhow!(e))?;
@ -33,7 +33,7 @@ fn main() -> anyhow::Result<()> {
mode::math(&config).map_err(|e| anyhow!(e))?;
}
Mode::Auto => {
mode::auto(&config)?;
mode::auto(&config).map_err(|e| anyhow!(e))?;
}
}