Add native shell launcher with themed search and browsing

This commit is contained in:
Samuel Onoja committed 2026-10-13 23:15:44 +01:00
1 parent 2a400b26d0
commit d8bdb68b2a
43 files changed
+3351 -115

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
fff-search = "=0.11.0"
mime_guess = "2.0.5"
ferese-activities = { path = "../../crates/ferese-activities" }
ferese-layout = { path = "../../crates/ferese-layout" }
ferese-panel = { path = "../../crates/ferese-panel" }
+9
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)]
pub(crate) struct ShellConfig {
pub(crate) terminal: Vec<String>,
pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) animations: crate::motion::Settings,
@@ -23,6 +24,7 @@ impl Default for ShellConfig {
fn default() -> Self {
let status = StatusConfig::default();
Self {
terminal: vec!["foot".into()],
activities: Default::default(),
notifications: Default::default(),
animations: Default::default(),
@@ -185,6 +187,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
commands: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
activities: ferese_config::activities::Settings,
#[serde(default)]
@@ -404,6 +408,11 @@ fn parse_document(
theme.bar_radius = theme.material_radius;
Ok(ShellConfig {
terminal: config
.commands
.get("terminal")
.cloned()
.unwrap_or_else(|| vec!["foot".into()]),
activities: config.activities,
notifications: config.notifications,
animations: config.animations,
+62 -1
View File
@@ -85,6 +85,9 @@ pub(crate) enum ControlCommand {
Logout(u32, String),
CancelLogout(u32),
ToggleGuide(String),
ToggleLauncher(String),
DismissLauncher,
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
ToggleDisplays(String),
MonitorsChanged,
}
@@ -128,6 +131,9 @@ enum ControlUpdate {
Logout(u32, String),
LogoutCancelled(u32),
ToggleGuide(String),
ToggleLauncher(String),
DismissLauncher,
Thumbnail(u32, u64, Option<cosmic::widget::image::Handle>),
ToggleDisplays(String),
MonitorsChanged,
}
@@ -137,7 +143,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=9, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=10, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
@@ -181,6 +187,11 @@ impl ShellControl {
Ok(ControlUpdate::Logout(serial, output)) => poll.commands.push(ControlCommand::Logout(serial, output)),
Ok(ControlUpdate::ToggleDisplays(output)) => poll.commands.push(ControlCommand::ToggleDisplays(output)),
Ok(ControlUpdate::MonitorsChanged) => poll.commands.push(ControlCommand::MonitorsChanged),
Ok(ControlUpdate::DismissLauncher) => poll.commands.push(ControlCommand::DismissLauncher),
Ok(ControlUpdate::Thumbnail(token, window, image)) => {
poll.commands.push(ControlCommand::Thumbnail(token, window, image))
}
Ok(ControlUpdate::ToggleLauncher(output)) => poll.commands.push(ControlCommand::ToggleLauncher(output)),
Ok(ControlUpdate::ToggleGuide(output)) => poll.commands.push(ControlCommand::ToggleGuide(output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => poll.commands.push(ControlCommand::CancelLogout(serial)),
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
@@ -195,6 +206,15 @@ impl ShellControl {
}
}
pub(crate) fn thumbnail(&self, token: u32, window: u64) {
if self.shell.version() < 10 {
return;
}
let (hi, lo) = split_id(window);
self.shell.thumbnail(token, hi, lo);
let _ = self.connection.flush();
}
pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) {
if self.shell.version() < 8 {
return;
@@ -318,6 +338,47 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
_qh: &QueueHandle<Self>,
) {
match event {
ferese_shell_v1::Event::DismissLauncher => {
let _ = state.sender.send(ControlUpdate::DismissLauncher);
}
ferese_shell_v1::Event::ThumbnailUnavailable {
token,
window_hi,
window_lo,
} => {
let _ = state
.sender
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), None));
}
ferese_shell_v1::Event::Thumbnail {
token,
window_hi,
window_lo,
width,
height,
pixels,
} => {
use std::io::Read;
let image = (|| {
if width == 0 || width > 320 || height == 0 || height > 168 {
return None;
}
let mut file = std::fs::File::from(pixels);
let size = (width * height * 4) as usize;
if file.metadata().ok()?.len() != size as u64 {
return None;
}
let mut bytes = vec![0; size];
file.read_exact(&mut bytes).ok()?;
Some(cosmic::widget::image::Handle::from_rgba(width, height, bytes))
})();
let _ = state
.sender
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), image));
}
ferese_shell_v1::Event::ToggleLauncher { output_name } => {
let _ = state.sender.send(ControlUpdate::ToggleLauncher(output_name));
}
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
ferese_shell_v1::Event::ConfigChunk { source } => {
append_config_chunk(&mut state.config, &source);
+161
View File
@@ -0,0 +1,161 @@
use super::sources::{Matcher, ResultId, Row, Source, Target};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub(crate) struct Apps {
rows: Vec<Row>,
normalized_names: Vec<String>,
by_id: HashMap<String, usize>,
}
#[derive(Default, Deserialize, Serialize)]
struct Usage {
count: u64,
last: u64,
}
fn state_path() -> Option<PathBuf> {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
.map(|path| path.join("ferese/launcher-apps.json"))
}
fn history() -> HashMap<String, Usage> {
state_path()
.and_then(|path| {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path)
.ok()?
.take(1024 * 1024 + 1)
.read_to_end(&mut bytes)
.ok()?;
(bytes.len() <= 1024 * 1024).then_some(bytes)
})
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl Apps {
fn entry(&self, app_id: &str) -> Option<&Row> {
self.by_id
.get(&app_id.trim_end_matches(".desktop").to_ascii_lowercase())
.map(|index| &self.rows[*index])
}
pub(super) fn name(&self, app_id: &str) -> Option<&str> {
self.entry(app_id).map(|row| row.title.as_str())
}
pub(super) fn icon(&self, app_id: &str) -> Option<cosmic::widget::icon::Handle> {
self.entry(app_id)?.icon.clone()
}
pub(super) fn empty() -> Self {
Self {
rows: Vec::new(),
normalized_names: Vec::new(),
by_id: HashMap::new(),
}
}
pub(super) fn load(cancel: &super::work::Cancellation) -> Self {
use freedesktop_desktop_entry as fde;
let locales = fde::get_languages_from_env();
let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_else(|_| "Ferese".into());
let desktops: Vec<_> = desktops.split(':').collect();
let mut seen = HashSet::new();
let usage = history();
let now = now();
let rows: Vec<Row> = fde::Iter::new(fde::default_paths())
.entries(Some(&locales))
.take_while(|_| !cancel.cancelled())
.filter(|entry| seen.insert(entry.appid.clone()))
.filter(|entry| !entry.hidden() && !entry.no_display())
.filter(|entry| {
entry
.only_show_in()
.is_none_or(|names| names.iter().any(|name| desktops.contains(name)))
})
.filter(|entry| {
entry
.not_show_in()
.is_none_or(|names| !names.iter().any(|name| desktops.contains(name)))
})
.filter_map(|entry| {
let title = entry.name(&locales)?.into_owned();
let score = usage.get(&entry.appid).map_or(0., |usage| {
let age = now.saturating_sub(usage.last) as f64 / 86400.;
(usage.count as f64).ln_1p() * 8. + 40. / (1. + age)
});
Some(Row {
id: ResultId::App(entry.appid.as_str().into()),
icon: Some(super::icons::resolve(
entry.icon().unwrap_or("application-x-executable"),
"application-x-executable",
)),
title,
subtitle: "Application".into(),
target: Target::App {
id: entry.appid,
desktop: entry.path,
},
score,
})
})
.collect();
if cancel.cancelled() {
return Self::empty();
}
let normalized_names = rows.iter().map(|row| row.title.to_lowercase()).collect();
let by_id = rows
.iter()
.enumerate()
.filter_map(|(index, row)| match &row.target {
Target::App { id, .. } => Some((id.to_ascii_lowercase(), index)),
_ => None,
})
.collect();
Self {
rows,
normalized_names,
by_id,
}
}
}
impl Apps {
pub(super) fn search(&self, query: &str, cancel: &super::work::Cancellation) -> Vec<Row> {
let matcher = Matcher::new(query);
self.rows
.iter()
.zip(&self.normalized_names)
.take_while(|_| !cancel.cancelled())
.filter_map(|(row, name)| {
let score = matcher.normalized(name)?;
let mut row = row.clone();
row.score += score;
Some(row)
})
.collect()
}
}
impl Source for Apps {
fn query(&self, query: &str) -> Vec<Row> {
self.search(query, &Default::default())
}
}
pub(super) fn record(id: &str) -> Result<(), String> {
let path = state_path().ok_or("No state directory")?;
let parent = path.parent().ok_or("Invalid state directory")?;
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut history = history();
let usage = history.entry(id.into()).or_default();
usage.count = usage.count.saturating_add(1);
usage.last = now();
let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
serde_json::to_writer(file.as_file_mut(), &history).map_err(|e| e.to_string())?;
file.persist(path).map_err(|e| e.to_string())?;
Ok(())
}
+160
View File
@@ -0,0 +1,160 @@
use super::sources::{Matcher, ResultId, Row, Source, Target};
use fff_search::{
FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
};
use std::sync::{Arc, OnceLock};
#[derive(Clone, Debug, Default)]
pub(crate) enum Status {
#[default]
Ready,
Indexing,
Unavailable(String),
}
impl Status {
pub(super) fn message(&self) -> Option<&str> {
match self {
Self::Ready => None,
Self::Indexing => Some("Indexing files…"),
Self::Unavailable(error) => Some(error),
}
}
}
#[derive(Clone, Default)]
pub(crate) struct Files(Arc<OnceLock<Result<SharedFilePicker, String>>>);
impl Files {
fn picker(&self) -> Result<&SharedFilePicker, &str> {
self.0
.get_or_init(|| {
let home = std::env::var_os("HOME").ok_or_else(|| "No home directory".to_string())?;
let home = std::fs::canonicalize(home).map_err(|e| e.to_string())?;
if home.parent().is_none() {
return Err("The filesystem root cannot be indexed".into());
}
let base_path = home
.into_os_string()
.into_string()
.map_err(|_| "Home directory is not UTF-8".to_string())?;
let shared = SharedFilePicker::default();
FilePicker::new_with_shared_state(
shared.clone(),
SharedFrecency::default(),
FilePickerOptions {
base_path,
enable_home_dir_scanning: true,
..Default::default()
},
)
.map_err(|error| error.to_string())?;
Ok(shared)
})
.as_ref()
.map_err(String::as_str)
}
pub(super) fn status(&self) -> Status {
match self.0.get() {
Some(Err(error)) => Status::Unavailable(format!("File search unavailable: {error}")),
Some(Ok(picker)) if !picker.wait_for_scan(std::time::Duration::ZERO) => Status::Indexing,
_ => Status::Ready,
}
}
}
impl Files {
pub(super) fn search(
&self,
query: &str,
cancel: &super::work::Cancellation,
icons: &std::sync::Mutex<super::icons::FileIcons>,
) -> Vec<Row> {
if cancel.cancelled() {
return Vec::new();
}
let Ok(shared) = self.picker() else {
return Vec::new();
};
if cancel.cancelled() {
return Vec::new();
}
let paths = {
let Ok(guard) = shared.read() else {
return Vec::new();
};
if cancel.cancelled() {
return Vec::new();
}
let Some(picker) = guard.as_ref() else {
return Vec::new();
};
let parser = QueryParser::default();
let parsed = parser.parse(query);
picker
.fuzzy_search(
&parsed,
None,
FuzzySearchOptions {
max_threads: 2,
pagination: PaginationArgs { offset: 0, limit: 100 },
..Default::default()
},
)
.items
.into_iter()
.take_while(|_| !cancel.cancelled())
.map(|item| item.absolute_path(picker, &picker.base_path))
.collect::<Vec<_>>()
};
// Filesystem/icon work must not hold the picker's read lock.
let matcher = Matcher::new(query);
let mut icons = icons.lock().unwrap_or_else(|error| error.into_inner());
paths
.into_iter()
.take_while(|_| !cancel.cancelled())
.map(|path| {
let title = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
let icon = Some(icons.file(&path));
let subtitle = format!("File · {}", path.parent().unwrap_or(&path).display());
let path: Arc<std::path::Path> = path.into();
Row {
id: ResultId::File(path.clone()),
score: matcher.score(&title).unwrap_or(200.),
icon,
title,
subtitle,
target: Target::File(path),
}
})
.collect()
}
}
impl Source for Files {
fn query(&self, query: &str) -> Vec<Row> {
self.search(query, &Default::default(), &Default::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_is_lazy_and_queries_use_the_file_picker() {
assert!(Files::default().0.get().is_none());
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("launcher-note.txt"), "note").unwrap();
let shared = SharedFilePicker::default();
FilePicker::new_with_shared_state(
shared.clone(),
SharedFrecency::default(),
FilePickerOptions {
base_path: directory.path().to_string_lossy().into_owned(),
..Default::default()
},
)
.unwrap();
assert!(shared.wait_for_scan(std::time::Duration::from_secs(10)));
let files = Files(Arc::new(OnceLock::from(Ok(shared))));
let matches = files.query("launcher-note");
assert!(matches.iter().any(|row| row.title == "launcher-note.txt"));
assert!(files.query("no-such-file-xyz").is_empty());
}
}
+40
View File
@@ -0,0 +1,40 @@
use cosmic::widget::icon;
use std::path::Path;
pub(super) fn resolve(name: &str, fallback: &'static str) -> icon::Handle {
if Path::new(name).is_absolute() {
icon::from_path(name.into())
} else {
icon::from_name(name.to_owned())
.prefer_svg(true)
.size(32)
.fallback(Some(icon::IconFallback::Names(vec![fallback.into()])))
.handle()
}
}
#[derive(Default)]
pub(super) struct FileIcons(std::collections::HashMap<String, icon::Handle>);
impl FileIcons {
pub(super) fn file(&mut self, path: &Path) -> icon::Handle {
let mime = mime_guess::from_path(path).first_or_octet_stream();
if let Some(handle) = self.0.get(mime.essence_str()) {
return handle.clone();
}
let name = mime.essence_str().replace('/', "-");
let generic = format!("{}-x-generic", mime.type_());
let handle = icon::from_name(name)
.prefer_svg(true)
.size(32)
.fallback(Some(icon::IconFallback::Names(vec![
generic.into(),
"text-x-generic".into(),
])))
.handle();
// The cache belongs to one invocation and has a fixed upper bound.
if self.0.len() < 64 {
self.0.insert(mime.essence_str().into(), handle.clone());
}
handle
}
}
+135
View File
@@ -0,0 +1,135 @@
use super::{Target, apps, sources};
use crate::compositor_ipc;
pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
use sources::Action;
let mut command = match target {
Target::App { id, desktop } => {
let entry = freedesktop_desktop_entry::DesktopEntry::from_path(&desktop, None::<&[&str]>)
.map_err(|e| e.to_string())?;
let result = if entry.terminal() {
let arguments = entry.parse_exec().map_err(|e| e.to_string())?;
let mut command = terminal_command(terminal)?;
command.args(arguments);
if let Some(path) = entry.path() {
command.current_dir(path);
}
spawn(&mut command)
} else {
open("gio", &[std::ffi::OsStr::new("launch"), desktop.as_os_str()])
};
if result.is_ok() {
if let Err(error) = apps::record(&id) {
eprintln!("Could not save launcher history: {error}");
}
}
return result;
}
Target::File(path) => return open("gio", &[std::ffi::OsStr::new("open"), path.as_os_str()]),
Target::Action(action @ (Action::Light | Action::Dark | Action::Auto)) => {
return compositor_ipc::request("theme-set-mode",serde_json::json!({"mode": match action { Action::Light => "light", Action::Dark => "dark", _ => "auto" }})).map(|_|());
}
Target::Action(Action::Logout) => {
return compositor_ipc::request("request-logout", serde_json::json!({})).map(|_| ());
}
Target::Action(Action::Lock) => std::process::Command::new("ferese-lock"),
Target::Action(Action::Screenshot) => return screenshot(),
_ => return Err("Action unavailable".into()),
};
spawn(&mut command)
}
fn terminal_command(terminal: &[String]) -> Result<std::process::Command, String> {
let (program, options) = terminal
.split_first()
.filter(|(program, _)| !program.is_empty())
.ok_or("No terminal command configured")?;
let mut command = std::process::Command::new(program);
command.args(options);
let name = std::path::Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(program);
match name {
"foot" | "footclient" | "kitty" | "gnome-terminal" | "kgx" => {
command.arg("--");
}
"wezterm" if !options.iter().any(|option| option == "start") => {
command.args(["start", "--"]);
}
"wezterm" => {
command.arg("--");
}
_ => {
command.arg("-e");
}
}
Ok(command)
}
fn open(program: &str, args: &[&std::ffi::OsStr]) -> Result<(), String> {
let mut command = std::process::Command::new(program);
command.args(args);
public_environment(&mut command);
use std::io::{Read, Seek};
let mut stderr = tempfile::tempfile().map_err(|e| e.to_string())?;
command
.stdout(std::process::Stdio::null())
.stderr(stderr.try_clone().map_err(|e| e.to_string())?);
// Waiting for gio's status must not wait for an application's inherited pipes.
let status = command.status().map_err(|e| e.to_string())?;
if status.success() {
return Ok(());
}
stderr.rewind().map_err(|e| e.to_string())?;
let mut error = String::new();
let _ = stderr.take(1024).read_to_string(&mut error);
if error.trim().is_empty() {
Err(format!("{program} exited with {status}"))
} else {
Err(error.trim().chars().take(240).collect())
}
}
fn public_environment(command: &mut std::process::Command) {
command
.stdin(std::process::Stdio::null())
.env_remove("WAYLAND_SOCKET")
.env_remove("FERESE_SHELL_CONTROL_SOCKET");
if let Some(display) = std::env::var_os("FERESE_PUBLIC_WAYLAND_DISPLAY") {
command.env("WAYLAND_DISPLAY", display);
}
}
fn spawn(command: &mut std::process::Command) -> Result<(), String> {
public_environment(command);
let mut child = command.spawn().map_err(|e| e.to_string())?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
fn screenshot() -> Result<(), String> {
let home = std::env::var_os("HOME").ok_or("No home directory")?;
let directory = std::path::PathBuf::from(home).join("Pictures/Screenshots");
std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
let file = tempfile::Builder::new()
.prefix("Ferese-")
.suffix(".png")
.tempfile_in(&directory)
.map_err(|e| e.to_string())?;
let mut command = std::process::Command::new("feresectl");
command
.arg("screenshot")
.stdout(file.reopen().map_err(|e| e.to_string())?)
.stderr(std::process::Stdio::piped());
public_environment(&mut command);
let output = command.output().map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr)
.trim()
.chars()
.take(240)
.collect());
}
file.keep().map_err(|e| e.to_string())?;
Ok(())
}
+675
View File
@@ -0,0 +1,675 @@
mod apps;
pub(super) mod files;
mod icons;
mod launch;
mod sources;
#[cfg(test)]
mod tests;
mod thumbnails;
mod view;
mod work;
use super::*;
use cosmic::iced::keyboard::{Key, key::Named};
use ferese_animation::{AnimatedValue, SpringConfig};
use sources::{Category, Row, Source, Target};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub(super) enum Message {
Input(window::Id, String),
Browse(window::Id, Category),
ActivateRow(window::Id, sources::ResultId),
Accept(bool),
Tick,
AppsScrolled(window::Id, usize),
Loaded(window::Id, Arc<apps::Apps>),
Results(window::Id, u64, Vec<Row>, files::Status),
Launched(window::Id, Result<(), String>),
Finished(Result<(), String>),
}
pub(super) struct Launcher {
pub id: window::Id,
pub(super) output: String,
top: f32,
input: cosmic::widget::Id,
query: String,
category: Option<Category>,
alternatives: bool,
category_focused: bool,
ghost_visible: bool,
selected: usize,
rows: Vec<Row>,
apps: Option<Arc<apps::Apps>>,
revision: u64,
running: bool,
activating: bool,
after_close: Option<Target>,
error: Option<String>,
file_status: files::Status,
preferred_result: Option<sources::ResultId>,
closing: bool,
keyboard_nav: bool,
progress: AnimatedValue,
height: AnimatedValue,
tick: Instant,
thumbnails: thumbnails::Thumbnails,
effects: Option<EffectsBinding>,
regions: motion::Regions,
refresh: Instant,
lifetime: work::Cancellation,
search_cancel: work::Cancellation,
file_icons: Arc<std::sync::Mutex<icons::FileIcons>>,
apps_first_row: usize,
windows: sources::Windows,
apps_scroll_id: cosmic::widget::Id,
}
impl Launcher {
fn status_message(&self) -> Option<&str> {
self.error.as_deref().or_else(|| self.file_status.message())
}
fn apps_browse(&self) -> bool {
self.query.is_empty() && self.category == Some(Category::Apps)
}
fn target_height(&self) -> f64 {
if self.query.is_empty() && self.category.is_none() {
72.
} else if self.query.is_empty() && self.category == Some(Category::Windows) {
220.
} else if self.apps_browse() {
80. + self.rows.len().div_ceil(4).clamp(1, 3) as f64 * 108.
} else if self.alternatives || (self.query.is_empty() && self.category.is_some()) {
88. + self.rows.len().clamp(1, 5) as f64 * 60.
} else {
72.
}
}
fn changed(&mut self) {
self.search_cancel.cancel();
self.apps_first_row = 0;
self.revision = self.revision.wrapping_add(1);
self.error = None;
self.file_status = files::Status::Ready;
self.preferred_result = None;
self.selected = 0;
self.category_focused = false;
self.rows.clear();
}
fn escape(&mut self) -> bool {
if !self.query.is_empty() {
self.query.clear();
self.alternatives = false;
self.changed();
false
} else if self.category.take().is_some() {
self.alternatives = false;
self.changed();
false
} else {
true
}
}
}
impl FereseShell {
pub(super) fn attach_launcher_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
let Some(launcher) = self.launcher.as_mut().filter(|launcher| launcher.id == id) else {
return;
};
if launcher.effects.is_none() {
match EffectsBinding::attach_role(&mut self.effects_context, surface, None, 0.) {
Ok(binding) => launcher.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: launcher material unavailable: {error}"),
}
}
}
pub(super) fn toggle_launcher(&mut self, name: String) -> Task<super::Message> {
if self.launcher.is_some() {
return self.close_launcher();
}
let Some(output) = self
.outputs
.iter()
.find(|o| o.name.as_deref() == Some(&name))
.or_else(|| self.outputs.first())
else {
return Task::none();
};
let name = output.name.clone().unwrap_or(name);
let wl_output = output.output.clone();
let top = output.size.map_or(240., |(_, h)| h as f32 * 0.3);
let dismissed = Task::batch([self.destroy_menu(), self.destroy_system_modal(true)]);
let id = window::Id::unique();
let mut progress = AnimatedValue::new(0.);
progress.set_target(1.);
if !self.render.motion.motion_enabled() {
progress.snap();
}
let lifetime = work::Cancellation::default();
let load_cancel = lifetime.clone();
self.launcher = Some(Launcher {
id,
output: name,
top,
input: cosmic::widget::Id::unique(),
query: String::new(),
category: None,
alternatives: false,
category_focused: false,
ghost_visible: true,
selected: 0,
rows: Vec::new(),
apps: None,
revision: 0,
running: false,
activating: false,
after_close: None,
error: None,
file_status: files::Status::Ready,
preferred_result: None,
closing: false,
keyboard_nav: self.core.keyboard_nav(),
progress,
height: AnimatedValue::new(72.),
tick: Instant::now(),
thumbnails: Default::default(),
effects: None,
regions: Default::default(),
refresh: Instant::now(),
lifetime,
search_cancel: Default::default(),
file_icons: Default::default(),
apps_first_row: 0,
windows: Default::default(),
apps_scroll_id: cosmic::widget::Id::unique(),
});
self.core.set_keyboard_nav(false);
let surface = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(),
move |_| SctkLayerSurfaceSettings {
id,
layer: Layer::Overlay,
keyboard_interactivity: KeyboardInteractivity::Exclusive,
anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT,
output: IcedOutput::Output(wl_output.clone()),
namespace: "ferese-launcher".into(),
exclusive_zone: -1,
size: Some((None, None)),
..Default::default()
},
Some(Box::new(move |app| app.view_launcher(id))),
);
Task::batch([
dismissed,
cosmic::task::message(cosmic::Action::Surface(surface)),
Task::perform(
async move {
tokio::task::spawn_blocking(move || apps::Apps::load(&load_cancel))
.await
.map(Arc::new)
.unwrap_or_else(|_| Arc::new(apps::Apps::empty()))
},
move |apps| cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps))),
),
])
}
pub(super) fn destroy_launcher(&mut self) -> Task<super::Message> {
let Some(launcher) = self.launcher.take() else {
return Task::none();
};
launcher.lifetime.cancel();
launcher.search_cancel.cancel();
self.core.set_keyboard_nav(launcher.keyboard_nav);
destroy_layer_surface(launcher.id)
}
fn close_launcher(&mut self) -> Task<super::Message> {
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
launcher.lifetime.cancel();
launcher.search_cancel.cancel();
launcher.closing = true;
launcher.progress.set_target(0.);
if !self.render.motion.motion_enabled() {
return self.finish_launcher();
}
Task::none()
}
fn finish_launcher(&mut self) -> Task<super::Message> {
let target = self.launcher.as_mut().and_then(|l| l.after_close.take());
let close = self.destroy_launcher();
if let Some(target) = target {
let terminal = self.config.terminal.clone();
close.chain(Task::perform(
async move {
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::task::spawn_blocking(move || launch::run(target, &terminal))
.await
.unwrap_or_else(|e| Err(e.to_string()))
},
|result| cosmic::Action::App(super::Message::Launcher(Message::Finished(result))),
))
} else {
close
}
}
pub(super) fn launcher_subscription(&self) -> Subscription<super::Message> {
let Some(launcher) = &self.launcher else {
return Subscription::none();
};
let interval = if launcher.progress.is_animating() || launcher.height.needs_update(launcher.target_height()) {
16
} else if launcher.category == Some(Category::Windows) && launcher.query.is_empty() {
500
} else if matches!(launcher.file_status, files::Status::Indexing) {
1000
} else {
return Subscription::none();
};
cosmic::iced::time::every(Duration::from_millis(interval)).map(|_| super::Message::Launcher(Message::Tick))
}
pub(super) fn refresh_launcher(&mut self) -> Task<super::Message> {
self.merge_launcher_windows(false)
}
fn merge_launcher_windows(&mut self, force: bool) -> Task<super::Message> {
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
if launcher.closing
|| (launcher.query.is_empty() && launcher.category.is_none())
|| !launcher.category.is_none_or(|category| category == Category::Windows)
{
return Task::none();
}
let changed = launcher
.windows
.sync(&self.snapshot, launcher.apps.as_deref(), &launcher.query);
if !changed && !force {
return Task::none();
}
let selected = launcher.rows.get(launcher.selected).map(|row| row.id.clone());
launcher.rows.retain(|row| !matches!(row.target, Target::Window(_)));
launcher.rows.extend(launcher.windows.rows().cloned());
sources::rank(&mut launcher.rows);
if launcher.category.is_none() {
launcher.rows.truncate(100);
}
launcher.selected = selected
.and_then(|id| launcher.rows.iter().position(|row| row.id == id))
.unwrap_or(0);
Task::none()
}
fn query_launcher(&mut self) -> Task<super::Message> {
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
if launcher.running || launcher.closing || (launcher.query.is_empty() && launcher.category.is_none()) {
return Task::none();
}
let Some(apps) = launcher.apps.clone() else {
return Task::none();
};
launcher.running = true;
launcher.search_cancel = work::Cancellation::default();
let cancel = launcher.search_cancel.clone();
let file_icons = launcher.file_icons.clone();
let (id, revision, query, category) = (
launcher.id,
launcher.revision,
launcher.query.clone(),
launcher.category,
);
let files = self.launcher_files.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let mut rows = Vec::new();
if !cancel.cancelled() && category.is_none_or(|c| c == Category::Apps) {
rows.extend(apps.search(&query, &cancel));
}
if !cancel.cancelled() && category.is_none_or(|c| c == Category::Files) {
rows.extend(files.search(&query, &cancel, &file_icons));
}
if !cancel.cancelled() && category.is_none() {
rows.extend(sources::Actions.query(&query));
}
if cancel.cancelled() {
return (Vec::new(), files::Status::Ready);
}
sources::rank(&mut rows);
if category.is_none() {
rows.truncate(100);
}
(
rows,
if category.is_none_or(|c| c == Category::Files) {
files.status()
} else {
files::Status::Ready
},
)
})
.await
.unwrap_or_else(|e| (Vec::new(), files::Status::Unavailable(e.to_string())))
},
move |(rows, status)| {
cosmic::Action::App(super::Message::Launcher(Message::Results(id, revision, rows, status)))
},
)
}
pub(super) fn update_launcher(&mut self, message: Message) -> Task<super::Message> {
if let Message::Finished(result) = message {
if let Err(error) = result {
self.notifications.service_error("Launcher action failed", &error);
}
return Task::none();
}
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
match message {
Message::AppsScrolled(id, first) if id == launcher.id && launcher.apps_browse() => {
launcher.apps_first_row = first.min(launcher.rows.len().div_ceil(4).saturating_sub(1));
Task::none()
}
Message::Input(id, query) if id == launcher.id && !launcher.closing => {
launcher.ghost_visible = launcher.query.starts_with(&query) || query.starts_with(&launcher.query);
launcher.query = query;
launcher.alternatives = false;
launcher.changed();
self.query_launcher()
}
Message::Browse(id, category) if id == launcher.id && !launcher.closing => {
launcher.category = Some(category);
launcher.query.clear();
launcher.alternatives = false;
launcher.changed();
launcher.thumbnails = Default::default();
let focus = cosmic::widget::text_input::focus(launcher.input.clone());
Task::batch([focus, self.query_launcher()])
}
Message::ActivateRow(id, row) if id == launcher.id && !launcher.closing => {
let Some(index) = launcher.rows.iter().position(|candidate| candidate.id == row) else {
return Task::none();
};
launcher.selected = index;
self.activate_launcher(false)
}
Message::Accept(alt) => self.activate_launcher(alt),
Message::Loaded(id, apps) if id == launcher.id => {
launcher.apps = Some(apps);
self.query_launcher()
}
Message::Results(id, revision, rows, status) if id == launcher.id => {
launcher.running = false;
if revision != launcher.revision {
return self.query_launcher();
}
let selected = launcher
.preferred_result
.take()
.or_else(|| launcher.rows.get(launcher.selected).map(|row| row.id.clone()));
launcher.rows = rows;
launcher.file_status = status;
let task = self.merge_launcher_windows(true);
let launcher = self.launcher.as_mut().unwrap();
launcher.selected = selected
.and_then(|id| launcher.rows.iter().position(|row| row.id == id))
.unwrap_or(0);
task
}
Message::Launched(id, result) if id == launcher.id => match result {
Ok(()) => self.close_launcher(),
Err(error) => {
launcher.activating = false;
launcher.error = Some(error);
Task::none()
}
},
Message::Tick => {
let now = Instant::now();
let delta = now
.duration_since(launcher.tick)
.min(Duration::from_millis(64))
.mul_f64(self.render.motion.speed);
launcher.tick = now;
let base = self.render.motion.spring_config().unwrap_or_default();
let stiffness = base.stiffness * if launcher.closing { 1.6 } else { 1. };
let spring = SpringConfig {
stiffness,
damping: 2. * (stiffness * base.mass).sqrt(),
position_tolerance: 0.001,
velocity_tolerance: 0.001,
crossing: ferese_animation::CrossingPolicy::NoCrossing,
..base
};
launcher.height.set_target(launcher.target_height());
if self.render.motion.motion_enabled() {
launcher.progress.advance(delta, spring);
launcher.height.advance(delta, spring);
} else {
launcher.progress.snap();
launcher.height.snap();
}
if launcher.closing && !launcher.progress.is_animating() {
return self.finish_launcher();
}
if !launcher.closing && launcher.query.is_empty() && launcher.category == Some(Category::Windows) {
if let Some(control) = &self.control {
launcher
.thumbnails
.refresh(&launcher.rows, launcher.selected, control, now);
}
}
if !launcher.closing
&& now.duration_since(launcher.refresh) >= Duration::from_secs(1)
&& matches!(launcher.file_status, files::Status::Indexing)
{
launcher.refresh = now;
return self.query_launcher();
}
Task::none()
}
_ => Task::none(),
}
}
pub(super) fn launcher_event(&mut self, event: &Event, id: window::Id) -> Option<Task<super::Message>> {
let launcher = self.launcher.as_mut().filter(|l| l.id == id)?;
if let Event::Window(window::Event::Opened { .. }) = event {
return Some(Task::batch([
cosmic::widget::text_input::focus(launcher.input.clone()),
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(super::Message::NativeSurface(id, surface))),
]));
}
if matches!(
event,
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
wayland::LayerEvent::Unfocused,
..
)))
) {
return Some(self.destroy_launcher());
}
if let Event::Window(window::Event::Closed) = event {
return Some(self.destroy_launcher());
}
if launcher.closing {
return Some(Task::none());
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))) {
launcher.ghost_visible = false;
}
let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = event else {
return None;
};
if launcher.query.is_empty() && launcher.category.is_none() {
match key {
Key::Named(Named::ArrowDown | Named::ArrowRight | Named::Tab) => {
launcher.selected = if launcher.category_focused {
(launcher.selected + 1) % 3
} else {
0
};
launcher.category_focused = true;
return Some(cosmic::widget::text_input::focus(launcher.input.clone()));
}
Key::Named(Named::ArrowLeft | Named::ArrowUp) => {
launcher.selected = (launcher.selected + 2) % 3;
launcher.category_focused = true;
return Some(cosmic::widget::text_input::focus(launcher.input.clone()));
}
_ => {}
}
}
if launcher.apps_browse() {
let next = match key {
Key::Named(Named::ArrowLeft) => Some(launcher.selected.saturating_sub(1)),
Key::Named(Named::ArrowRight) => Some(launcher.selected + 1),
Key::Named(Named::ArrowUp) => Some(launcher.selected.saturating_sub(4)),
Key::Named(Named::ArrowDown) => Some(launcher.selected + 4),
_ => None,
};
if let Some(next) = next {
launcher.selected = next.min(launcher.rows.len().saturating_sub(1));
let rows = launcher.rows.len().div_ceil(4);
launcher.apps_first_row = (launcher.selected / 4).saturating_sub(2);
return Some(cosmic::iced::widget::scrollable::snap_to(
launcher.apps_scroll_id.clone(),
cosmic::iced::widget::scrollable::RelativeOffset {
x: None,
y: Some(if rows > 3 {
(launcher.selected / 4).saturating_sub(2) as f32 / (rows - 3) as f32
} else {
0.
}),
},
));
}
}
Some(match key {
Key::Named(Named::Escape) => {
if launcher.escape() {
self.close_launcher()
} else {
let focus = cosmic::widget::text_input::focus(launcher.input.clone());
Task::batch([focus, self.query_launcher()])
}
}
Key::Named(Named::ArrowDown) => {
if launcher.alternatives || launcher.query.is_empty() {
launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
}
launcher.alternatives = true;
Task::none()
}
Key::Named(Named::ArrowUp) => {
launcher.selected = launcher.selected.saturating_sub(1);
Task::none()
}
Key::Named(Named::ArrowRight)
if launcher.query.is_empty() && launcher.category == Some(Category::Windows) =>
{
launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
Task::none()
}
Key::Named(Named::ArrowLeft)
if launcher.query.is_empty() && launcher.category == Some(Category::Windows) =>
{
launcher.selected = launcher.selected.saturating_sub(1);
Task::none()
}
Key::Named(Named::Tab) if !launcher.query.is_empty() => {
if let Some(row) = launcher.rows.get(launcher.selected) {
let preferred = row.id.clone();
launcher.query = row.title.clone();
launcher.changed();
launcher.preferred_result = Some(preferred);
}
let focus = cosmic::widget::text_input::focus(launcher.input.clone());
let cursor = cosmic::widget::text_input::move_cursor_to_end(launcher.input.clone());
Task::batch([focus, cursor, self.query_launcher()])
}
Key::Named(Named::ArrowLeft | Named::ArrowRight | Named::Home) => {
launcher.ghost_visible = false;
Task::none()
}
Key::Named(Named::End) => {
launcher.ghost_visible = true;
Task::none()
}
Key::Named(Named::Tab) => cosmic::widget::text_input::focus(launcher.input.clone()),
Key::Named(Named::Enter) => self.activate_launcher(modifiers.alt()),
_ => Task::none(),
})
}
fn activate_launcher(&mut self, alt: bool) -> Task<super::Message> {
let Some(launcher) = &mut self.launcher else {
return Task::none();
};
if launcher.closing || launcher.running || launcher.activating {
return Task::none();
}
if launcher.query.is_empty() && launcher.category.is_none() && launcher.category_focused {
let category = [Category::Apps, Category::Files, Category::Windows][launcher.selected.min(2)];
let id = launcher.id;
return self.update_launcher(Message::Browse(id, category));
}
let Some(row) = launcher.rows.get(launcher.selected) else {
return Task::none();
};
if row.requires_alt() && !alt {
launcher.error = Some("Use Alt+Enter to confirm".into());
return Task::none();
}
let target = row.target.clone();
let id = launcher.id;
let terminal = self.config.terminal.clone();
if matches!(target, Target::Action(sources::Action::Screenshot)) {
launcher.after_close = Some(target);
return self.close_launcher();
}
if let Target::Window(window) = target {
if let Some(control) = &self.control {
if alt {
if let Some(workspace) = self
.snapshot
.outputs
.iter()
.find(|o| o.name == launcher.output)
.map(|o| o.active_workspace)
{
control.move_window_to_workspace(window, workspace);
}
} else {
control.activate_window(window);
}
}
return self.close_launcher();
}
use sources::Action;
if let Target::Action(action) = target {
let power = match action {
Action::Poweroff => Some(system_modal::PowerAction::Poweroff),
Action::Reboot => Some(system_modal::PowerAction::Reboot),
Action::Suspend => Some(system_modal::PowerAction::Suspend),
_ => None,
};
if let Some(power) = power {
let close = self.destroy_launcher();
let open = self.open_system_modal(power, None);
return Task::batch([close, open]);
}
}
launcher.activating = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || launch::run(target, &terminal))
.await
.unwrap_or_else(|e| Err(e.to_string()))
},
move |result| cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result))),
)
}
}
+297
View File
@@ -0,0 +1,297 @@
use crate::control::ShellSnapshot;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Category {
Apps,
Files,
Windows,
}
impl Category {
pub(super) fn label(self) -> &'static str {
match self {
Self::Apps => "Apps",
Self::Files => "Files",
Self::Windows => "Windows",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Action {
Light,
Dark,
Auto,
Screenshot,
Lock,
Suspend,
Poweroff,
Reboot,
Logout,
}
impl Action {
pub(super) fn destructive(self) -> bool {
matches!(self, Self::Poweroff | Self::Reboot | Self::Logout)
}
}
#[derive(Clone, Debug)]
pub(crate) enum Target {
App { id: String, desktop: PathBuf },
File(Arc<Path>),
Window(u64),
Action(Action),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ResultId {
App(Arc<str>),
File(Arc<Path>),
Window(u64),
Action(Action),
}
impl From<&str> for ResultId {
fn from(value: &str) -> Self {
Self::App(value.into())
}
}
#[derive(Clone, Debug)]
pub(crate) struct Row {
pub id: ResultId,
pub title: String,
pub icon: Option<cosmic::widget::icon::Handle>,
pub subtitle: String,
pub target: Target,
pub score: f64,
}
impl Row {
pub(super) fn category(&self) -> Option<Category> {
match self.target {
Target::App { .. } => Some(Category::Apps),
Target::File(_) => Some(Category::Files),
Target::Window(_) => Some(Category::Windows),
Target::Action(_) => None,
}
}
pub(super) fn requires_alt(&self) -> bool {
matches!(self.target, Target::Action(action) if action.destructive())
}
}
pub(super) trait Source {
fn query(&self, query: &str) -> Vec<Row>;
}
// Normalize the query once, and reuse normalized catalog names across keystrokes.
pub(super) struct Matcher(String);
impl Matcher {
pub(super) fn new(query: &str) -> Self {
Self(query.trim().to_lowercase())
}
pub(super) fn score(&self, text: &str) -> Option<f64> {
if self.0.is_empty() {
return Some(0.);
}
self.normalized(&text.to_lowercase())
}
pub(super) fn normalized(&self, text: &str) -> Option<f64> {
let query = &self.0;
if query.is_empty() {
return Some(0.);
}
if text == query {
return Some(1000.);
}
if text.starts_with(query) {
return Some(800. - text.chars().count() as f64 * 0.1);
}
if let Some(offset) = text.find(query) {
return Some(600. - offset as f64);
}
let mut remaining = query.chars();
let mut next = remaining.next()?;
for ch in text.chars() {
if ch == next {
match remaining.next() {
Some(ch) => next = ch,
None => return Some(300. - text.chars().count() as f64 * 0.1),
}
}
}
None
}
}
#[cfg(test)]
pub(super) fn match_score(query: &str, text: &str) -> Option<f64> {
Matcher::new(query).score(text)
}
pub(super) fn rank(rows: &mut Vec<Row>) {
// Group duplicate identities without allocating cloned IDs or a hash table.
rows.sort_unstable_by(|a, b| {
a.id.cmp(&b.id)
.then_with(|| b.score.total_cmp(&a.score))
.then_with(|| a.title.cmp(&b.title))
});
rows.dedup_by(|a, b| a.id == b.id);
rows.sort_unstable_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.title.cmp(&b.title))
.then_with(|| a.id.cmp(&b.id))
});
}
#[derive(Default)]
pub(super) struct Windows {
entries: std::collections::HashMap<u64, WindowEntry>,
workspaces: std::collections::HashMap<u64, String>,
workspace_ids: std::collections::HashSet<u64>,
query: String,
apps_ready: bool,
}
struct WindowEntry {
title: String,
app_id: String,
workspace: u64,
workspace_name: String,
result: Option<Row>,
icon: Option<cosmic::widget::icon::Handle>,
seen: bool,
}
impl Windows {
pub(super) fn sync(&mut self, snapshot: &ShellSnapshot, apps: Option<&super::apps::Apps>, query: &str) -> bool {
let query_changed = self.query != query || self.apps_ready != apps.is_some();
if self.query != query {
self.query.clear();
self.query.push_str(query);
}
self.apps_ready = apps.is_some();
let matcher = Matcher::new(query);
self.workspace_ids.clear();
for workspace in &snapshot.workspaces {
self.workspace_ids.insert(workspace.id);
self.workspaces
.entry(workspace.id)
.or_default()
.clone_from(&workspace.name);
}
self.workspaces.retain(|id, _| self.workspace_ids.contains(id));
for entry in self.entries.values_mut() {
entry.seen = false;
}
let mut changed = query_changed;
for window in &snapshot.windows {
let name = self.workspaces.get(&window.workspace).map_or("?", String::as_str);
if let Some(entry) = self.entries.get_mut(&window.id) {
entry.seen = true;
if !query_changed
&& entry.title == window.title
&& entry.app_id == window.app_id
&& entry.workspace == window.workspace
&& entry.workspace_name == name
{
continue;
}
}
let title = if window.title.is_empty() {
&window.app_id
} else {
&window.title
};
let score = matcher
.score(title)
.into_iter()
.chain(matcher.score(&window.app_id))
.max_by(f64::total_cmp);
let icon = apps.and_then(|apps| apps.icon(&window.app_id));
let result = score.map(|score| Row {
id: ResultId::Window(window.id),
title: title.clone(),
icon: icon.clone(),
subtitle: format!("Window · Workspace {name}"),
target: Target::Window(window.id),
score,
});
self.entries.insert(
window.id,
WindowEntry {
title: window.title.clone(),
app_id: window.app_id.clone(),
workspace: window.workspace,
workspace_name: name.into(),
result,
icon,
seen: true,
},
);
changed = true;
}
self.entries.retain(|_, entry| {
if !entry.seen {
changed = true;
}
entry.seen
});
changed
}
pub(super) fn rows(&self) -> impl Iterator<Item = &Row> {
self.entries.values().filter_map(|entry| entry.result.as_ref())
}
}
impl Source for Windows {
fn query(&self, query: &str) -> Vec<Row> {
if self.query == query {
return self.rows().cloned().collect();
}
let matcher = Matcher::new(query);
self.entries
.iter()
.filter_map(|(id, entry)| {
let title = if entry.title.is_empty() {
&entry.app_id
} else {
&entry.title
};
let score = matcher
.score(title)
.into_iter()
.chain(matcher.score(&entry.app_id))
.max_by(f64::total_cmp)?;
Some(Row {
id: ResultId::Window(*id),
title: title.clone(),
icon: entry.icon.clone(),
subtitle: format!("Window · Workspace {}", entry.workspace_name),
target: Target::Window(*id),
score,
})
})
.collect()
}
}
pub(super) struct Actions;
impl Source for Actions {
fn query(&self, query: &str) -> Vec<Row> {
let matcher = Matcher::new(query);
[
("Light theme", Action::Light),
("Dark theme", Action::Dark),
("Automatic theme", Action::Auto),
("Take screenshot", Action::Screenshot),
("Lock screen", Action::Lock),
("Suspend", Action::Suspend),
("Power off", Action::Poweroff),
("Restart", Action::Reboot),
("Log out", Action::Logout),
]
.into_iter()
.filter_map(|(title, action)| {
let score = matcher.score(title)?;
Some(Row {
id: ResultId::Action(action),
icon: None,
title: title.into(),
subtitle: "Action".into(),
target: Target::Action(action),
score,
})
})
.collect()
}
}
+180
View File
@@ -0,0 +1,180 @@
use super::*;
fn shell() -> FereseShell {
let mut shell = crate::tests::shell_with_measured_panel();
let _ = shell.toggle_launcher("test".into());
shell
}
fn row(title: &str) -> Row {
Row {
id: title.into(),
title: title.into(),
icon: None,
subtitle: "Action".into(),
target: Target::Action(sources::Action::Lock),
score: 1.,
}
}
#[test]
fn escape_pops_one_layer_in_every_category() {
let mut shell = shell();
let launcher = shell.launcher.as_mut().unwrap();
for category in [
None,
Some(Category::Apps),
Some(Category::Files),
Some(Category::Windows),
] {
launcher.category = category;
launcher.query = "term".into();
launcher.alternatives = true;
assert!(!launcher.escape());
assert!(launcher.query.is_empty());
assert!(!launcher.alternatives);
assert_eq!(launcher.category, category);
if category.is_some() {
assert!(!launcher.escape());
assert_eq!(launcher.category, None);
}
assert!(launcher.escape());
}
}
#[test]
fn typing_never_opens_alternates_and_empty_home_has_pills() {
let mut shell = shell();
let _ = shell.update_launcher(Message::Input(shell.launcher.as_ref().unwrap().id, "fire".into()));
let launcher = shell.launcher.as_mut().unwrap();
assert!(!launcher.alternatives);
assert_eq!(launcher.target_height(), 72.);
launcher.rows = vec![row("Firefox"), row("Files")];
launcher.alternatives = true;
assert_eq!(launcher.target_height(), 208.);
assert!(!launcher.escape());
assert_eq!(launcher.target_height(), 72.);
}
#[test]
fn late_results_cannot_replace_a_new_query_or_invocation() {
let mut shell = shell();
let id = shell.launcher.as_ref().unwrap().id;
let _ = shell.update_launcher(Message::Input(id, "new".into()));
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready));
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
let _ = shell.destroy_launcher();
let _ = shell.update_launcher(Message::Results(id, 1, vec![row("old")], files::Status::Ready));
assert!(shell.launcher.is_none());
let _ = shell.toggle_launcher("test".into());
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("old")], files::Status::Ready));
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
}
#[test]
fn selection_survives_result_reordering() {
let mut shell = shell();
let launcher = shell.launcher.as_mut().unwrap();
launcher.rows = vec![row("one"), row("two")];
launcher.selected = 1;
let (id, revision) = (launcher.id, launcher.revision);
let _ = shell.update_launcher(Message::Results(
id,
revision,
vec![row("two"), row("one")],
files::Status::Ready,
));
assert_eq!(shell.launcher.as_ref().unwrap().selected, 0);
}
#[test]
fn destructive_actions_require_alt() {
for action in [
sources::Action::Poweroff,
sources::Action::Reboot,
sources::Action::Logout,
] {
let mut shell = shell();
let mut result = row("dangerous");
result.target = Target::Action(action);
shell.launcher.as_mut().unwrap().rows = vec![result];
let _ = shell.activate_launcher(false);
let launcher = shell.launcher.as_ref().unwrap();
assert!(!launcher.closing);
assert!(!launcher.activating);
assert_eq!(launcher.error.as_deref(), Some("Use Alt+Enter to confirm"));
assert!(shell.system_modal.is_none());
}
}
#[test]
fn rank_is_deterministic_and_deduplicates_ids() {
let mut rows = vec![row("B"), row("A"), row("B")];
sources::rank(&mut rows);
assert_eq!(
rows.iter().map(|r| r.title.as_str()).collect::<Vec<_>>(),
vec!["A", "B"]
);
assert!(sources::match_score("é", "Éditeur").is_some());
assert!(sources::match_score("nope", "Terminal").is_none());
}
#[test]
fn close_releases_catalog_and_restores_keyboard_navigation() {
let mut shell = shell();
let apps = Arc::new(apps::Apps::empty());
let weak = Arc::downgrade(&apps);
shell.launcher.as_mut().unwrap().apps = Some(apps);
shell.launcher.as_mut().unwrap().keyboard_nav = true;
let _ = shell.destroy_launcher();
assert!(weak.upgrade().is_none());
assert!(shell.core.keyboard_nav());
assert!(shell.launcher.is_none());
}
#[test]
fn queries_are_coalesced_and_only_the_latest_result_is_applied() {
let mut shell = shell();
let launcher = shell.launcher.as_mut().unwrap();
launcher.apps = Some(Arc::new(apps::Apps::empty()));
launcher.running = true;
let id = launcher.id;
let _ = shell.update_launcher(Message::Input(id, "first".into()));
let _ = shell.update_launcher(Message::Input(id, "second".into()));
let revision = shell.launcher.as_ref().unwrap().revision;
let _ = shell.update_launcher(Message::Results(id, 0, vec![row("stale")], files::Status::Ready));
assert!(shell.launcher.as_ref().unwrap().running);
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
let _ = shell.update_launcher(Message::Results(
id,
revision,
vec![row("current")],
files::Status::Ready,
));
assert!(!shell.launcher.as_ref().unwrap().running);
assert_eq!(shell.launcher.as_ref().unwrap().rows[0].title, "current");
}
#[test]
fn queued_widget_messages_cannot_change_a_reopened_launcher() {
let mut shell = shell();
let old = shell.launcher.as_ref().unwrap().id;
let _ = shell.destroy_launcher();
let _ = shell.toggle_launcher("test".into());
let _ = shell.update_launcher(Message::Input(old, "stale paste".into()));
let _ = shell.update_launcher(Message::Browse(old, Category::Files));
let launcher = shell.launcher.as_ref().unwrap();
assert!(launcher.query.is_empty());
assert_eq!(launcher.category, None);
}
#[test]
fn clicked_rows_keep_their_identity_after_a_result_refresh() {
let mut shell = shell();
let launcher = shell.launcher.as_mut().unwrap();
let id = launcher.id;
let mut power = row("power");
power.target = Target::Action(sources::Action::Poweroff);
let mut window = row("window");
window.target = Target::Window(99);
launcher.rows = vec![window, power];
let _ = shell.update_launcher(Message::ActivateRow(id, "power".into()));
let launcher = shell.launcher.as_ref().unwrap();
assert_eq!(launcher.selected, 1);
assert!(!launcher.closing);
assert_eq!(launcher.error.as_deref(), Some("Use Alt+Enter to confirm"));
let _ = shell.update_launcher(Message::ActivateRow(id, "vanished".into()));
assert_eq!(shell.launcher.as_ref().unwrap().selected, 1);
}
@@ -0,0 +1,83 @@
use super::*;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT_TOKEN: AtomicU32 = AtomicU32::new(1);
pub(super) struct Thumbnails {
token: u32,
pub images: HashMap<u64, image::Handle>,
pending: HashSet<u64>,
updated: Option<Instant>,
visible: [Option<u64>; 3],
}
impl Default for Thumbnails {
fn default() -> Self {
Self {
token: NEXT_TOKEN.fetch_add(1, Ordering::Relaxed),
images: HashMap::new(),
pending: HashSet::new(),
updated: None,
visible: [None; 3],
}
}
}
impl Thumbnails {
pub fn refresh(&mut self, rows: &[Row], selected: usize, control: &ShellControl, now: Instant) {
if self
.updated
.is_some_and(|updated| now.duration_since(updated) < Duration::from_millis(500))
{
return;
}
self.visible = std::array::from_fn(|offset| {
rows.get(selected.saturating_sub(2) + offset)
.and_then(|row| match row.target {
Target::Window(id) => Some(id),
_ => None,
})
});
self.images.retain(|id, _| self.visible.contains(&Some(*id)));
for id in self.visible.into_iter().flatten() {
if self.pending.len() < 3 && self.pending.insert(id) {
control.thumbnail(self.token, id);
}
}
self.updated = Some(now);
}
}
impl FereseShell {
pub(crate) fn launcher_thumbnail(&mut self, token: u32, window: u64, image: Option<image::Handle>) {
let Some(launcher) = &mut self.launcher else {
return;
};
if launcher.closing || launcher.thumbnails.token != token || !launcher.thumbnails.pending.remove(&window) {
return;
}
if let Some(image) = image {
launcher.thumbnails.images.insert(window, image);
} else {
launcher.thumbnails.images.remove(&window);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnail_replies_must_belong_to_the_open_invocation() {
let mut shell = crate::tests::shell_with_measured_panel();
let _ = shell.toggle_launcher("test".into());
let launcher = shell.launcher.as_mut().unwrap();
let token = launcher.thumbnails.token;
launcher.thumbnails.pending.insert(1);
let image = image::Handle::from_rgba(1, 1, vec![255, 0, 0, 255]);
shell.launcher_thumbnail(token.wrapping_add(1), 1, Some(image.clone()));
assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty());
shell.launcher_thumbnail(token, 1, Some(image.clone()));
assert_eq!(shell.launcher.as_ref().unwrap().thumbnails.images.len(), 1);
let _ = shell.destroy_launcher();
let _ = shell.toggle_launcher("test".into());
shell.launcher_thumbnail(token, 1, Some(image));
assert!(shell.launcher.as_ref().unwrap().thumbnails.images.is_empty());
}
}
+531
View File
@@ -0,0 +1,531 @@
use super::*;
use cosmic::iced::border::Shape;
use cosmic::iced::widget::{Space, rich_text, span, stack};
use cosmic::widget::{column, text_input};
type Ui<'a> = Element<'a, cosmic::Action<super::super::Message>>;
fn message(message: Message) -> cosmic::Action<super::super::Message> {
cosmic::Action::App(super::super::Message::Launcher(message))
}
fn fill(color: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| container::Style {
background: Some(color.into()),
border: Border {
radius: radius.into(),
shape: Shape::Continuous,
..Default::default()
},
..Default::default()
})
}
fn button_style(color: Color) -> theme::Button {
button_radius(color, 20.)
}
fn button_radius(color: Color, radius: f32) -> theme::Button {
let paint = move |hovered| button::Style {
background: Some(color.scale_alpha(if hovered { 0.18 } else { 0.09 }).into()),
border_radius: radius.into(),
border_width: 0.,
shape: Some(Shape::Continuous),
..Default::default()
};
theme::Button::Custom {
active: Box::new(move |_, _| paint(false)),
hovered: Box::new(move |_, _| paint(true)),
pressed: Box::new(move |_, _| paint(true)),
disabled: Box::new(move |_| paint(false)),
}
}
impl FereseShell {
pub(super) fn view_launcher(&self, id: window::Id) -> Ui<'_> {
motion::frame_driven(
None,
move |_| self.launcher_content(id),
|_| false,
move |_| {
if let Some(launcher) = self.launcher.as_ref().filter(|launcher| launcher.id == id)
&& let Some(effects) = &launcher.effects
{
let _ = effects.set_presentation(
&launcher.regions.lock().unwrap(),
launcher.progress.current as f32,
[],
ferese_surface_effects_v1::Role::Popover,
);
}
},
|_| None,
)
}
fn launcher_content(&self, id: window::Id) -> Ui<'_> {
let Some(launcher) = self.launcher.as_ref().filter(|l| l.id == id) else {
return Space::new().into();
};
let palette = self.config.theme.palette();
let opacity = launcher.progress.current as f32;
let text = palette.text.scale_alpha(opacity);
let muted = palette.muted.scale_alpha(opacity);
let appearance = move |_: &cosmic::Theme| text_input::Appearance {
shape: Some(Shape::Continuous),
outline: None,
background: Color::TRANSPARENT.into(),
border_radius: 0.into(),
border_offset: None,
border_width: 0.,
border_color: Color::TRANSPARENT,
label_color: text,
placeholder_color: muted,
selected_text_color: text,
icon_color: Some(text),
text_color: Some(text),
selected_fill: palette.accent.scale_alpha(0.3 * opacity),
};
let accent = palette.accent.scale_alpha(opacity);
let medium = cosmic::font::Font {
weight: cosmic::iced::font::Weight::Medium,
..self.render.font
};
let placeholder = launcher.category.map_or("Search", Category::label);
let input = text_input(placeholder, &launcher.query)
.id(launcher.input.clone())
.font(self.render.font)
.size(24)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(32.into()))
.padding(0)
.on_input(move |value| message(Message::Input(id, value)))
.style(theme::TextInput::Custom {
active: Box::new(appearance),
error: Box::new(appearance),
hovered: Box::new(appearance),
focused: Box::new(appearance),
disabled: Box::new(appearance),
});
let completion = launcher
.rows
.get(launcher.selected)
.filter(|_| !launcher.query.is_empty() && launcher.ghost_visible && launcher.query.chars().count() <= 28)
.map(|row| {
if row
.title
.get(..launcher.query.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(&launcher.query))
{
std::borrow::Cow::Borrowed(&row.title[launcher.query.len()..])
} else {
std::borrow::Cow::Owned(format!(" {}", row.title))
}
});
let show_tab = completion.is_some();
let completion_text = rich_text([
span::<(), cosmic::font::Font>(&launcher.query).color(Color::TRANSPARENT),
span(completion.unwrap_or_default()).color(text.scale_alpha(0.3)),
])
.size(24)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(32.into()))
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.font(self.render.font);
let mut suggestion = row![completion_text]
.spacing(6)
.align_y(cosmic::iced::Alignment::Center);
if show_tab {
suggestion = suggestion.push(
container(
self.render
.text("Tab")
.size(12)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into()))
.class(theme::Text::Color(muted)),
)
.padding([2, 6])
.class(fill(text.scale_alpha(0.06), 5.)),
);
}
// The input determines the layout even when the completion is empty.
// Keep both layers present so result updates preserve focus and cursor state.
let editor: Ui<'_> = stack![input]
.push_under(suggestion)
.width(Length::Fill)
.clip(true)
.into();
let search_match = launcher
.rows
.get(launcher.selected)
.filter(|_| !launcher.query.is_empty());
let icon_category = search_match.map_or(launcher.category, Row::category);
let icon = match icon_category {
Some(Category::Windows) => ferese_theme::icons::WINDOW,
Some(Category::Apps) => ferese_theme::icons::APPS,
Some(Category::Files) => ferese_theme::icons::FOLDER,
None if search_match.is_some() => ferese_theme::icons::morph::CONTROLS,
None => ferese_theme::icons::SEARCH,
};
let icon_color = if search_match.is_some() || launcher.category.is_some() {
accent
} else {
muted
};
let mut input_row = row![bar_icon(icon, 24, icon_color), editor]
.spacing(16)
.align_y(cosmic::iced::Alignment::Center);
if let Some(handle) = search_match.and_then(|result| result.icon.clone()) {
input_row = input_row.push(cosmic::widget::icon::icon(handle).size(28).opacity(opacity));
}
if launcher.query.is_empty() && launcher.category.is_none() {
let dark = self.config.theme.appearance == ferese_config::theme::Appearance::Dark;
let mut pills = row![].spacing(6);
for (index, category) in [Category::Apps, Category::Files, Category::Windows]
.into_iter()
.enumerate()
{
let symbol = match category {
Category::Apps => ferese_theme::icons::APPS,
Category::Files => ferese_theme::icons::FOLDER,
Category::Windows => ferese_theme::icons::WINDOW,
};
let selected = launcher.category_focused && launcher.selected == index;
let normal = if dark { 0.065 } else { 0.04 };
let strong = if dark { 0.10 } else { 0.07 };
let paint = move |alpha| button::Style {
background: Some(text.scale_alpha(alpha).into()),
border_radius: 16.into(),
border_width: 0.,
..Default::default()
};
let label = self
.render
.text(category.label())
.font(medium)
.size(13)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(18.into()))
.class(theme::Text::Color(text));
pills = pills.push(
button::custom(
row![bar_icon(symbol, 16, accent), label]
.spacing(7)
.align_y(cosmic::iced::Alignment::Center),
)
.height(32)
.padding(cosmic::iced::Padding {
top: 7.,
bottom: 7.,
left: 10.,
right: 13.,
})
.class(theme::Button::Custom {
active: Box::new(move |_, _| paint(if selected { strong } else { normal })),
hovered: Box::new(move |_, _| paint(strong)),
pressed: Box::new(move |_, _| paint(strong - 0.02)),
disabled: Box::new(move |_| paint(normal)),
})
.on_press(message(Message::Browse(id, category))),
);
}
input_row = input_row.push(pills);
}
let input_content = if let Some(error) = launcher.status_message() {
column![
input_row,
self.render.text(error).size(12).class(theme::Text::Color(muted))
]
.spacing(2)
} else {
column![input_row]
};
let mut body = column![
container(input_content)
.height(72)
.padding(cosmic::iced::Padding {
left: 24.,
right: 20.,
..Default::default()
})
.align_y(cosmic::iced::Alignment::Center)
];
if launcher.apps_browse() {
let total = launcher.rows.len().div_ceil(4);
let first = launcher.apps_first_row.min(total.saturating_sub(1)).saturating_sub(1);
let end = (first + 6).min(total);
let mut grid = cosmic::iced::widget::keyed_column(Vec::<(usize, Ui<'_>)>::new());
if first > 0 {
grid = grid.push(usize::MAX, Space::new().height(first as f32 * 108.));
}
for (row_index, apps) in launcher.rows.chunks(4).enumerate().skip(first).take(end - first) {
let mut tiles = row![].spacing(12);
for (column_index, app) in apps.iter().enumerate() {
let glyph: Ui<'_> = match &app.icon {
Some(handle) => cosmic::widget::icon::icon(handle.clone())
.size(44)
.opacity(opacity)
.into(),
None => bar_icon(ferese_theme::icons::APPS, 44, accent).into(),
};
let label = container(
self.render
.text(&app.title)
.font(medium)
.size(14)
.class(theme::Text::Color(text)),
)
.center_x(Length::Fill)
.height(20)
.clip(true);
tiles = tiles.push(
button::custom(
column![container(glyph).center_x(Length::Fill), label]
.spacing(8)
.align_x(cosmic::iced::Alignment::Center),
)
.width(Length::Fill)
.height(100)
.padding(12)
.class(button_style(if launcher.selected == row_index * 4 + column_index {
palette.accent.scale_alpha(opacity)
} else {
Color::TRANSPARENT
}))
.on_press(message(Message::ActivateRow(id, app.id.clone()))),
);
}
for _ in apps.len()..4 {
tiles = tiles.push(Space::new().width(Length::Fill));
}
grid = grid.push(
row_index,
container(tiles).height(if row_index + 1 == total { 100 } else { 108 }),
);
}
if end < total {
grid = grid.push(usize::MAX - 1, Space::new().height((total - end) as f32 * 108. - 8.));
}
if launcher.rows.is_empty() {
grid = grid.push(
usize::MAX - 2,
container(
self.render
.text(if launcher.running { "Loading apps…" } else { "No apps" })
.size(14)
.class(theme::Text::Color(muted)),
)
.height(100),
);
}
let height = launcher.rows.len().div_ceil(4).clamp(1, 3) as f32 * 108. - 8.;
body = body.push(
container(
cosmic::iced::widget::scrollable(grid)
.id(launcher.apps_scroll_id.clone())
.on_scroll(move |viewport| {
message(Message::AppsScrolled(
id,
(viewport.absolute_offset().y / 108.) as usize,
))
})
.height(height),
)
.padding([0, 16]),
);
} else if launcher.query.is_empty() && launcher.category == Some(Category::Windows) {
let mut cards = row![].spacing(12);
for (index, result) in launcher
.rows
.iter()
.enumerate()
.skip(launcher.selected.saturating_sub(2))
.take(3)
{
let Target::Window(id) = result.target else {
continue;
};
let window = self.snapshot.windows.iter().find(|w| w.id == id);
let workspace = window.and_then(|w| self.snapshot.workspaces.iter().find(|ws| ws.id == w.workspace));
let thumbnail: Ui<'_> = match launcher.thumbnails.images.get(&id) {
Some(handle) => image(handle.clone())
.width(Length::Fill)
.height(84)
.content_fit(ContentFit::Contain)
.opacity(opacity)
.into(),
None => container(bar_icon(ferese_theme::icons::WINDOW, 28, muted))
.center_x(Length::Fill)
.center_y(84)
.into(),
};
let badge = container(
self.render
.text(workspace.map_or("?", |w| w.name.as_str()))
.size(12)
.class(theme::Text::Color(text)),
)
.padding([3, 6])
.class(fill(palette.sidebar.scale_alpha(opacity), 7.));
let preview = container(stack![thumbnail, container(badge).padding(6)])
.clip(true)
.class(fill(palette.card.scale_alpha(opacity), 15.));
let label = self
.render
.text(window.map_or(result.title.as_str(), |w| {
launcher
.apps
.as_ref()
.and_then(|apps| apps.name(&w.app_id))
.unwrap_or(if w.app_id.is_empty() { &w.title } else { &w.app_id })
}))
.size(14)
.class(theme::Text::Color(text));
let selected = index == launcher.selected;
let card = container(column![preview, label].spacing(8))
.padding(6)
.class(theme::Container::custom(move |_| container::Style {
border: Border {
radius: 20.into(),
width: if selected { 2. } else { 0. },
color: palette.accent.scale_alpha(opacity),
shape: Shape::Continuous,
..Default::default()
},
..Default::default()
}));
cards = cards.push(
button::custom(card)
.width(Length::Fill)
.padding(0)
.class(button_style(Color::TRANSPARENT))
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
);
}
if launcher.rows.is_empty() {
cards = cards.push(
self.render
.text("No open windows")
.size(14)
.class(theme::Text::Color(muted)),
);
}
body = body.push(container(cards).padding([0, 16]));
} else if launcher.alternatives || (launcher.query.is_empty() && launcher.category.is_some()) {
let start = launcher.selected.saturating_sub(4);
let mut rows = column![].spacing(0);
for (index, result) in launcher.rows.iter().enumerate().skip(start).take(5) {
let symbol = match result.category() {
Some(Category::Windows) => ferese_theme::icons::WINDOW,
Some(Category::Files) => ferese_theme::icons::FOLDER,
Some(Category::Apps) => ferese_theme::icons::APPS,
None => ferese_theme::icons::morph::CONTROLS,
};
let tone = accent;
let result_icon: Ui<'_> = match &result.icon {
Some(handle) => cosmic::widget::icon::icon(handle.clone())
.size(24)
.opacity(opacity)
.into(),
None => bar_icon(symbol, 20, tone).into(),
};
let glyph = container(result_icon)
.center_x(36)
.center_y(36)
.class(fill(tone.scale_alpha(0.1), 12.));
let labels = column![
self.render
.text(&result.title)
.font(medium)
.size(15)
.class(theme::Text::Color(text)),
self.render
.text(&result.subtitle)
.size(12.5)
.class(theme::Text::Color(muted))
]
.spacing(3)
.width(Length::Fill);
let mut content = row![glyph, labels].spacing(12).align_y(cosmic::iced::Alignment::Center);
if index == launcher.selected {
content = content.push(
self.render
.text(if result.requires_alt() { "Alt+Enter" } else { "Enter" })
.size(12)
.class(theme::Text::Color(muted)),
);
}
rows = rows.push(
button::custom(
container(content)
.height(60)
.clip(true)
.align_y(cosmic::iced::Alignment::Center),
)
.width(Length::Fill)
.padding([0, 12])
.class(button_radius(
if index == launcher.selected {
palette.accent.scale_alpha(opacity)
} else {
Color::TRANSPARENT
},
18.,
))
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
);
}
if launcher.rows.is_empty() {
rows = rows.push(
container(
self.render
.text(launcher.status_message().unwrap_or(if launcher.running {
"Searching…"
} else {
"No matches"
}))
.size(13)
.class(theme::Text::Color(muted)),
)
.height(60)
.padding(16),
);
}
body = body.push(container(rows).padding([0, 12]));
}
let available_height = self
.outputs
.iter()
.find(|o| o.name.as_deref() == Some(&launcher.output))
.and_then(|o| o.size)
.map(|(_, h)| h as f32);
let top = available_height.map_or(launcher.top, |h| {
(h * 0.3).min(h - launcher.height.current as f32 - 16.).max(16.)
});
let mut background = color_with_opacity(self.config.theme.surface_popover, opacity);
if self.config.theme.high_contrast || self.config.theme.reduce_transparency {
background.a = opacity;
}
let compositor_material = launcher.effects.is_some();
let panel = container(body)
.id("ferese-blur-card")
.width(if launcher.apps_browse() { 720 } else { 560 })
.height(launcher.height.current as f32)
.clip(true)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material).then_some(background.into()),
text_color: Some(text),
border: Border {
color: muted.scale_alpha(0.16),
width: 0.5,
radius: 28.into(),
shape: Shape::Continuous,
..Default::default()
},
..Default::default()
}));
container(motion::scaled(
panel.into(),
0.97 + 0.03 * opacity,
launcher.regions.clone(),
))
.width(Length::Fill)
.align_x(cosmic::iced::Alignment::Center)
.padding(cosmic::iced::Padding {
top,
..Default::default()
})
.into()
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Default, Debug)]
pub(super) struct Cancellation(Arc<AtomicBool>);
impl Cancellation {
pub(super) fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub(super) fn cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
+30 -1
View File
@@ -12,6 +12,7 @@ mod control;
mod display_mode;
mod ipc_subscription;
mod keybinding_guide;
mod launcher;
mod media;
mod motion;
mod notification_ui;
@@ -170,6 +171,8 @@ struct FereseShell {
pending_config_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
system_modal: Option<system_modal::SystemModal>,
launcher: Option<launcher::Launcher>,
launcher_files: launcher::files::Files,
display_mode: display_mode::Model,
guide_shown: bool,
guide_load: keybinding_guide::LoadState,
@@ -194,6 +197,8 @@ struct OutputSurfaces {
#[derive(Clone, Debug)]
enum Message {
Launcher(launcher::Message),
LauncherEnter(window::Id, bool),
NightLightTemperature(u16),
CommitNightLightTemperature,
HubOpen(window::Id, status_ui::Menu),
@@ -335,6 +340,8 @@ impl cosmic::Application for FereseShell {
pending_config_error,
menu: None,
system_modal: None,
launcher: None,
launcher_files: Default::default(),
display_mode: display_mode::Model::default(),
guide_shown: false,
guide_load: Default::default(),
@@ -365,6 +372,7 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
self.launcher_subscription(),
self.control.as_ref().map_or_else(Subscription::none, |control| {
control.subscription().map(|_| Message::ControlReady)
}),
@@ -376,8 +384,11 @@ impl cosmic::Application for FereseShell {
event::listen_with(|event, status, id| match &event {
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
modifiers,
..
}) if status == cosmic::iced::event::Status::Captured => None,
}) if status == cosmic::iced::event::Status::Captured => {
Some(Message::LauncherEnter(id, modifiers.alt()))
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
.load(Ordering::Relaxed)
.then_some(Message::Event(event, id)),
@@ -407,6 +418,14 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
let task = match message {
Message::Launcher(message) => self.update_launcher(message),
Message::LauncherEnter(id, alt) => {
if self.launcher.as_ref().is_some_and(|l| l.id == id) {
self.update_launcher(launcher::Message::Accept(alt))
} else {
Task::none()
}
}
Message::HubOpen(id, kind) => self.open_hub(id, kind),
Message::HubClose(id) => self.close_hub(id),
Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta),
@@ -562,6 +581,7 @@ impl cosmic::Application for FereseShell {
}
Ok((_connection, surface)) => {
self.attach_power_material(id, &surface);
self.attach_launcher_effects(id, &surface);
if let Some(entry) = &mut self.notification_surface
&& entry.id == id
&& entry.effects.is_none()
@@ -857,6 +877,7 @@ impl cosmic::Application for FereseShell {
if let Some(snapshot) = poll.snapshot {
let mode_was_active = self.snapshot.binding_mode.is_some();
self.snapshot = snapshot;
reload_task = Task::batch([reload_task, self.refresh_launcher()]);
let mode = self.binding_mode_transition(mode_was_active);
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
let mut tasks = vec![
@@ -919,6 +940,12 @@ impl cosmic::Application for FereseShell {
}
for command in poll.commands {
let task = match command {
control::ControlCommand::DismissLauncher => self.destroy_launcher(),
control::ControlCommand::Thumbnail(token, window, image) => {
self.launcher_thumbnail(token, window, image);
Task::none()
}
control::ControlCommand::ToggleLauncher(output) => self.toggle_launcher(output),
control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)),
control::ControlCommand::MonitorsChanged => self.load_display_inventory(),
control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output),
@@ -1177,6 +1204,8 @@ mod tests {
pending_config_error: None,
menu: None,
system_modal: None,
launcher: None,
launcher_files: Default::default(),
display_mode: Default::default(),
guide_shown: false,
guide_load: Default::default(),
+122 -72
View File
@@ -348,18 +348,31 @@ pub fn animated_from_edge<'a, M: 'a>(
Element::new(Motion {
content,
progress,
regions,
regions: Some(regions),
radius,
scale: 1.,
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
})
}
pub fn scaled<'a, M: 'a>(content: Element<'a, M>, scale: f32, regions: Regions) -> Element<'a, M> {
Element::new(Motion {
content,
progress: 1.,
regions: Some(regions),
radius: 28.,
direction: 0.,
scale,
})
}
struct Motion<'a, M> {
content: Element<'a, M>,
progress: f32,
regions: Regions,
regions: Option<Regions>,
radius: f32,
direction: f32,
scale: f32,
}
impl<M> Motion<'_, M> {
@@ -367,9 +380,11 @@ impl<M> Motion<'_, M> {
Vector::new(0.0, self.direction * 4.0 * (1.0 - self.progress))
}
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(p - self.translation())
mouse::Cursor::Available(
layout.bounds().center() + (p - layout.bounds().center() - self.translation()) / self.scale,
)
})
}
}
@@ -381,13 +396,16 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
let mut nodes = self.content.as_widget().a11y_nodes(layout, tree, self.cursor(cursor));
let mut nodes = self
.content
.as_widget()
.a11y_nodes(layout, tree, self.cursor(layout, cursor));
let offset = self.translation();
for node in nodes.root_mut() {
translate_accessible_node(node, offset);
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
}
for node in nodes.children_mut() {
translate_accessible_node(node, offset);
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
}
nodes
}
@@ -432,19 +450,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let mut collector = CollectRegions(Vec::new());
self.content
.as_widget_mut()
.operate(tree, layout, renderer, &mut collector);
if collector.0.is_empty() {
// A single-content popover (e.g. battery) has one inner card, not
// a full-surface rectangle including the outer transparent padding.
if let Some(content) = layout.children().next() {
collector.0.push(content.bounds());
if let Some(regions) = &self.regions {
let mut collected = regions.lock().unwrap();
collected.clear();
self.content
.as_widget_mut()
.operate(tree, layout, renderer, &mut ReuseRegions(&mut collected));
if collected.is_empty()
&& let Some(content) = layout.children().next()
{
let bounds = content.bounds();
collected.push([bounds.x, bounds.y, bounds.width, bounds.height, 0.]);
}
transform_regions(
&mut collected,
layout.bounds().center(),
self.scale,
self.translation(),
self.radius,
);
}
*self.regions.lock().unwrap() = collector.into_regions(self.translation(), self.radius);
let cursor = self.cursor(cursor);
let cursor = self.cursor(layout, cursor);
self.content
.as_widget_mut()
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
@@ -461,16 +487,25 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
viewport: &Rectangle,
) {
let v = self.translation();
let transform = Transformation::translate(v.x, v.y);
let center = layout.bounds().center();
let transform = Transformation::translate(center.x + v.x, center.y + v.y)
* Transformation::scale(self.scale)
* Transformation::translate(-center.x, -center.y);
// Layout/region collection still run before the surface starts opening.
// No child may paint an opaque control before the presentation begins.
if self.progress <= 0.0 {
return;
}
renderer.with_transformation(transform, |renderer| {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport)
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(layout, cursor),
viewport,
)
});
}
@@ -484,7 +519,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer)
.mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer)
}
fn operate(
@@ -512,56 +547,55 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
}
}
fn translate_accessible_node(node: &mut iced_accessibility::A11yNode, offset: Vector) {
fn transform_accessible_node(
node: &mut iced_accessibility::A11yNode,
offset: Vector,
center: cosmic::iced::Point,
scale: f32,
) {
if let Some(mut bounds) = node.node().bounds() {
bounds.x0 += f64::from(offset.x);
bounds.x1 += f64::from(offset.x);
bounds.y0 += f64::from(offset.y);
bounds.y1 += f64::from(offset.y);
let x = f64::from(center.x);
let y = f64::from(center.y);
bounds.x0 = x + (bounds.x0 - x) * f64::from(scale) + f64::from(offset.x);
bounds.x1 = x + (bounds.x1 - x) * f64::from(scale) + f64::from(offset.x);
bounds.y0 = y + (bounds.y0 - y) * f64::from(scale) + f64::from(offset.y);
bounds.y1 = y + (bounds.y1 - y) * f64::from(scale) + f64::from(offset.y);
node.node_mut().set_bounds(bounds);
}
}
struct CollectRegions(Vec<Rectangle>);
impl CollectRegions {
fn into_regions(self, translation: Vector, radius: f32) -> Vec<[f32; 5]> {
self.0
.into_iter()
.take(32)
.filter(|r| r.width > 0.0 && r.height > 0.0)
.map(|r| {
[
r.x + translation.x,
r.y + translation.y,
r.width,
r.height,
radius.max(0.0),
]
})
.collect()
fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) {
for region in regions {
region[0] = center.x + (region[0] - center.x) * scale + offset.x;
region[1] = center.y + (region[1] - center.y) * scale + offset.y;
region[2] *= scale;
region[3] *= scale;
region[4] = radius.max(0.) * scale;
}
}
impl widget::Operation for CollectRegions {
struct ReuseRegions<'a>(&'a mut Vec<[f32; 5]>);
impl widget::Operation for ReuseRegions<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("ferese-blur-card")) {
let contains = |outer: &Rectangle, inner: &Rectangle| {
outer.x <= inner.x
&& outer.y <= inner.y
&& outer.x + outer.width >= inner.x + inner.width
&& outer.y + outer.height >= inner.y + inner.height
};
// Nested controls belong to their enclosing card's single blur pass.
if self.0.iter().any(|outer| contains(outer, &bounds)) {
return;
}
self.0.retain(|inner| !contains(&bounds, inner));
self.0.push(bounds);
if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. {
return;
}
let r = [bounds.x, bounds.y, bounds.width, bounds.height, 0.];
let contains = |outer: &[f32; 5], inner: &[f32; 5]| {
outer[0] <= inner[0]
&& outer[1] <= inner[1]
&& outer[0] + outer[2] >= inner[0] + inner[2]
&& outer[1] + outer[3] >= inner[1] + inner[3]
};
if self.0.iter().any(|outer| contains(outer, &r)) {
return;
}
self.0.retain(|inner| !contains(&r, inner));
if self.0.len() < 32 {
self.0.push(r);
}
}
}
@@ -728,22 +762,31 @@ mod tests {
#[test]
fn material_regions_preserve_fractional_layout_and_translation() {
let collector = CollectRegions(vec![Rectangle::new((0.25, 2.5).into(), (100.25, 40.5).into())]);
assert_eq!(
collector.into_regions(Vector::new(0.125, -0.25), 13.5),
vec![[0.375, 2.25, 100.25, 40.5, 13.5]]
let mut regions = vec![[0.25, 2.5, 100.25, 40.5, 0.]];
transform_regions(
&mut regions,
cosmic::iced::Point::ORIGIN,
1.,
Vector::new(0.125, -0.25),
13.5,
);
assert_eq!(regions, vec![[0.375, 2.25, 100.25, 40.5, 13.5]]);
}
#[test]
fn centered_modal_material_tracks_surface_position_and_animation() {
let mut collector = CollectRegions(Vec::new());
let mut regions = Vec::new();
let mut collector = ReuseRegions(&mut regions);
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
assert_eq!(
collector.into_regions(Vector::new(0.0, -2.0), 14.0),
vec![[380., 418., 440., 220., 14.]]
transform_regions(
&mut regions,
cosmic::iced::Point::ORIGIN,
1.,
Vector::new(0.0, -2.0),
14.0,
);
assert_eq!(regions, vec![[380., 418., 440., 220., 14.]]);
}
#[test]
@@ -901,7 +944,8 @@ mod tests {
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new());
let mut regions = Vec::new();
let mut collector = ReuseRegions(&mut regions);
let id = widget::Id::new("ferese-blur-card");
widget::Operation::container(
&mut collector,
@@ -912,12 +956,18 @@ mod tests {
let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into());
widget::Operation::container(&mut collector, Some(&id), first);
widget::Operation::container(&mut collector, Some(&id), second);
assert_eq!(collector.0, vec![first, second]);
assert_eq!(
*collector.0,
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
);
widget::Operation::container(
&mut collector,
Some(&id),
Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()),
);
assert_eq!(collector.0, vec![first, second]);
assert_eq!(
*collector.0,
vec![[12., 12., 140., 80., 0.], [160., 12., 140., 80., 0.]]
);
}
}
+37 -10
View File
@@ -37,7 +37,17 @@ impl FereseShell {
} else {
Task::none()
};
let launcher = if self
.launcher
.as_ref()
.is_some_and(|l| Some(&l.output) == entry.name.as_ref())
{
self.destroy_launcher()
} else {
Task::none()
};
let mut tasks = vec![
launcher,
self.dismiss_workspace_tooltip(),
menu.chain(destroy_layer_surface(entry.bar)),
self.rebuild_system_modal(),
@@ -211,6 +221,7 @@ impl FereseShell {
self.outputs
.iter()
.any(|entry| entry.bar == id || entry.wallpaper == Some(id))
|| self.launcher.as_ref().is_some_and(|launcher| launcher.id == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|| self
@@ -221,6 +232,9 @@ impl FereseShell {
}
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
if let Some(task) = self.launcher_event(&event, id) {
return task;
}
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
return self.hub_hover(id, true);
}
@@ -736,16 +750,29 @@ impl EffectsBinding {
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect();
validate_region_counts(regions.len(), values.len())?;
let next = (regions.to_vec(), opacity, values, role);
if self.presentation.borrow().as_ref() != Some(&next) {
self.surface.set_presentation(
role,
encode_regions(regions),
opacity,
next.2.iter().flat_map(|value| value.to_ne_bytes()).collect(),
);
self.connection.flush()?;
*self.presentation.borrow_mut() = Some(next);
let mut previous = self.presentation.borrow_mut();
if previous
.as_ref()
.is_some_and(|old| old.0 == regions && old.1 == opacity && old.2 == values && old.3 == role)
{
return Ok(());
}
self.surface.set_presentation(
role,
encode_regions(regions),
opacity,
values.iter().flat_map(|value| value.to_ne_bytes()).collect(),
);
self.connection.flush()?;
if let Some(old) = previous.as_mut() {
old.0.clear();
old.0.extend_from_slice(regions);
old.1 = opacity;
old.2.clear();
old.2.extend_from_slice(&values);
old.3 = role;
} else {
*previous = Some((regions.to_vec(), opacity, values, role));
}
Ok(())
}
+1
View File
@@ -181,6 +181,7 @@ impl FereseShell {
})
.unwrap_or(0);
let mut tasks = vec![
self.destroy_launcher(),
self.destroy_system_modal(true),
self.destroy_menu(),
self.close_notification_history(),