Add native shell launcher with themed search and browsing
This commit is contained in:
1 parent
2a400b26d0
commit
d8bdb68b2a
43 files changed
+3351
-115
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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(())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user