Format Rust sources at 100 columns
This commit is contained in:
1 parent
4661f595f8
commit
610478f8c7
336 files changed
+23497
-7892
No files matched your search
@@ -32,7 +32,8 @@ impl Appearance {
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| Document::parse(&source).ok());
|
||||
let mut theme = ferese_theme_client::service::current().presented;
|
||||
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
||||
if let Some(path) =
|
||||
path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
||||
&& let Some(document) = &doc
|
||||
&& let Ok(mut connection) = ferese_ipc::Connection::connect()
|
||||
{
|
||||
@@ -49,7 +50,11 @@ impl Appearance {
|
||||
Self::from_document(user, doc, &theme)
|
||||
}
|
||||
|
||||
fn from_document(user: &str, doc: Option<Document>, theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
fn from_document(
|
||||
user: &str,
|
||||
doc: Option<Document>,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Self {
|
||||
let doc = doc.map(|doc| doc.with_theme(theme));
|
||||
let string = |key: &str, fallback: &str| {
|
||||
doc.as_ref()
|
||||
@@ -80,7 +85,9 @@ impl Appearance {
|
||||
let loaded = load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur });
|
||||
let wallpaper_loaded = loaded.is_ok();
|
||||
let wallpaper = loaded.unwrap_or_else(|_| {
|
||||
image::Handle::from_bytes(include_bytes!("../../../assets/wallpapers/ferese.png").as_slice())
|
||||
image::Handle::from_bytes(
|
||||
include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(),
|
||||
)
|
||||
});
|
||||
let boolean = |key, fallback| {
|
||||
doc.as_ref()
|
||||
@@ -134,7 +141,10 @@ impl Appearance {
|
||||
if self.wallpaper_pending.as_ref() == Some(&desired) {
|
||||
return None;
|
||||
}
|
||||
if self.wallpaper_loaded && path == self.wallpaper_path && reduce == self.reduce_transparency {
|
||||
if self.wallpaper_loaded
|
||||
&& path == self.wallpaper_path
|
||||
&& reduce == self.reduce_transparency
|
||||
{
|
||||
if self.wallpaper_pending.take().is_some() {
|
||||
self.wallpaper_revision = self.wallpaper_revision.wrapping_add(1);
|
||||
}
|
||||
@@ -149,7 +159,11 @@ impl Appearance {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn finish_wallpaper(&mut self, revision: u64, result: Result<image::Handle, String>) -> Result<(), String> {
|
||||
pub fn finish_wallpaper(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
result: Result<image::Handle, String>,
|
||||
) -> Result<(), String> {
|
||||
if revision != self.wallpaper_revision {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -165,7 +179,11 @@ impl Appearance {
|
||||
}
|
||||
|
||||
pub fn clock_format(&self) -> &'static str {
|
||||
if self.twelve_hour { "%I:%M %p" } else { "%H:%M" }
|
||||
if self.twelve_hour {
|
||||
"%I:%M %p"
|
||||
} else {
|
||||
"%H:%M"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
@@ -188,7 +206,8 @@ impl Appearance {
|
||||
}
|
||||
|
||||
fn account_picture(user: &str) -> Option<image::Handle> {
|
||||
let mut candidates = vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
|
||||
let mut candidates =
|
||||
vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
candidates.push(std::path::PathBuf::from(home).join(".face"));
|
||||
}
|
||||
@@ -206,11 +225,13 @@ fn account_picture(user: &str) -> Option<image::Handle> {
|
||||
side,
|
||||
)
|
||||
.to_image();
|
||||
let mut pixels = ::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
|
||||
let mut pixels =
|
||||
::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
|
||||
// Clip the decoded thumbnail, so the avatar stays circular even on
|
||||
// renderers that do not support rounded image clipping.
|
||||
for (x, y, pixel) in pixels.enumerate_pixels_mut() {
|
||||
let distance = ((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
|
||||
let distance =
|
||||
((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
|
||||
let coverage = (128. - distance).clamp(0., 1.);
|
||||
pixel[3] = (pixel[3] as f32 * coverage).round() as u8;
|
||||
}
|
||||
@@ -235,7 +256,11 @@ fn decode_avatar(path: &std::path::Path) -> Result<::image::RgbaImage, String> {
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !file.metadata().map_err(|error| error.to_string())?.is_file() {
|
||||
if !file
|
||||
.metadata()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("Avatar is not a regular file".into());
|
||||
}
|
||||
|
||||
@@ -323,7 +348,10 @@ mod tests {
|
||||
}
|
||||
match load_wallpaper(file.path(), 2.).unwrap() {
|
||||
image::Handle::Rgba {
|
||||
width, height, pixels, ..
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
..
|
||||
} => {
|
||||
assert_eq!((width, height), (640, 360));
|
||||
assert_eq!(pixels.len(), 640 * 360 * 4);
|
||||
@@ -378,7 +406,10 @@ mod tests {
|
||||
assert!(appearance.request_wallpaper(&theme).is_none());
|
||||
theme.tokens.background.lock_path = Some(initial.clone());
|
||||
let (_, path, _) = appearance.request_wallpaper(&theme).unwrap();
|
||||
assert_eq!(path, initial, "an explicit lock image overrides the desktop wallpaper");
|
||||
assert_eq!(
|
||||
path, initial,
|
||||
"an explicit lock image overrides the desktop wallpaper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,7 +426,11 @@ mod tests {
|
||||
assert!(decode_avatar(file.path()).is_err());
|
||||
|
||||
file.as_file().set_len(8 * 1024 * 1024 + 1).unwrap();
|
||||
assert!(decode_avatar(file.path()).unwrap_err().contains("file limit"));
|
||||
assert!(
|
||||
decode_avatar(file.path())
|
||||
.unwrap_err()
|
||||
.contains("file limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -429,9 +464,12 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let document = Document::parse(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||
let theme = ferese_config::theme::resolve(&document, directory.path(), jiff::Timestamp::now(), |_| {
|
||||
Err("unexpected file".into())
|
||||
})
|
||||
let theme = ferese_config::theme::resolve(
|
||||
&document,
|
||||
directory.path(),
|
||||
jiff::Timestamp::now(),
|
||||
|_| Err("unexpected file".into()),
|
||||
)
|
||||
.unwrap()
|
||||
.theme;
|
||||
let appearance = Appearance::from_document("no-such-test-user", Some(document), &theme);
|
||||
|
||||
@@ -14,7 +14,8 @@ struct Response {
|
||||
}
|
||||
#[repr(C)]
|
||||
struct Conversation {
|
||||
callback: unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int,
|
||||
callback:
|
||||
unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int,
|
||||
data: *mut c_void,
|
||||
}
|
||||
struct Credentials {
|
||||
@@ -38,7 +39,8 @@ unsafe extern "C" fn converse(
|
||||
unsafe {
|
||||
*out = std::ptr::null_mut();
|
||||
let credentials = &mut *(data as *mut Credentials);
|
||||
let responses = libc::calloc(count as usize, std::mem::size_of::<Response>()) as *mut Response;
|
||||
let responses =
|
||||
libc::calloc(count as usize, std::mem::size_of::<Response>()) as *mut Response;
|
||||
if responses.is_null() {
|
||||
return CONV_ERR;
|
||||
}
|
||||
@@ -49,11 +51,13 @@ unsafe extern "C" fn converse(
|
||||
} else {
|
||||
match (*msg).style {
|
||||
1 => {
|
||||
credentials.password_prompts = credentials.password_prompts.saturating_add(1);
|
||||
(credentials.password_prompts == 1).then_some(credentials.password.as_slice())
|
||||
credentials.password_prompts =
|
||||
credentials.password_prompts.saturating_add(1);
|
||||
(credentials.password_prompts == 1)
|
||||
.then_some(credentials.password.as_slice())
|
||||
} // Never send the password as a second-factor response.
|
||||
2 => Some(credentials.user.as_bytes_with_nul()), // PAM_PROMPT_ECHO_ON
|
||||
3 | 4 => Some(&[][..]), // PAM_ERROR_MSG / PAM_TEXT_INFO
|
||||
3 | 4 => Some(&[][..]), // PAM_ERROR_MSG / PAM_TEXT_INFO
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
@@ -61,7 +65,11 @@ unsafe extern "C" fn converse(
|
||||
if !bytes.is_empty() {
|
||||
let allocation = libc::malloc(bytes.len()) as *mut c_char;
|
||||
if !allocation.is_null() {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), allocation.cast(), bytes.len());
|
||||
std::ptr::copy_nonoverlapping(
|
||||
bytes.as_ptr(),
|
||||
allocation.cast(),
|
||||
bytes.len(),
|
||||
);
|
||||
(*responses.add(i)).text = allocation;
|
||||
continue;
|
||||
}
|
||||
@@ -131,7 +139,12 @@ fn authenticate_with(user: &str, password: &str, config: Option<&CStr>) -> bool
|
||||
let Ok(library) = libloading::Library::new("libpam.so.0") else {
|
||||
return false;
|
||||
};
|
||||
type Start = unsafe extern "C" fn(*const c_char, *const c_char, *const Conversation, *mut *mut c_void) -> c_int;
|
||||
type Start = unsafe extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*const Conversation,
|
||||
*mut *mut c_void,
|
||||
) -> c_int;
|
||||
type StartConf = unsafe extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
|
||||
@@ -22,7 +22,8 @@ fn main() -> iced::Result {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let preview = args == ["--preview"] || (args.len() == 3 && args[0] == "--preview" && args[1] == "--config");
|
||||
let preview = args == ["--preview"]
|
||||
|| (args.len() == 3 && args[0] == "--preview" && args[1] == "--config");
|
||||
let config = if preview && args.len() == 3 {
|
||||
Some(std::path::PathBuf::from(&args[2]))
|
||||
} else {
|
||||
@@ -113,7 +114,8 @@ impl AuthState {
|
||||
}
|
||||
|
||||
fn retry_ready(&mut self, now: Instant) -> bool {
|
||||
if matches!(self, Self::Rejected(at) if now.saturating_duration_since(*at) >= Duration::from_secs(2)) {
|
||||
if matches!(self, Self::Rejected(at) if now.saturating_duration_since(*at) >= Duration::from_secs(2))
|
||||
{
|
||||
*self = Self::Ready;
|
||||
true
|
||||
} else {
|
||||
@@ -202,14 +204,17 @@ impl cosmic::Application for Locker {
|
||||
if self.preview {
|
||||
Subscription::none()
|
||||
} else {
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot)))
|
||||
ferese_theme_client::service::subscription()
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot)))
|
||||
},
|
||||
event::listen_with(|event, _, _| match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||
wayland::Event::Output(..) | wayland::Event::SessionLock(..),
|
||||
))
|
||||
| Event::Window(window::Event::Opened { .. })
|
||||
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => Some(Message::Event(Box::new(event))),
|
||||
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => {
|
||||
Some(Message::Event(Box::new(event)))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
if self.appearance.show_clock || self.appearance.show_date {
|
||||
@@ -236,13 +241,18 @@ impl cosmic::Application for Locker {
|
||||
self.appearance.accent = palette.accent;
|
||||
self.appearance.on_accent = palette.on_accent;
|
||||
self.appearance.radius = palette.radius;
|
||||
self.appearance.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
self.appearance.font =
|
||||
ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
let theme = cosmic::command::set_theme(self.appearance.theme());
|
||||
if let Some((revision, path, blur)) = self.appearance.request_wallpaper(&snapshot.theme) {
|
||||
if let Some((revision, path, blur)) =
|
||||
self.appearance.request_wallpaper(&snapshot.theme)
|
||||
{
|
||||
let wallpaper = cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || appearance::load_wallpaper(&path, blur))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
appearance::load_wallpaper(&path, blur)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperLoaded(revision, result)
|
||||
});
|
||||
return Task::batch([theme, wallpaper]);
|
||||
@@ -284,7 +294,10 @@ impl cosmic::Application for Locker {
|
||||
return focus();
|
||||
}
|
||||
|
||||
if !self.confirmation.confirmed() || !self.state.may_submit() || self.password.is_empty() {
|
||||
if !self.confirmation.confirmed()
|
||||
|| !self.state.may_submit()
|
||||
|| self.password.is_empty()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
@@ -337,9 +350,10 @@ impl Locker {
|
||||
Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => {
|
||||
self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK);
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output)))
|
||||
if !self.preview =>
|
||||
{
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
|
||||
event,
|
||||
output,
|
||||
))) if !self.preview => {
|
||||
if matches!(event, wayland::OutputEvent::Removed) {
|
||||
if let Some(id) = self.outputs.remove(&output) {
|
||||
return session_lock::destroy_lock_surface(id);
|
||||
@@ -352,33 +366,33 @@ impl Locker {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::SessionLock(event))) if !self.preview => {
|
||||
match event {
|
||||
wayland::SessionLockEvent::Locked if self.confirmation.observe(&event) => {
|
||||
use std::io::Write;
|
||||
if std::env::var_os("FERESE_LOCK_READY").is_some() {
|
||||
let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED");
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::SessionLock(
|
||||
event,
|
||||
))) if !self.preview => match event {
|
||||
wayland::SessionLockEvent::Locked if self.confirmation.observe(&event) => {
|
||||
use std::io::Write;
|
||||
if std::env::var_os("FERESE_LOCK_READY").is_some() {
|
||||
let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED");
|
||||
}
|
||||
wayland::SessionLockEvent::Requested if !self.started => {
|
||||
self.started = true;
|
||||
return Task::batch(
|
||||
self.outputs
|
||||
.iter()
|
||||
.map(|(output, id)| session_lock::get_lock_surface(*id, output.clone())),
|
||||
);
|
||||
}
|
||||
wayland::SessionLockEvent::Focused(..) => return focus(),
|
||||
wayland::SessionLockEvent::Unlocked if self.state == AuthState::Unlocking => {
|
||||
return iced::exit();
|
||||
}
|
||||
wayland::SessionLockEvent::Finished | wayland::SessionLockEvent::NotSupported => {
|
||||
eprintln!("ferese-lock: compositor refused the lock");
|
||||
return iced::exit();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
wayland::SessionLockEvent::Requested if !self.started => {
|
||||
self.started = true;
|
||||
return Task::batch(
|
||||
self.outputs.iter().map(|(output, id)| {
|
||||
session_lock::get_lock_surface(*id, output.clone())
|
||||
}),
|
||||
);
|
||||
}
|
||||
wayland::SessionLockEvent::Focused(..) => return focus(),
|
||||
wayland::SessionLockEvent::Unlocked if self.state == AuthState::Unlocking => {
|
||||
return iced::exit();
|
||||
}
|
||||
wayland::SessionLockEvent::Finished | wayland::SessionLockEvent::NotSupported => {
|
||||
eprintln!("ferese-lock: compositor refused the lock");
|
||||
return iced::exit();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Window(window::Event::Opened { .. }) => {
|
||||
return focus();
|
||||
}
|
||||
@@ -403,7 +417,14 @@ impl Locker {
|
||||
fn screen_content(&self, size: iced::Size) -> Element<'_, Message> {
|
||||
let avatar_size = (size.height * 0.08).clamp(56., 88.);
|
||||
let clock_size = (size.height * 0.06)
|
||||
.min(size.width * if self.appearance.twelve_hour { 0.055 } else { 0.085 })
|
||||
.min(
|
||||
size.width
|
||||
* if self.appearance.twelve_hour {
|
||||
0.055
|
||||
} else {
|
||||
0.085
|
||||
},
|
||||
)
|
||||
.clamp(32., 72.) as u16;
|
||||
let margin = if size.width < 640. { 24. } else { 36. };
|
||||
let top = (size.height * 0.09).max(24.);
|
||||
@@ -412,7 +433,9 @@ impl Locker {
|
||||
.password()
|
||||
.padding([4, 10])
|
||||
.size(14)
|
||||
.style(ferese_theme::controls::lock_input(a.radius, a.accent, a.panel))
|
||||
.style(ferese_theme::controls::lock_input(
|
||||
a.radius, a.accent, a.panel,
|
||||
))
|
||||
.font(a.font)
|
||||
.id(widget::Id::new("password"));
|
||||
|
||||
@@ -468,8 +491,15 @@ impl Locker {
|
||||
self.label(&self.user, 16).class(iced::Color::WHITE),
|
||||
widget::Space::new().height(6),
|
||||
input,
|
||||
self.label(if self.caps_lock { "Caps Lock is on" } else { self.status }, 12)
|
||||
.class(iced::Color::WHITE.scale_alpha(0.68)),
|
||||
self.label(
|
||||
if self.caps_lock {
|
||||
"Caps Lock is on"
|
||||
} else {
|
||||
self.status
|
||||
},
|
||||
12
|
||||
)
|
||||
.class(iced::Color::WHITE.scale_alpha(0.68)),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_x(iced::Alignment::Center)
|
||||
@@ -492,10 +522,17 @@ impl Locker {
|
||||
}
|
||||
|
||||
if a.show_date {
|
||||
clock = clock.push(self.label(&self.date, 18).class(iced::Color::WHITE.scale_alpha(0.72)));
|
||||
clock = clock.push(
|
||||
self.label(&self.date, 18)
|
||||
.class(iced::Color::WHITE.scale_alpha(0.72)),
|
||||
);
|
||||
}
|
||||
|
||||
let clock_height = if a.show_clock { clock_size as f32 * 1.3 } else { 0. } + if a.show_date { 24. } else { 0. };
|
||||
let clock_height = if a.show_clock {
|
||||
clock_size as f32 * 1.3
|
||||
} else {
|
||||
0.
|
||||
} + if a.show_date { 24. } else { 0. };
|
||||
let gap = (size.height * 0.44 - top - clock_height).max(24.);
|
||||
let foreground = container(
|
||||
column![
|
||||
@@ -529,12 +566,14 @@ impl Locker {
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| widget::container::Style {
|
||||
text_color: Some(iced::Color::WHITE.scale_alpha(0.65)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.025, 0.035, 0.06, a.dim,
|
||||
))),
|
||||
..Default::default()
|
||||
.class(theme::Container::custom(move |_| {
|
||||
widget::container::Style {
|
||||
text_color: Some(iced::Color::WHITE.scale_alpha(0.65)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.025, 0.035, 0.06, a.dim,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}));
|
||||
let background: Element<'_, Message> = image(a.wallpaper.clone())
|
||||
.width(Length::Fill)
|
||||
@@ -588,6 +627,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn password_messages_are_redacted() {
|
||||
assert!(!format!("{:?}", Message::Input(Zeroizing::new("secret".into()))).contains("secret"));
|
||||
assert!(
|
||||
!format!("{:?}", Message::Input(Zeroizing::new("secret".into()))).contains("secret")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,10 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||
}
|
||||
}
|
||||
pub fn check_protocol() -> Result<(), String> {
|
||||
let connection = Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
|
||||
let (globals, _) = registry_queue_init::<Registry>(&connection).map_err(|e| format!("Wayland registry: {e}"))?;
|
||||
let connection =
|
||||
Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
|
||||
let (globals, _) = registry_queue_init::<Registry>(&connection)
|
||||
.map_err(|e| format!("Wayland registry: {e}"))?;
|
||||
if globals
|
||||
.contents()
|
||||
.clone_list()
|
||||
@@ -70,7 +72,11 @@ pub struct Confirmation {
|
||||
}
|
||||
impl Confirmation {
|
||||
pub fn observe(&mut self, event: &cosmic::iced::event::wayland::SessionLockEvent) -> bool {
|
||||
if !matches!(event, cosmic::iced::event::wayland::SessionLockEvent::Locked) || self.confirmed {
|
||||
if !matches!(
|
||||
event,
|
||||
cosmic::iced::event::wayland::SessionLockEvent::Locked
|
||||
) || self.confirmed
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.confirmed = true;
|
||||
@@ -88,7 +94,12 @@ mod tests {
|
||||
fn invalid_confirmation_returns_without_waiting_for_or_killing_the_locker() {
|
||||
for message in ["unexpected", "FERESE_LOCKED"] {
|
||||
let mut child = Command::new("sh")
|
||||
.args(["-c", "printf '%s\\n' \"$1\"; exec sleep 3", "lock-fixture", message])
|
||||
.args([
|
||||
"-c",
|
||||
"printf '%s\\n' \"$1\"; exec sleep 3",
|
||||
"lock-fixture",
|
||||
message,
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
@@ -105,7 +116,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_compositor_confirmation_marks_the_lock_ready_once() {
|
||||
use cosmic::iced::event::wayland::SessionLockEvent::{Finished, Locked, NotSupported, Requested};
|
||||
use cosmic::iced::event::wayland::SessionLockEvent::{
|
||||
Finished, Locked, NotSupported, Requested,
|
||||
};
|
||||
let mut c = Confirmation::default();
|
||||
for event in [Requested, Requested, Finished, NotSupported] {
|
||||
assert!(!c.observe(&event));
|
||||
@@ -131,7 +144,9 @@ pub fn minute_ticks() -> impl cosmic::iced::futures::Stream<Item = ()> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn retry_deadline(at: &std::time::Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||
pub fn retry_deadline(
|
||||
at: &std::time::Instant,
|
||||
) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||
let at = *at;
|
||||
cosmic::iced::futures::stream::once(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2).saturating_sub(at.elapsed())).await;
|
||||
@@ -149,6 +164,9 @@ mod clock_tests {
|
||||
until_next_minute(Duration::from_millis(119_999)),
|
||||
Duration::from_millis(1)
|
||||
);
|
||||
assert_eq!(until_next_minute(Duration::from_secs(125)), Duration::from_secs(55));
|
||||
assert_eq!(
|
||||
until_next_minute(Duration::from_secs(125)),
|
||||
Duration::from_secs(55)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,21 @@ struct Agent {
|
||||
impl Agent {
|
||||
async fn vendor(&self, action_id: &str) -> (String, String) {
|
||||
let lookup = async {
|
||||
let authority = zbus::Proxy::new(&self.connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
|
||||
let authority = zbus::Proxy::new(
|
||||
&self.connection,
|
||||
AUTHORITY,
|
||||
AUTHORITY_PATH,
|
||||
AUTHORITY_INTERFACE,
|
||||
)
|
||||
.await?;
|
||||
let locale = std::env::var("LANG").unwrap_or_default();
|
||||
let actions: Vec<ActionDescription> = authority.call("EnumerateActions", &(locale,)).await?;
|
||||
Ok::<_, zbus::Error>(actions.into_iter().find(|action| action.action_id == action_id))
|
||||
let actions: Vec<ActionDescription> =
|
||||
authority.call("EnumerateActions", &(locale,)).await?;
|
||||
Ok::<_, zbus::Error>(
|
||||
actions
|
||||
.into_iter()
|
||||
.find(|action| action.action_id == action_id),
|
||||
)
|
||||
};
|
||||
// Missing metadata must never prevent authentication or cancellation.
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), lookup).await {
|
||||
@@ -119,8 +130,9 @@ impl Agent {
|
||||
identities: Vec<Identity>,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
let allowed = available_identities(&identities);
|
||||
let uid = choose_identity(&allowed)
|
||||
.ok_or_else(|| zbus::fdo::Error::Failed("No supported authentication identity".into()))?;
|
||||
let uid = choose_identity(&allowed).ok_or_else(|| {
|
||||
zbus::fdo::Error::Failed("No supported authentication identity".into())
|
||||
})?;
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
{
|
||||
@@ -176,7 +188,11 @@ fn available_identities(identities: &[Identity]) -> Vec<u32> {
|
||||
for uid in identities
|
||||
.iter()
|
||||
.filter(|(kind, _)| kind == "unix-user")
|
||||
.filter_map(|(_, details)| details.get("uid").and_then(|uid| u32::try_from(uid.clone()).ok()))
|
||||
.filter_map(|(_, details)| {
|
||||
details
|
||||
.get("uid")
|
||||
.and_then(|uid| u32::try_from(uid.clone()).ok())
|
||||
})
|
||||
.filter(|uid| *uid <= i32::MAX as u32)
|
||||
{
|
||||
if !available.contains(&uid) {
|
||||
@@ -212,7 +228,10 @@ fn process_subject() -> Result<(String, HashMap<String, Value<'static>>), String
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut details = HashMap::new();
|
||||
details.insert("pid".to_owned(), Value::from(std::process::id()));
|
||||
details.insert("uid".to_owned(), Value::from(unsafe { libc::geteuid() } as i32));
|
||||
details.insert(
|
||||
"uid".to_owned(),
|
||||
Value::from(unsafe { libc::geteuid() } as i32),
|
||||
);
|
||||
details.insert("start-time".to_owned(), Value::from(started));
|
||||
Ok(("unix-process".into(), details))
|
||||
}
|
||||
@@ -233,7 +252,10 @@ async fn session_subject(
|
||||
"org.freedesktop.login1.Manager",
|
||||
)
|
||||
.await?;
|
||||
let path: zbus::zvariant::OwnedObjectPath = match manager.call("GetSessionByPID", &(std::process::id(),)).await {
|
||||
let path: zbus::zvariant::OwnedObjectPath = match manager
|
||||
.call("GetSessionByPID", &(std::process::id(),))
|
||||
.await
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
// A launcher in the user manager can be outside the session cgroup
|
||||
@@ -273,11 +295,15 @@ async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.await?;
|
||||
let subject = session_subject(&connection).await?;
|
||||
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
|
||||
let authority =
|
||||
zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
|
||||
let locale = std::env::var("LANG").unwrap_or_default();
|
||||
|
||||
authority
|
||||
.call::<_, _, ()>("RegisterAuthenticationAgent", &(subject, locale, AGENT_PATH))
|
||||
.call::<_, _, ()>(
|
||||
"RegisterAuthenticationAgent",
|
||||
&(subject, locale, AGENT_PATH),
|
||||
)
|
||||
.await?;
|
||||
std::future::pending::<()>().await;
|
||||
|
||||
@@ -343,9 +369,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prompt_protocol_keeps_selection_response_and_echo_fields() {
|
||||
let selection: PromptEvent =
|
||||
serde_json::from_str(r#"{"type":"select_identity","generation":{"selection":2,"attempt":0},"uid":1001}"#)
|
||||
.unwrap();
|
||||
let selection: PromptEvent = serde_json::from_str(
|
||||
r#"{"type":"select_identity","generation":{"selection":2,"attempt":0},"uid":1001}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
selection,
|
||||
PromptEvent::SelectIdentity {
|
||||
@@ -498,9 +525,15 @@ mod tests {
|
||||
agent.vendor("test.action").await,
|
||||
("Test vendor".into(), "https://example.test/vendor".into())
|
||||
);
|
||||
assert_eq!(agent.vendor("missing.action").await, (String::new(), String::new()));
|
||||
assert_eq!(
|
||||
agent.vendor("missing.action").await,
|
||||
(String::new(), String::new())
|
||||
);
|
||||
server.close().await.unwrap();
|
||||
assert_eq!(agent.vendor("test.action").await, (String::new(), String::new()));
|
||||
assert_eq!(
|
||||
agent.vendor("test.action").await,
|
||||
(String::new(), String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -516,7 +549,10 @@ mod tests {
|
||||
let (kind, details) = process_subject().unwrap();
|
||||
assert_eq!(kind, "unix-process");
|
||||
assert_eq!(details["pid"], Value::from(std::process::id()));
|
||||
assert_eq!(details["uid"], Value::from(unsafe { libc::geteuid() } as i32));
|
||||
assert_eq!(
|
||||
details["uid"],
|
||||
Value::from(unsafe { libc::geteuid() } as i32)
|
||||
);
|
||||
assert!(u64::try_from(details["start-time"].clone()).unwrap() > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,13 +114,18 @@ fn content_height(description: &str, status: Option<&str>) -> f32 {
|
||||
.lines()
|
||||
.map(|line| line.chars().count().div_ceil(48).max(1))
|
||||
.sum();
|
||||
let status_height = status.map_or(0, |message| 14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3));
|
||||
let status_height = status.map_or(0, |message| {
|
||||
14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3)
|
||||
});
|
||||
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
|
||||
}
|
||||
|
||||
fn dialog_height(request: &AuthenticationRequest, status: Option<&str>, expanded: bool) -> f32 {
|
||||
let extra = if request.identities.len() > 1 { 54. } else { 0. }
|
||||
+ if has_details(request) { 40. } else { 0. }
|
||||
let extra = if request.identities.len() > 1 {
|
||||
54.
|
||||
} else {
|
||||
0.
|
||||
} + if has_details(request) { 40. } else { 0. }
|
||||
+ if expanded { 96. } else { 0. };
|
||||
(content_height(&request.message, status) + extra).min(360.)
|
||||
}
|
||||
@@ -141,7 +146,10 @@ fn action_icon(name: &str) -> Option<widget::icon::Handle> {
|
||||
.map(widget::icon::from_path)
|
||||
}
|
||||
|
||||
fn read_events(lines: impl Iterator<Item = std::io::Result<String>>, queue: &Mutex<VecDeque<PromptEvent>>) {
|
||||
fn read_events(
|
||||
lines: impl Iterator<Item = std::io::Result<String>>,
|
||||
queue: &Mutex<VecDeque<PromptEvent>>,
|
||||
) {
|
||||
for line in lines {
|
||||
let Ok(line) = line else { break };
|
||||
if let Ok(event) = serde_json::from_str(&line) {
|
||||
@@ -242,7 +250,11 @@ impl Prompt {
|
||||
|
||||
impl cosmic::Application for Prompt {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, AuthenticationRequest, Appearance);
|
||||
type Flags = (
|
||||
Arc<Mutex<VecDeque<PromptEvent>>>,
|
||||
AuthenticationRequest,
|
||||
Appearance,
|
||||
);
|
||||
type Message = Message;
|
||||
const APP_ID: &'static str = "dev.ferese.Authentication";
|
||||
|
||||
@@ -255,11 +267,15 @@ impl cosmic::Application for Prompt {
|
||||
}
|
||||
|
||||
fn style(&self) -> Option<cosmic::iced::theme::Style> {
|
||||
Some(self.appearance.palette().application_style(if self.material.is_some() {
|
||||
cosmic::iced::Color::TRANSPARENT
|
||||
} else {
|
||||
self.appearance.surface
|
||||
}))
|
||||
Some(
|
||||
self.appearance
|
||||
.palette()
|
||||
.application_style(if self.material.is_some() {
|
||||
cosmic::iced::Color::TRANSPARENT
|
||||
} else {
|
||||
self.appearance.surface
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn init(mut core: Core, (incoming, request, appearance): Self::Flags) -> (Self, Task<Message>) {
|
||||
@@ -303,7 +319,8 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
ferese_theme_client::service::subscription()
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||
@@ -321,8 +338,11 @@ impl cosmic::Application for Prompt {
|
||||
return cosmic::command::set_theme(self.appearance.theme());
|
||||
}
|
||||
Message::WindowOpened(id) => {
|
||||
return cosmic::iced::window::run(id, ferese_theme_client::material::ModalMaterial::attach)
|
||||
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
|
||||
return cosmic::iced::window::run(
|
||||
id,
|
||||
ferese_theme_client::material::ModalMaterial::attach,
|
||||
)
|
||||
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
|
||||
}
|
||||
Message::MaterialAttached(result) => {
|
||||
self.material = result.ok();
|
||||
@@ -336,7 +356,8 @@ impl cosmic::Application for Prompt {
|
||||
if uid == self.request.selected_uid
|
||||
&& generation.selection == self.request.generation.selection
|
||||
&& (generation.attempt > self.request.generation.attempt
|
||||
|| (generation == self.request.generation && self.awaiting_ack)) =>
|
||||
|| (generation == self.request.generation
|
||||
&& self.awaiting_ack)) =>
|
||||
{
|
||||
self.request.generation = generation;
|
||||
self.reset_authentication(uid);
|
||||
@@ -422,12 +443,18 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = &self.appearance;
|
||||
let material_opacity = if palette.high_contrast { 1.0 } else { palette.surface.a };
|
||||
let material_opacity = if palette.high_contrast {
|
||||
1.0
|
||||
} else {
|
||||
palette.surface.a
|
||||
};
|
||||
let mut input = widget::text_input(&self.question, self.answer.as_str())
|
||||
.id(widget::Id::new("auth-response"))
|
||||
.font(palette.font)
|
||||
.padding([7, 12])
|
||||
.style(ferese_theme::controls::authentication_input(palette.palette()));
|
||||
.style(ferese_theme::controls::authentication_input(
|
||||
palette.palette(),
|
||||
));
|
||||
if !self.echo {
|
||||
input = input.password();
|
||||
}
|
||||
@@ -438,7 +465,9 @@ impl cosmic::Application for Prompt {
|
||||
});
|
||||
}
|
||||
let action = button::custom(ferese_theme::text("Authenticate", palette.font).size(13))
|
||||
.class(ferese_theme::controls::authentication_button(palette.palette()))
|
||||
.class(ferese_theme::controls::authentication_button(
|
||||
palette.palette(),
|
||||
))
|
||||
.height(Length::Fixed(36.))
|
||||
.padding([8, 16])
|
||||
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
|
||||
@@ -461,7 +490,10 @@ impl cosmic::Application for Prompt {
|
||||
.height(40)
|
||||
.center_x(40)
|
||||
.center_y(40)
|
||||
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)),
|
||||
.class(ferese_theme::controls::surface(
|
||||
palette.accent.scale_alpha(0.13),
|
||||
12.,
|
||||
)),
|
||||
column![
|
||||
ferese_theme::text("Authentication required", palette.font).size(18),
|
||||
ferese_theme::text(format!("Confirm as {user}"), palette.font)
|
||||
@@ -472,9 +504,10 @@ impl cosmic::Application for Prompt {
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center);
|
||||
let mut information = column![ferese_theme::text(&self.request.message, palette.font).size(13)]
|
||||
.spacing(10)
|
||||
.width(Length::Fill);
|
||||
let mut information =
|
||||
column![ferese_theme::text(&self.request.message, palette.font).size(13)]
|
||||
.spacing(10)
|
||||
.width(Length::Fill);
|
||||
if has_details(&self.request) {
|
||||
information = information.push(
|
||||
button::custom(
|
||||
@@ -522,15 +555,26 @@ impl cosmic::Application for Prompt {
|
||||
ferese_theme::text("Account", palette.font)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
widget::dropdown(&self.identity_names[..], selected, Message::SelectIdentity)
|
||||
.width(Length::Fill),
|
||||
ferese_theme::icons::tinted(ferese_theme::icons::CHEVRON_DOWN, 16, palette.muted),
|
||||
widget::dropdown(
|
||||
&self.identity_names[..],
|
||||
selected,
|
||||
Message::SelectIdentity
|
||||
)
|
||||
.width(Length::Fill),
|
||||
ferese_theme::icons::tinted(
|
||||
ferese_theme::icons::CHEVRON_DOWN,
|
||||
16,
|
||||
palette.muted
|
||||
),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([4, 10])
|
||||
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.08), 8.)),
|
||||
.class(ferese_theme::controls::surface(
|
||||
palette.accent.scale_alpha(0.08),
|
||||
8.,
|
||||
)),
|
||||
);
|
||||
}
|
||||
body = body.push(input);
|
||||
@@ -582,7 +626,9 @@ fn detail_row<'a>(key: &'a str, value: &'a str, palette: &Appearance) -> Element
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted))
|
||||
.width(Length::Fixed(106.)),
|
||||
ferese_theme::text(value, palette.font).size(12).width(Length::Fill),
|
||||
ferese_theme::text(value, palette.font)
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
@@ -595,7 +641,9 @@ mod tests {
|
||||
|
||||
use cosmic::app::Core;
|
||||
|
||||
use super::{Appearance, Message, Prompt, action_icon, content_height, dialog_height, has_details};
|
||||
use super::{
|
||||
Appearance, Message, Prompt, action_icon, content_height, dialog_height, has_details,
|
||||
};
|
||||
use crate::tests::request_fixture;
|
||||
|
||||
#[test]
|
||||
@@ -610,7 +658,8 @@ mod tests {
|
||||
let (reader, mut writer) = UnixStream::pair().unwrap();
|
||||
let incoming = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let queue = incoming.clone();
|
||||
let reader_thread = std::thread::spawn(move || super::read_events(BufReader::new(reader).lines(), &queue));
|
||||
let reader_thread =
|
||||
std::thread::spawn(move || super::read_events(BufReader::new(reader).lines(), &queue));
|
||||
writeln!(
|
||||
writer,
|
||||
"{{\"type\":\"request\",\"generation\":{{\"selection\":0,\"attempt\":0}},\"prompt\":\"Password:\",\"echo\":false}}"
|
||||
@@ -618,9 +667,13 @@ mod tests {
|
||||
.unwrap();
|
||||
drop(writer);
|
||||
reader_thread.join().unwrap();
|
||||
assert!(matches!(incoming.lock().unwrap().back(), Some(PromptEvent::Cancel)));
|
||||
assert!(matches!(
|
||||
incoming.lock().unwrap().back(),
|
||||
Some(PromptEvent::Cancel)
|
||||
));
|
||||
let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
|
||||
let (mut prompt, _) = Prompt::init(Core::default(), (incoming, request_fixture(), appearance));
|
||||
let (mut prompt, _) =
|
||||
Prompt::init(Core::default(), (incoming, request_fixture(), appearance));
|
||||
*prompt.answer = "test-password".into();
|
||||
prompt.waiting = false;
|
||||
let _ = prompt.update(Message::Tick);
|
||||
@@ -636,7 +689,10 @@ mod tests {
|
||||
|
||||
let incoming = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
|
||||
let (mut prompt, _) = Prompt::init(Core::default(), (incoming.clone(), request_fixture(), appearance));
|
||||
let (mut prompt, _) = Prompt::init(
|
||||
Core::default(),
|
||||
(incoming.clone(), request_fixture(), appearance),
|
||||
);
|
||||
assert_eq!(prompt.select_identity(1), Some(1001));
|
||||
let older = prompt.request.generation;
|
||||
assert_eq!(prompt.select_identity(0), Some(1000));
|
||||
@@ -700,7 +756,10 @@ mod tests {
|
||||
assert_eq!(prompt.question, "Current password");
|
||||
|
||||
// A fresh retry gets its own generation, even for the same account.
|
||||
let retry = Generation { attempt: 1, ..current };
|
||||
let retry = Generation {
|
||||
attempt: 1,
|
||||
..current
|
||||
};
|
||||
incoming.lock().unwrap().extend([
|
||||
PromptEvent::IdentityChanged {
|
||||
generation: retry,
|
||||
@@ -732,7 +791,11 @@ mod tests {
|
||||
let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
|
||||
let (mut prompt, _) = <Prompt as cosmic::Application>::init(
|
||||
Core::default(),
|
||||
(Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance),
|
||||
(
|
||||
Arc::new(Mutex::new(VecDeque::new())),
|
||||
request_fixture(),
|
||||
appearance,
|
||||
),
|
||||
);
|
||||
*prompt.answer = "test-response".into();
|
||||
prompt.waiting = false;
|
||||
@@ -755,7 +818,10 @@ mod tests {
|
||||
fn full_requests_and_expanded_details_keep_the_height_cap() {
|
||||
let request = request_fixture();
|
||||
assert_eq!(dialog_height(&request, None, false), 360.);
|
||||
assert_eq!(dialog_height(&request, Some("Password not accepted"), true), 360.);
|
||||
assert_eq!(
|
||||
dialog_height(&request, Some("Password not accepted"), true),
|
||||
360.
|
||||
);
|
||||
assert!(has_details(&request));
|
||||
let mut short = request;
|
||||
short.message = "Short request".into();
|
||||
|
||||
@@ -37,21 +37,29 @@ impl Api {
|
||||
static LIBRARIES: OnceLock<Result<(Library, Library), String>> = OnceLock::new();
|
||||
let (agent, polkit) = LIBRARIES
|
||||
.get_or_init(|| {
|
||||
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
|
||||
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
|
||||
let agent =
|
||||
Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
|
||||
let polkit =
|
||||
Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
|
||||
Ok((agent, polkit))
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)?;
|
||||
let user_new = *polkit.get(b"polkit_unix_user_new\0").map_err(|e| e.to_string())?;
|
||||
let session_new = *agent.get(b"polkit_agent_session_new\0").map_err(|e| e.to_string())?;
|
||||
let user_new = *polkit
|
||||
.get(b"polkit_unix_user_new\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let session_new = *agent
|
||||
.get(b"polkit_agent_session_new\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let initiate = *agent
|
||||
.get(b"polkit_agent_session_initiate\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = *agent
|
||||
.get(b"polkit_agent_session_response\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cancel = *agent.get(b"polkit_agent_session_cancel\0").map_err(|e| e.to_string())?;
|
||||
let cancel = *agent
|
||||
.get(b"polkit_agent_session_cancel\0")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
user_new,
|
||||
@@ -69,7 +77,9 @@ pub fn check() -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub(crate) fn username(uid: u32) -> String {
|
||||
let output = Command::new("getent").args(["passwd", &uid.to_string()]).output();
|
||||
let output = Command::new("getent")
|
||||
.args(["passwd", &uid.to_string()])
|
||||
.output();
|
||||
output
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
@@ -132,21 +142,32 @@ fn prompt_process(request: AuthenticationRequest) -> Result<PromptProcess, Strin
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
unsafe extern "C" fn request_signal(_session: Object, prompt: *const c_char, echo: c_int, data: *mut c_void) {
|
||||
unsafe extern "C" fn request_signal(
|
||||
_session: Object,
|
||||
prompt: *const c_char,
|
||||
echo: c_int,
|
||||
data: *mut c_void,
|
||||
) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned();
|
||||
let prompt = unsafe { CStr::from_ptr(prompt) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
|
||||
let text = unsafe { CStr::from_ptr(text) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Info(text));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) {
|
||||
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
|
||||
let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
|
||||
let text = unsafe { CStr::from_ptr(text) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let _ = tx.send(SessionEvent::Error(text));
|
||||
}
|
||||
|
||||
@@ -155,7 +176,12 @@ unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_vo
|
||||
let _ = tx.send(SessionEvent::Completed(success != 0));
|
||||
}
|
||||
|
||||
unsafe fn connect(session: Object, name: &'static CStr, callback: gobject_ffi::GCallback, data: *mut c_void) {
|
||||
unsafe fn connect(
|
||||
session: Object,
|
||||
name: &'static CStr,
|
||||
callback: gobject_ffi::GCallback,
|
||||
data: *mut c_void,
|
||||
) {
|
||||
unsafe {
|
||||
gobject_ffi::g_signal_connect_data(
|
||||
session.cast(),
|
||||
@@ -195,7 +221,10 @@ impl Drop for SessionGuard {
|
||||
}
|
||||
|
||||
fn permitted_selection(request: &AuthenticationRequest, uid: u32) -> bool {
|
||||
request.identities.iter().any(|identity| identity.uid == uid)
|
||||
request
|
||||
.identities
|
||||
.iter()
|
||||
.any(|identity| identity.uid == uid)
|
||||
}
|
||||
|
||||
fn response_buffer(value: &str) -> Option<Zeroizing<Vec<u8>>> {
|
||||
@@ -273,7 +302,7 @@ fn run_session(
|
||||
unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
unsafe extern "C" fn(),
|
||||
>(
|
||||
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void)
|
||||
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
@@ -284,7 +313,7 @@ fn run_session(
|
||||
unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
unsafe extern "C" fn(),
|
||||
>(
|
||||
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void)
|
||||
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
@@ -295,7 +324,7 @@ fn run_session(
|
||||
unsafe extern "C" fn(Object, c_int, *mut c_void),
|
||||
unsafe extern "C" fn(),
|
||||
>(
|
||||
completed as unsafe extern "C" fn(Object, c_int, *mut c_void)
|
||||
completed as unsafe extern "C" fn(Object, c_int, *mut c_void),
|
||||
)),
|
||||
signal_data,
|
||||
);
|
||||
@@ -322,8 +351,13 @@ fn run_session(
|
||||
},
|
||||
)?;
|
||||
}
|
||||
SessionEvent::Info(text) => send(&mut process.writer, &PromptEvent::Info { generation, text })?,
|
||||
SessionEvent::Error(text) => send(&mut process.writer, &PromptEvent::Error { generation, text })?,
|
||||
SessionEvent::Info(text) => {
|
||||
send(&mut process.writer, &PromptEvent::Info { generation, text })?
|
||||
}
|
||||
SessionEvent::Error(text) => send(
|
||||
&mut process.writer,
|
||||
&PromptEvent::Error { generation, text },
|
||||
)?,
|
||||
SessionEvent::Completed(success) => outcome = Some(success),
|
||||
}
|
||||
}
|
||||
@@ -368,7 +402,11 @@ fn run_session(
|
||||
if !stopped
|
||||
&& (selected_uid.is_some()
|
||||
|| cancelled.load(Ordering::Acquire)
|
||||
|| process.child.try_wait().map_err(|e| e.to_string())?.is_some())
|
||||
|| process
|
||||
.child
|
||||
.try_wait()
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some())
|
||||
{
|
||||
unsafe { (api.cancel)(session) };
|
||||
stopped = true;
|
||||
@@ -402,7 +440,14 @@ pub fn authenticate(
|
||||
context
|
||||
.with_thread_default(|| {
|
||||
let mut process = prompt_process(request.clone())?;
|
||||
authenticate_sessions(&api, &mut request, &cookie, &context, &mut process, &cancelled)
|
||||
authenticate_sessions(
|
||||
&api,
|
||||
&mut request,
|
||||
&cookie,
|
||||
&context,
|
||||
&mut process,
|
||||
&cancelled,
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
@@ -421,7 +466,9 @@ fn authenticate_sessions(
|
||||
loop {
|
||||
match run_session(api, request, cookie, context, process, cancelled) {
|
||||
Ok(SessionOutcome::Completed(true)) => break Ok(true),
|
||||
Ok(SessionOutcome::IdentityChanged { uid, generation }) if !cancelled.load(Ordering::Acquire) => {
|
||||
Ok(SessionOutcome::IdentityChanged { uid, generation })
|
||||
if !cancelled.load(Ordering::Acquire) =>
|
||||
{
|
||||
request.selected_uid = uid;
|
||||
request.generation = generation;
|
||||
}
|
||||
@@ -429,7 +476,9 @@ fn authenticate_sessions(
|
||||
failures += 1;
|
||||
// Switching accounts never resets this request's attempt budget.
|
||||
if failures >= MAX_AUTH_ATTEMPTS {
|
||||
break Err(format!("Authentication failed after {MAX_AUTH_ATTEMPTS} attempts"));
|
||||
break Err(format!(
|
||||
"Authentication failed after {MAX_AUTH_ATTEMPTS} attempts"
|
||||
));
|
||||
}
|
||||
request.generation.attempt += 1;
|
||||
}
|
||||
@@ -448,7 +497,12 @@ mod tests {
|
||||
|
||||
unsafe extern "C" fn fake_initiate(session: Object) {
|
||||
unsafe {
|
||||
gobject_ffi::g_signal_emit_by_name(session.cast(), c"request".as_ptr(), c"Password:".as_ptr(), 0 as c_int);
|
||||
gobject_ffi::g_signal_emit_by_name(
|
||||
session.cast(),
|
||||
c"request".as_ptr(),
|
||||
c"Password:".as_ptr(),
|
||||
0 as c_int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +556,8 @@ mod tests {
|
||||
let mut uid = 1000;
|
||||
for line in BufReader::new(output).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if let Ok(PromptEvent::Request { generation, .. }) = serde_json::from_str::<PromptEvent>(&line)
|
||||
if let Ok(PromptEvent::Request { generation, .. }) =
|
||||
serde_json::from_str::<PromptEvent>(&line)
|
||||
{
|
||||
let count = counter.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let event = if count == 3 {
|
||||
@@ -527,7 +582,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut process = PromptProcess { child, writer, input };
|
||||
let mut process = PromptProcess {
|
||||
child,
|
||||
writer,
|
||||
input,
|
||||
};
|
||||
let mut request = request_fixture();
|
||||
let result = authenticate_sessions(
|
||||
&api,
|
||||
@@ -563,13 +622,21 @@ mod tests {
|
||||
let context = glib::MainContext::new();
|
||||
context
|
||||
.with_thread_default(|| {
|
||||
let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("5")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let writer = BufWriter::new(child.stdin.take().unwrap());
|
||||
let cookie = CString::new("test-cookie").unwrap();
|
||||
let cancelled = AtomicBool::new(false);
|
||||
let mut request = request_fixture();
|
||||
let (tx, input) = mpsc::channel();
|
||||
let mut process = PromptProcess { child, writer, input };
|
||||
let mut process = PromptProcess {
|
||||
child,
|
||||
writer,
|
||||
input,
|
||||
};
|
||||
tx.send(PromptEvent::SelectIdentity {
|
||||
uid: 1001,
|
||||
generation: Generation {
|
||||
@@ -585,7 +652,8 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(),
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled)
|
||||
.unwrap(),
|
||||
SessionOutcome::IdentityChanged {
|
||||
uid: 1001,
|
||||
generation: Generation {
|
||||
@@ -631,13 +699,15 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(),
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled)
|
||||
.unwrap(),
|
||||
SessionOutcome::Completed(true)
|
||||
);
|
||||
assert_eq!(RESPONSES.load(Ordering::SeqCst), 1);
|
||||
tx.send(PromptEvent::Cancel).unwrap();
|
||||
assert_eq!(
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled).unwrap(),
|
||||
run_session(&api, &request, &cookie, &context, &mut process, &cancelled)
|
||||
.unwrap(),
|
||||
SessionOutcome::Cancelled
|
||||
);
|
||||
})
|
||||
@@ -652,13 +722,24 @@ mod tests {
|
||||
let context = glib::MainContext::new();
|
||||
context
|
||||
.with_thread_default(|| {
|
||||
let mut child = Command::new("sleep").arg("5").stdin(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("5")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let writer = BufWriter::new(child.stdin.take().unwrap());
|
||||
let (tx, input) = mpsc::channel();
|
||||
let mut process = PromptProcess { child, writer, input };
|
||||
let mut process = PromptProcess {
|
||||
child,
|
||||
writer,
|
||||
input,
|
||||
};
|
||||
for (selection, uid) in [(2, 1001), (3, 1000), (1, 1001), (2, 1001)] {
|
||||
tx.send(PromptEvent::SelectIdentity {
|
||||
generation: Generation { selection, attempt: 0 },
|
||||
generation: Generation {
|
||||
selection,
|
||||
attempt: 0,
|
||||
},
|
||||
uid,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -25,7 +25,9 @@ pub(crate) enum Kind {
|
||||
pub(super) fn valid_answer(kind: &Kind, value: &str) -> bool {
|
||||
match kind {
|
||||
Kind::Pin => !value.is_empty() && value.chars().count() <= 16,
|
||||
Kind::Passkey => !value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit()),
|
||||
Kind::Passkey => {
|
||||
!value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
Kind::Display(_) => false,
|
||||
_ => true,
|
||||
}
|
||||
@@ -107,19 +109,29 @@ impl Pairing {
|
||||
}
|
||||
|
||||
fn check(state: &Pending, device: &str, sender: Option<&str>) -> Result<(), Error> {
|
||||
if state.target.as_deref() != Some(device) || sender.is_none() || state.owner.as_deref() != sender {
|
||||
if state.target.as_deref() != Some(device)
|
||||
|| sender.is_none()
|
||||
|| state.owner.as_deref() != sender
|
||||
{
|
||||
Err(Error::Rejected("No matching pairing request".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn ask(&self, device: OwnedObjectPath, kind: Kind, sender: Option<&str>) -> Result<Zeroizing<String>, Error> {
|
||||
async fn ask(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
kind: Kind,
|
||||
sender: Option<&str>,
|
||||
) -> Result<Zeroizing<String>, Error> {
|
||||
let (id, rx) = {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
Self::check(&state, device.as_str(), sender)?;
|
||||
if state.reply.is_some() {
|
||||
return Err(Error::Rejected("A pairing question is already pending".into()));
|
||||
return Err(Error::Rejected(
|
||||
"A pairing question is already pending".into(),
|
||||
));
|
||||
}
|
||||
state.serial = state.serial.wrapping_add(1);
|
||||
let id = state.serial;
|
||||
@@ -141,7 +153,12 @@ impl Pairing {
|
||||
.ok_or_else(|| Error::Canceled("Pairing canceled or timed out".into()))
|
||||
}
|
||||
|
||||
fn display(&self, device: OwnedObjectPath, text: String, sender: Option<&str>) -> Result<(), Error> {
|
||||
fn display(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
text: String,
|
||||
sender: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
Self::check(&state, device.as_str(), sender)?;
|
||||
state.serial = state.serial.wrapping_add(1);
|
||||
@@ -171,14 +188,20 @@ impl Pairing {
|
||||
device: OwnedObjectPath,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<String, Error> {
|
||||
let answer = self.ask(device, Kind::Pin, header.sender().map(|s| s.as_str())).await?;
|
||||
let answer = self
|
||||
.ask(device, Kind::Pin, header.sender().map(|s| s.as_str()))
|
||||
.await?;
|
||||
if !valid_answer(&Kind::Pin, &answer) {
|
||||
return Err(Error::Rejected("PIN must have 1–16 characters".into()));
|
||||
}
|
||||
Ok(answer.to_string())
|
||||
}
|
||||
|
||||
async fn request_passkey(&self, device: OwnedObjectPath, #[zbus(header)] header: Header<'_>) -> Result<u32, Error> {
|
||||
async fn request_passkey(
|
||||
&self,
|
||||
device: OwnedObjectPath,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<u32, Error> {
|
||||
let answer = self
|
||||
.ask(device, Kind::Passkey, header.sender().map(|s| s.as_str()))
|
||||
.await?;
|
||||
@@ -198,9 +221,13 @@ impl Pairing {
|
||||
passkey: u32,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<(), Error> {
|
||||
self.ask(device, Kind::Confirm(passkey), header.sender().map(|s| s.as_str()))
|
||||
.await
|
||||
.map(|_| ())
|
||||
self.ask(
|
||||
device,
|
||||
Kind::Confirm(passkey),
|
||||
header.sender().map(|s| s.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn request_authorization(
|
||||
@@ -219,9 +246,13 @@ impl Pairing {
|
||||
uuid: String,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> Result<(), Error> {
|
||||
self.ask(device, Kind::Service(uuid), header.sender().map(|s| s.as_str()))
|
||||
.await
|
||||
.map(|_| ())
|
||||
self.ask(
|
||||
device,
|
||||
Kind::Service(uuid),
|
||||
header.sender().map(|s| s.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn display_pin_code(
|
||||
@@ -276,7 +307,9 @@ impl Client {
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut registered = self.registered.lock().await;
|
||||
if registered.as_ref() == Some(&owner) && self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner) {
|
||||
if registered.as_ref() == Some(&owner)
|
||||
&& self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.agent.0.lock().unwrap().owner = Some(owner.clone());
|
||||
@@ -286,11 +319,16 @@ impl Client {
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path = OwnedObjectPath::try_from(PATH).unwrap();
|
||||
proxy(&self.connection, "org.bluez", "/org/bluez", "org.bluez.AgentManager1")
|
||||
.await?
|
||||
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
proxy(
|
||||
&self.connection,
|
||||
"org.bluez",
|
||||
"/org/bluez",
|
||||
"org.bluez.AgentManager1",
|
||||
)
|
||||
.await?
|
||||
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
*registered = Some(owner);
|
||||
Ok(())
|
||||
}
|
||||
@@ -338,11 +376,22 @@ mod tests {
|
||||
pairing.0.lock().unwrap().owner = Some(":1.2".into());
|
||||
let generation = pairing.begin("/device/1").unwrap();
|
||||
let path = OwnedObjectPath::try_from("/device/2").unwrap();
|
||||
assert!(pairing.ask(path, Kind::Authorize, Some(":1.2")).await.is_err());
|
||||
assert!(
|
||||
pairing
|
||||
.ask(path, Kind::Authorize, Some(":1.2"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let path = OwnedObjectPath::try_from("/device/1").unwrap();
|
||||
assert!(pairing.ask(path.clone(), Kind::Authorize, Some(":1.9")).await.is_err());
|
||||
assert!(
|
||||
pairing
|
||||
.ask(path.clone(), Kind::Authorize, Some(":1.9"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let other = pairing.clone();
|
||||
let task = tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
|
||||
let task =
|
||||
tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
|
||||
tokio::task::yield_now().await;
|
||||
let id = pairing.prompt().unwrap().id;
|
||||
pairing.reply(id.wrapping_sub(1), Some(Zeroizing::new(String::new())));
|
||||
@@ -361,7 +410,11 @@ mod tests {
|
||||
let other = pairing.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
other
|
||||
.ask(OwnedObjectPath::try_from("/device/1").unwrap(), Kind::Pin, Some(":1.2"))
|
||||
.ask(
|
||||
OwnedObjectPath::try_from("/device/1").unwrap(),
|
||||
Kind::Pin,
|
||||
Some(":1.2"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
@@ -370,6 +423,9 @@ mod tests {
|
||||
assert!(task.await.unwrap().is_err());
|
||||
pairing.begin("/device/2").unwrap();
|
||||
pairing.finish(old);
|
||||
assert_eq!(pairing.0.lock().unwrap().target.as_deref(), Some("/device/2"));
|
||||
assert_eq!(
|
||||
pairing.0.lock().unwrap().target.as_deref(),
|
||||
Some("/device/2")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,12 @@ pub(crate) struct Bluetooth {
|
||||
}
|
||||
|
||||
pub(crate) async fn read(connection: &Connection) -> Result<Bluetooth, String> {
|
||||
let objects: ManagedObjects = proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
|
||||
.await?
|
||||
.call("GetManagedObjects", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let objects: ManagedObjects =
|
||||
proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
|
||||
.await?
|
||||
.call("GetManagedObjects", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(snapshot(&objects))
|
||||
}
|
||||
|
||||
@@ -85,7 +86,11 @@ fn snapshot(objects: &ManagedObjects) -> Bluetooth {
|
||||
result.devices.push(Device {
|
||||
path: path.to_string(),
|
||||
adapter: adapter.to_string(),
|
||||
name: if alias.is_empty() { address.clone() } else { alias },
|
||||
name: if alias.is_empty() {
|
||||
address.clone()
|
||||
} else {
|
||||
alias
|
||||
},
|
||||
address,
|
||||
icon: string(p, "Icon"),
|
||||
connected: boolean(p, "Connected"),
|
||||
@@ -116,7 +121,11 @@ pub(crate) async fn power(connection: &Connection, path: &str, on: bool) -> Resu
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
|
||||
pub(crate) async fn adapter_call(
|
||||
connection: &Connection,
|
||||
path: &str,
|
||||
method: &str,
|
||||
) -> Result<(), String> {
|
||||
proxy(connection, BLUEZ, path, ADAPTER)
|
||||
.await?
|
||||
.call::<_, _, ()>(method, &())
|
||||
@@ -124,7 +133,11 @@ pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &s
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn device_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
|
||||
pub(crate) async fn device_call(
|
||||
connection: &Connection,
|
||||
path: &str,
|
||||
method: &str,
|
||||
) -> Result<(), String> {
|
||||
proxy(connection, BLUEZ, path, DEVICE)
|
||||
.await?
|
||||
.call::<_, _, ()>(method, &())
|
||||
@@ -140,7 +153,11 @@ pub(crate) async fn trust(connection: &Connection, path: &str, on: bool) -> Resu
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn forget(connection: &Connection, adapter: &str, path: &str) -> Result<(), String> {
|
||||
pub(crate) async fn forget(
|
||||
connection: &Connection,
|
||||
adapter: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = OwnedObjectPath::try_from(path).map_err(|e| e.to_string())?;
|
||||
proxy(connection, BLUEZ, adapter, ADAPTER)
|
||||
.await?
|
||||
|
||||
@@ -35,7 +35,11 @@ impl App {
|
||||
}
|
||||
body = body.push(choices);
|
||||
}
|
||||
let Some(adapter) = bluetooth.adapters.iter().find(|a| Some(&a.path) == s.adapter.as_ref()) else {
|
||||
let Some(adapter) = bluetooth
|
||||
.adapters
|
||||
.iter()
|
||||
.find(|a| Some(&a.path) == s.adapter.as_ref())
|
||||
else {
|
||||
return body
|
||||
.push(self.connection_note("No Bluetooth adapter is available."))
|
||||
.into();
|
||||
@@ -71,7 +75,10 @@ impl App {
|
||||
if !adapter.powered {
|
||||
return body.into();
|
||||
}
|
||||
let scanning = s.client.as_ref().is_some_and(|c| c.discovering(&adapter.path));
|
||||
let scanning = s
|
||||
.client
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.discovering(&adapter.path));
|
||||
body = body.push(
|
||||
row([])
|
||||
.align_y(Alignment::Center)
|
||||
@@ -87,7 +94,11 @@ impl App {
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(self.connection_button(
|
||||
if scanning { "Stop scan" } else { "Find devices" },
|
||||
if scanning {
|
||||
"Stop scan"
|
||||
} else {
|
||||
"Find devices"
|
||||
},
|
||||
Input::Run(if scanning {
|
||||
Command::BluetoothStop
|
||||
} else {
|
||||
@@ -102,9 +113,20 @@ impl App {
|
||||
} else {
|
||||
"Choose Find devices to discover nearby devices in pairing mode."
|
||||
}));
|
||||
let devices: Vec<_> = bluetooth.devices.iter().filter(|d| d.adapter == adapter.path).collect();
|
||||
let devices: Vec<_> = bluetooth
|
||||
.devices
|
||||
.iter()
|
||||
.filter(|d| d.adapter == adapter.path)
|
||||
.collect();
|
||||
for paired in [true, false] {
|
||||
body = body.push(self.label(if paired { "Paired devices" } else { "Nearby devices" }, 14.));
|
||||
body = body.push(self.label(
|
||||
if paired {
|
||||
"Paired devices"
|
||||
} else {
|
||||
"Nearby devices"
|
||||
},
|
||||
14.,
|
||||
));
|
||||
if !devices.iter().any(|d| d.paired == paired) {
|
||||
body = body.push(self.connection_note(if paired {
|
||||
"No paired devices yet."
|
||||
@@ -124,7 +146,10 @@ impl App {
|
||||
"{state} · {}{}{}",
|
||||
device.address,
|
||||
if device.trusted { " · Trusted" } else { "" },
|
||||
device.battery.map(|b| format!(" · {b}% battery")).unwrap_or_default()
|
||||
device
|
||||
.battery
|
||||
.map(|b| format!(" · {b}% battery"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let mut controls = row([]).spacing(6);
|
||||
let action = if device.connected {
|
||||
@@ -149,13 +174,19 @@ impl App {
|
||||
if device.paired {
|
||||
controls = controls.push(self.connection_button(
|
||||
if device.trusted { "Untrust" } else { "Trust" },
|
||||
Input::Run(Command::BluetoothTrust(device.path.clone(), !device.trusted)),
|
||||
Input::Run(Command::BluetoothTrust(
|
||||
device.path.clone(),
|
||||
!device.trusted,
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Forget",
|
||||
Input::Forget(Command::BluetoothForget(device.adapter.clone(), device.path.clone())),
|
||||
Input::Forget(Command::BluetoothForget(
|
||||
device.adapter.clone(),
|
||||
device.path.clone(),
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
|
||||
@@ -142,7 +142,11 @@ impl Wireless {
|
||||
|
||||
#[zbus(property)]
|
||||
fn active_access_point(&self) -> OwnedObjectPath {
|
||||
path(if self.0.lock().unwrap().activated { "/ap" } else { "/" })
|
||||
path(if self.0.lock().unwrap().activated {
|
||||
"/ap"
|
||||
} else {
|
||||
"/"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +166,11 @@ impl NetworkDevice {
|
||||
|
||||
#[zbus(property)]
|
||||
fn state(&self) -> u32 {
|
||||
if self.0.lock().unwrap().activated { 100 } else { 30 }
|
||||
if self.0.lock().unwrap().activated {
|
||||
100
|
||||
} else {
|
||||
30
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(&self) {
|
||||
@@ -227,7 +235,8 @@ impl Profile {
|
||||
"802-11-wireless".into(),
|
||||
HashMap::from([(
|
||||
"ssid".into(),
|
||||
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice())).unwrap(),
|
||||
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice()))
|
||||
.unwrap(),
|
||||
)]),
|
||||
),
|
||||
(
|
||||
@@ -263,14 +272,26 @@ async fn network_service(bus: &Bus, state: &Arc<Mutex<NetworkState>>) -> Connect
|
||||
server.request_name(NM).await.unwrap();
|
||||
let objects = server.object_server();
|
||||
objects.at(ROOT, Manager(state.clone())).await.unwrap();
|
||||
objects.at("/device", Wireless(state.clone())).await.unwrap();
|
||||
objects.at("/device", NetworkDevice(state.clone())).await.unwrap();
|
||||
objects.at("/ap", AccessPoint).await.unwrap();
|
||||
objects
|
||||
.at("/org/freedesktop/NetworkManager/Settings", Profiles(state.clone()))
|
||||
.at("/device", Wireless(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects
|
||||
.at("/device", NetworkDevice(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at("/ap", AccessPoint).await.unwrap();
|
||||
objects
|
||||
.at(
|
||||
"/org/freedesktop/NetworkManager/Settings",
|
||||
Profiles(state.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
objects
|
||||
.at("/profile", Profile(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at("/profile", Profile(state.clone())).await.unwrap();
|
||||
objects.at("/active", Active).await.unwrap();
|
||||
server
|
||||
}
|
||||
@@ -303,20 +324,30 @@ async fn wifi_controls_and_missing_bluetooth_are_independent() {
|
||||
assert_eq!(state.updates, 1);
|
||||
assert_eq!(state.scans, 1);
|
||||
assert_eq!(
|
||||
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"]).unwrap(),
|
||||
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"])
|
||||
.unwrap(),
|
||||
"updated-password"
|
||||
);
|
||||
}
|
||||
assert!(client.snapshot().await.wifi.unwrap().networks[0].connected);
|
||||
client.execute(Action::WifiDisconnect("/device".into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::WifiDisconnect("/device".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!client.snapshot().await.wifi.unwrap().networks[0].connected);
|
||||
let mut unsaved = wifi.networks[0].clone();
|
||||
unsaved.saved = None;
|
||||
client
|
||||
.execute(Action::WifiConnect(unsaved, Zeroizing::new("new-password".into())))
|
||||
.execute(Action::WifiConnect(
|
||||
unsaved,
|
||||
Zeroizing::new("new-password".into()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::WifiForget("/profile".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client.execute(Action::WifiForget("/profile".into())).await.unwrap();
|
||||
assert!(client.snapshot().await.wifi.unwrap().saved.is_empty());
|
||||
}
|
||||
|
||||
@@ -446,7 +477,12 @@ struct AgentManager(Arc<Mutex<BluetoothState>>);
|
||||
|
||||
#[zbus::interface(name = "org.bluez.AgentManager1")]
|
||||
impl AgentManager {
|
||||
fn register_agent(&self, agent: OwnedObjectPath, capability: String, #[zbus(header)] header: Header<'_>) {
|
||||
fn register_agent(
|
||||
&self,
|
||||
agent: OwnedObjectPath,
|
||||
capability: String,
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) {
|
||||
assert_eq!(capability, "KeyboardDisplay");
|
||||
self.0.lock().unwrap().agent = Some((header.sender().unwrap().to_string(), agent));
|
||||
}
|
||||
@@ -457,9 +493,15 @@ async fn bluetooth_service(bus: &Bus, state: &Arc<Mutex<BluetoothState>>) -> Con
|
||||
server.request_name("org.bluez").await.unwrap();
|
||||
let objects = server.object_server();
|
||||
objects.at("/", zbus::fdo::ObjectManager).await.unwrap();
|
||||
objects.at("/org/bluez", AgentManager(state.clone())).await.unwrap();
|
||||
objects
|
||||
.at("/org/bluez", AgentManager(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
objects.at(ADAPTER, Adapter(state.clone())).await.unwrap();
|
||||
objects.at(DEVICE, BluetoothDevice(state.clone())).await.unwrap();
|
||||
objects
|
||||
.at(DEVICE, BluetoothDevice(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
server
|
||||
}
|
||||
|
||||
@@ -496,8 +538,14 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
.execute(Action::BluetoothPower(ADAPTER.into(), true))
|
||||
.await
|
||||
.unwrap();
|
||||
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
|
||||
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothScan(ADAPTER.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothScan(ADAPTER.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.lock().unwrap().starts, 1);
|
||||
let snapshot = client.snapshot().await;
|
||||
assert!(snapshot.wifi.is_err());
|
||||
@@ -505,7 +553,8 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
assert!(bluetooth.adapters[0].powered);
|
||||
assert_eq!(bluetooth.devices[0].name, "Headphones");
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
assert_eq!(prompt.kind, Kind::Confirm(123456));
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
@@ -523,20 +572,31 @@ async fn bluetooth_pairing_controls_and_close_cleanup() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!state.lock().unwrap().trusted);
|
||||
client.execute(Action::BluetoothConnect(DEVICE.into())).await.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothConnect(DEVICE.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(Action::BluetoothForget(ADAPTER.into(), DEVICE.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.lock().unwrap().removed);
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
wait_for_prompt(&client).await;
|
||||
// Window destruction happens on a UI thread outside Tokio's context.
|
||||
let closing = client.clone();
|
||||
std::thread::spawn(move || closing.shutdown()).join().unwrap();
|
||||
std::thread::spawn(move || closing.shutdown())
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(pairing.await.unwrap().is_err());
|
||||
assert!(client.execute(Action::BluetoothConnect(DEVICE.into())).await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.execute(Action::BluetoothConnect(DEVICE.into()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.prompt().is_none());
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while state.lock().unwrap().discovering {
|
||||
@@ -556,7 +616,8 @@ async fn bluetooth_agent_recovers_after_service_restart() {
|
||||
let old_server = bluetooth_service(&bus, &state).await;
|
||||
let client = Client::with_connection(bus.connect().await);
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
pairing.await.unwrap().unwrap();
|
||||
@@ -564,7 +625,8 @@ async fn bluetooth_agent_recovers_after_service_restart() {
|
||||
let new_state = Arc::new(Mutex::new(BluetoothState::default()));
|
||||
let _server = bluetooth_service(&bus, &new_state).await;
|
||||
let other = client.clone();
|
||||
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let pairing =
|
||||
tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
|
||||
let prompt = wait_for_prompt(&client).await;
|
||||
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
|
||||
pairing.await.unwrap().unwrap();
|
||||
|
||||
@@ -14,9 +14,10 @@ impl App {
|
||||
}
|
||||
self.connections.loading = true;
|
||||
if let Some(client) = self.connections.client.clone() {
|
||||
Task::perform(async move { Box::new(client.snapshot().await) }, |snapshot| {
|
||||
cosmic::Action::App(Message::ConnectionsRefreshed(snapshot))
|
||||
})
|
||||
Task::perform(
|
||||
async move { Box::new(client.snapshot().await) },
|
||||
|snapshot| cosmic::Action::App(Message::ConnectionsRefreshed(snapshot)),
|
||||
)
|
||||
} else {
|
||||
Task::perform(Client::new(), |client| {
|
||||
cosmic::Action::App(Message::ConnectionsReady(client))
|
||||
@@ -24,7 +25,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connections_ready(&mut self, result: Result<Arc<Client>, String>) -> Task<Message> {
|
||||
pub(crate) fn connections_ready(
|
||||
&mut self,
|
||||
result: Result<Arc<Client>, String>,
|
||||
) -> Task<Message> {
|
||||
self.connections.loading = false;
|
||||
match result {
|
||||
Ok(client) => {
|
||||
@@ -80,18 +84,26 @@ impl App {
|
||||
let prompt = self.connections.client.as_ref().and_then(|c| c.prompt());
|
||||
if prompt != self.connections.prompt {
|
||||
self.connections.pairing_input.zeroize();
|
||||
let needs_input = prompt
|
||||
.as_ref()
|
||||
.is_some_and(|p| matches!(p.kind, super::agent::Kind::Pin | super::agent::Kind::Passkey));
|
||||
let needs_input = prompt.as_ref().is_some_and(|p| {
|
||||
matches!(
|
||||
p.kind,
|
||||
super::agent::Kind::Pin | super::agent::Kind::Passkey
|
||||
)
|
||||
});
|
||||
self.connections.prompt = prompt;
|
||||
let scroll = cosmic::iced::widget::scrollable::snap_to(
|
||||
cosmic::widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
|
||||
cosmic::iced::widget::scrollable::RelativeOffset {
|
||||
x: None,
|
||||
y: Some(0.),
|
||||
},
|
||||
);
|
||||
return if needs_input {
|
||||
Task::batch([
|
||||
scroll,
|
||||
cosmic::widget::text_input::focus(cosmic::widget::Id::new("bluetooth-passkey")),
|
||||
cosmic::widget::text_input::focus(cosmic::widget::Id::new(
|
||||
"bluetooth-passkey",
|
||||
)),
|
||||
])
|
||||
} else {
|
||||
scroll
|
||||
@@ -132,8 +144,12 @@ impl App {
|
||||
&& network.security.can_create()
|
||||
&& super::wifi::valid_password(network.security, &self.connections.password)
|
||||
{
|
||||
let password = std::mem::replace(&mut self.connections.password, Zeroizing::new(String::new()));
|
||||
return self.connections_input(Input::Run(Command::WifiConnect(network, password)));
|
||||
let password = std::mem::replace(
|
||||
&mut self.connections.password,
|
||||
Zeroizing::new(String::new()),
|
||||
);
|
||||
return self
|
||||
.connections_input(Input::Run(Command::WifiConnect(network, password)));
|
||||
}
|
||||
}
|
||||
Input::PairingValue(id, value) => {
|
||||
@@ -154,8 +170,12 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(client) = &self.connections.client {
|
||||
let value = accepted
|
||||
.then(|| std::mem::replace(&mut self.connections.pairing_input, Zeroizing::new(String::new())));
|
||||
let value = accepted.then(|| {
|
||||
std::mem::replace(
|
||||
&mut self.connections.pairing_input,
|
||||
Zeroizing::new(String::new()),
|
||||
)
|
||||
});
|
||||
client.reply(id, value);
|
||||
}
|
||||
self.connections.pairing_input.zeroize();
|
||||
@@ -165,7 +185,10 @@ impl App {
|
||||
self.connections.forget = Some(command);
|
||||
return cosmic::iced::widget::scrollable::snap_to(
|
||||
cosmic::widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
|
||||
cosmic::iced::widget::scrollable::RelativeOffset {
|
||||
x: None,
|
||||
y: Some(0.),
|
||||
},
|
||||
);
|
||||
}
|
||||
Input::CancelForget => self.connections.forget = None,
|
||||
@@ -195,9 +218,10 @@ impl App {
|
||||
self.connections.selected = None;
|
||||
self.connections.hidden = false;
|
||||
let epoch = client.epoch();
|
||||
return Task::perform(async move { client.execute_checked(command, epoch).await }, |result| {
|
||||
cosmic::Action::App(Message::ConnectionFinished(result))
|
||||
});
|
||||
return Task::perform(
|
||||
async move { client.execute_checked(command, epoch).await },
|
||||
|result| cosmic::Action::App(Message::ConnectionFinished(result)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
@@ -295,7 +319,10 @@ mod tests {
|
||||
hidden: false,
|
||||
};
|
||||
for message in [
|
||||
Input::Run(Command::WifiConnect(network, Zeroizing::new("never-log-this".into()))),
|
||||
Input::Run(Command::WifiConnect(
|
||||
network,
|
||||
Zeroizing::new("never-log-this".into()),
|
||||
)),
|
||||
Input::Password(Zeroizing::new("never-log-this".into())),
|
||||
Input::PairingValue(1, Zeroizing::new("never-log-this".into())),
|
||||
] {
|
||||
@@ -312,7 +339,9 @@ mod tests {
|
||||
(
|
||||
"/unused/connections-test.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
Some(crate::InitialPage::Connections(super::super::Tab::Bluetooth)),
|
||||
Some(crate::InitialPage::Connections(
|
||||
super::super::Tab::Bluetooth,
|
||||
)),
|
||||
),
|
||||
);
|
||||
assert_eq!(app.page, crate::schema::Page::Connections);
|
||||
|
||||
@@ -187,7 +187,9 @@ impl Client {
|
||||
match command {
|
||||
Command::WifiPower(on) => wifi::power(&self.connection, on).await,
|
||||
Command::WifiScan => wifi::scan(&self.connection).await,
|
||||
Command::WifiConnect(network, password) => wifi::connect(&self.connection, &network, &password).await,
|
||||
Command::WifiConnect(network, password) => {
|
||||
wifi::connect(&self.connection, &network, &password).await
|
||||
}
|
||||
Command::WifiDisconnect(path) => wifi::disconnect(&self.connection, &path).await,
|
||||
Command::WifiForget(path) => wifi::forget(&self.connection, &path).await,
|
||||
Command::BluetoothPower(path, on) => {
|
||||
@@ -199,10 +201,18 @@ impl Client {
|
||||
Command::BluetoothScan(path) => self.discover(path, epoch).await,
|
||||
Command::BluetoothStop => self.stop_discovery().await,
|
||||
Command::BluetoothPair(path) => self.pair(&path, epoch).await,
|
||||
Command::BluetoothConnect(path) => bluetooth::device_call(&self.connection, &path, "Connect").await,
|
||||
Command::BluetoothDisconnect(path) => bluetooth::device_call(&self.connection, &path, "Disconnect").await,
|
||||
Command::BluetoothTrust(path, on) => bluetooth::trust(&self.connection, &path, on).await,
|
||||
Command::BluetoothForget(adapter, path) => bluetooth::forget(&self.connection, &adapter, &path).await,
|
||||
Command::BluetoothConnect(path) => {
|
||||
bluetooth::device_call(&self.connection, &path, "Connect").await
|
||||
}
|
||||
Command::BluetoothDisconnect(path) => {
|
||||
bluetooth::device_call(&self.connection, &path, "Disconnect").await
|
||||
}
|
||||
Command::BluetoothTrust(path, on) => {
|
||||
bluetooth::trust(&self.connection, &path, on).await
|
||||
}
|
||||
Command::BluetoothForget(adapter, path) => {
|
||||
bluetooth::forget(&self.connection, &adapter, &path).await
|
||||
}
|
||||
Command::CancelPairing => self.cancel_pairing().await,
|
||||
}
|
||||
}
|
||||
@@ -238,7 +248,9 @@ impl Drop for Client {
|
||||
}
|
||||
}
|
||||
|
||||
async fn bounded<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
|
||||
async fn bounded<T>(
|
||||
future: impl std::future::Future<Output = Result<T, String>>,
|
||||
) -> Result<T, String> {
|
||||
tokio::time::timeout(Duration::from_secs(10), future)
|
||||
.await
|
||||
.map_err(|_| "The connection service did not respond. Try again.".to_owned())?
|
||||
|
||||
@@ -67,11 +67,14 @@ impl App {
|
||||
),
|
||||
Input::Tab(Tab::Wifi) => (Some(crate::schema::Page::Connections.icon()), false),
|
||||
Input::Tab(Tab::Bluetooth) => (Some("M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"), false),
|
||||
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => {
|
||||
(Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"), false)
|
||||
}
|
||||
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => (
|
||||
Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"),
|
||||
false,
|
||||
),
|
||||
Input::Run(Command::BluetoothStop) => (Some("M6 6h12v12H6z"), false),
|
||||
Input::Run(Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_))
|
||||
Input::Run(
|
||||
Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_),
|
||||
)
|
||||
| Input::Select(_)
|
||||
| Input::SubmitWifi => (Some("M4 12h15 M14 7l5 5-5 5"), false),
|
||||
Input::Run(Command::WifiDisconnect(_) | Command::BluetoothDisconnect(_)) => {
|
||||
@@ -83,7 +86,10 @@ impl App {
|
||||
};
|
||||
let mut content = row([]).spacing(5).align_y(Alignment::Center);
|
||||
if let Some(icon) = icon {
|
||||
content = content.push(visuals::action_icon(icon, if selected { p.accent } else { p.text }));
|
||||
content = content.push(visuals::action_icon(
|
||||
icon,
|
||||
if selected { p.accent } else { p.text },
|
||||
));
|
||||
}
|
||||
if !icon_only {
|
||||
content = content.push(self.label(label.to_owned(), 12.));
|
||||
@@ -112,7 +118,10 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn connection_card<'a>(&self, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
pub(super) fn connection_card<'a>(
|
||||
&self,
|
||||
body: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
container(body)
|
||||
.padding([7, 10])
|
||||
@@ -128,8 +137,18 @@ impl App {
|
||||
body = body.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
.push(self.connection_button("Wi-Fi", Input::Tab(Tab::Wifi), true, s.tab == Tab::Wifi))
|
||||
.push(self.connection_button("Bluetooth", Input::Tab(Tab::Bluetooth), true, s.tab == Tab::Bluetooth))
|
||||
.push(self.connection_button(
|
||||
"Wi-Fi",
|
||||
Input::Tab(Tab::Wifi),
|
||||
true,
|
||||
s.tab == Tab::Wifi,
|
||||
))
|
||||
.push(self.connection_button(
|
||||
"Bluetooth",
|
||||
Input::Tab(Tab::Bluetooth),
|
||||
true,
|
||||
s.tab == Tab::Bluetooth,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
.push(self.connection_button("Refresh", Input::Refresh, !s.loading, false)),
|
||||
);
|
||||
@@ -147,7 +166,12 @@ impl App {
|
||||
.spacing(6)
|
||||
.push(self.label(busy, 12.));
|
||||
if busy == "Pairing…" {
|
||||
status = status.push(self.connection_button("Cancel", Input::Run(Command::CancelPairing), true, false));
|
||||
status = status.push(self.connection_button(
|
||||
"Cancel",
|
||||
Input::Run(Command::CancelPairing),
|
||||
true,
|
||||
false,
|
||||
));
|
||||
}
|
||||
body = body.push(status);
|
||||
}
|
||||
|
||||
@@ -101,12 +101,18 @@ fn security(flags: u32, wpa: u32, rsn: u32) -> Security {
|
||||
|
||||
pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let enabled = root.get_property("WirelessEnabled").await.map_err(|e| e.to_string())?;
|
||||
let enabled = root
|
||||
.get_property("WirelessEnabled")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let hardware_enabled = root
|
||||
.get_property("WirelessHardwareEnabled")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root
|
||||
.call("GetDevices", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let saved = profiles(connection).await?;
|
||||
let mut result = Wifi {
|
||||
enabled,
|
||||
@@ -120,7 +126,10 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
|
||||
continue;
|
||||
}
|
||||
let name: String = device.get_property("Interface").await.map_err(|e| e.to_string())?;
|
||||
let name: String = device
|
||||
.get_property("Interface")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
result.radios.push(Radio {
|
||||
path: path.to_string(),
|
||||
name,
|
||||
@@ -128,7 +137,10 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let state: u32 = device.get_property("State").await.map_err(|e| e.to_string())?;
|
||||
let state: u32 = device
|
||||
.get_property("State")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let wireless = proxy(connection, NM, path.as_str(), WIRELESS).await?;
|
||||
let active: OwnedObjectPath = wireless
|
||||
.get_property("ActiveAccessPoint")
|
||||
@@ -144,10 +156,19 @@ pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||
if ssid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let strength = ap.get_property("Strength").await.map_err(|e| e.to_string())?;
|
||||
let strength = ap
|
||||
.get_property("Strength")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let flags: u32 = ap.get_property("Flags").await.map_err(|e| e.to_string())?;
|
||||
let wpa: u32 = ap.get_property("WpaFlags").await.map_err(|e| e.to_string())?;
|
||||
let rsn: u32 = ap.get_property("RsnFlags").await.map_err(|e| e.to_string())?;
|
||||
let wpa: u32 = ap
|
||||
.get_property("WpaFlags")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rsn: u32 = ap
|
||||
.get_property("RsnFlags")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let auth = security(flags, wpa, rsn);
|
||||
let saved = result
|
||||
.saved
|
||||
@@ -187,17 +208,31 @@ fn compatible(saved: Security, advertised: Security, auth: u32) -> bool {
|
||||
|
||||
fn insert_network(networks: &mut BTreeMap<(Vec<u8>, Security, String), Network>, network: Network) {
|
||||
// Keep adapters separate, and prefer the active AP over a stronger same-SSID AP.
|
||||
let key = (network.ssid.clone(), network.security, network.device.clone());
|
||||
let key = (
|
||||
network.ssid.clone(),
|
||||
network.security,
|
||||
network.device.clone(),
|
||||
);
|
||||
if networks.get(&key).is_none_or(|old| {
|
||||
(network.connected, network.connecting, network.strength) > (old.connected, old.connecting, old.strength)
|
||||
(network.connected, network.connecting, network.strength)
|
||||
> (old.connected, old.connecting, old.strength)
|
||||
}) {
|
||||
networks.insert(key, network);
|
||||
}
|
||||
}
|
||||
|
||||
async fn profiles(connection: &Connection) -> Result<Vec<Saved>, String> {
|
||||
let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?;
|
||||
let paths: Vec<OwnedObjectPath> = settings.call("ListConnections", &()).await.map_err(|e| e.to_string())?;
|
||||
let settings = proxy(
|
||||
connection,
|
||||
NM,
|
||||
"/org/freedesktop/NetworkManager/Settings",
|
||||
SETTINGS,
|
||||
)
|
||||
.await?;
|
||||
let paths: Vec<OwnedObjectPath> = settings
|
||||
.call("ListConnections", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut saved = Vec::new();
|
||||
for path in paths {
|
||||
let profile = proxy(connection, NM, path.as_str(), PROFILE).await?;
|
||||
@@ -252,7 +287,10 @@ pub(crate) async fn power(connection: &Connection, enabled: bool) -> Result<(),
|
||||
|
||||
pub(crate) async fn scan(connection: &Connection) -> Result<(), String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||
let devices: Vec<OwnedObjectPath> = root
|
||||
.call("GetDevices", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut found = false;
|
||||
for path in devices {
|
||||
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
|
||||
@@ -331,7 +369,11 @@ fn settings<'a>(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub(crate) async fn connect(connection: &Connection, network: &Network, password: &str) -> Result<(), String> {
|
||||
pub(crate) async fn connect(
|
||||
connection: &Connection,
|
||||
network: &Network,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||
let device = OwnedObjectPath::try_from(network.device.as_str()).map_err(|e| e.to_string())?;
|
||||
let ap = OwnedObjectPath::try_from(network.ap.as_str()).map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +383,10 @@ pub(crate) async fn connect(connection: &Connection, network: &Network, password
|
||||
return Err("Invalid network password.".into());
|
||||
}
|
||||
let profile = proxy(connection, NM, saved, PROFILE).await?;
|
||||
let previous: Settings = profile.call("GetSettings", &()).await.map_err(|e| e.to_string())?;
|
||||
let previous: Settings = profile
|
||||
.call("GetSettings", &())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut values: HashMap<String, HashMap<String, Value<'_>>> = previous
|
||||
.into_iter()
|
||||
.map(|(section, values)| {
|
||||
@@ -383,9 +428,17 @@ pub(crate) async fn connect(connection: &Connection, network: &Network, password
|
||||
)
|
||||
.await?;
|
||||
for _ in 0..90 {
|
||||
match active.get_property::<u32>("State").await.map_err(|e| e.to_string())? {
|
||||
match active
|
||||
.get_property::<u32>("State")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
2 => return Ok(()),
|
||||
3 | 4 => return Err("The network connection failed. Check the password and try again.".into()),
|
||||
3 | 4 => {
|
||||
return Err(
|
||||
"The network connection failed. Check the password and try again.".into(),
|
||||
);
|
||||
}
|
||||
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
|
||||
}
|
||||
}
|
||||
@@ -464,10 +517,17 @@ mod tests {
|
||||
let mut network = network();
|
||||
network.hidden = true;
|
||||
let values = settings(&network, "password").unwrap();
|
||||
assert_eq!(values["802-11-wireless-security"]["psk"], Value::from("password"));
|
||||
assert_eq!(
|
||||
values["802-11-wireless-security"]["psk"],
|
||||
Value::from("password")
|
||||
);
|
||||
assert_eq!(values["802-11-wireless"]["hidden"], Value::from(true));
|
||||
network.security = Security::Open;
|
||||
assert!(!settings(&network, "").unwrap().contains_key("802-11-wireless-security"));
|
||||
assert!(
|
||||
!settings(&network, "")
|
||||
.unwrap()
|
||||
.contains_key("802-11-wireless-security")
|
||||
);
|
||||
network.security = Security::Enterprise;
|
||||
assert!(settings(&network, "password").is_err());
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ use super::{Command, Security};
|
||||
use crate::{App, Message, visuals};
|
||||
|
||||
impl App {
|
||||
pub(super) fn wifi_view(&self, result: &Result<super::wifi::Wifi, String>) -> Element<'_, Message> {
|
||||
pub(super) fn wifi_view(
|
||||
&self,
|
||||
result: &Result<super::wifi::Wifi, String>,
|
||||
) -> Element<'_, Message> {
|
||||
let s = &self.connections;
|
||||
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let ready = s.busy.is_none();
|
||||
@@ -46,8 +49,10 @@ impl App {
|
||||
ferese_theme::controls::switch(wifi.enabled, p)
|
||||
.name("Wi-Fi power")
|
||||
.on_press_maybe(
|
||||
(ready && (wifi.hardware_enabled || wifi.enabled) && !wifi.radios.is_empty())
|
||||
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
|
||||
(ready
|
||||
&& (wifi.hardware_enabled || wifi.enabled)
|
||||
&& !wifi.radios.is_empty())
|
||||
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -62,7 +67,8 @@ impl App {
|
||||
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
|
||||
);
|
||||
if wifi.networks.is_empty() {
|
||||
body = body.push(self.connection_note("No networks found. Scan to look for nearby networks."));
|
||||
body = body
|
||||
.push(self.connection_note("No networks found. Scan to look for nearby networks."));
|
||||
}
|
||||
for network in &wifi.networks {
|
||||
let radio = wifi
|
||||
@@ -98,20 +104,33 @@ impl App {
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
} else if network.saved.is_some() || !network.security.password() && network.security.can_create() {
|
||||
} else if network.saved.is_some()
|
||||
|| !network.security.password() && network.security.can_create()
|
||||
{
|
||||
controls = controls.push(self.connection_button(
|
||||
"Connect",
|
||||
Input::Run(Command::WifiConnect(network.clone(), Zeroizing::new(String::new()))),
|
||||
Input::Run(Command::WifiConnect(
|
||||
network.clone(),
|
||||
Zeroizing::new(String::new()),
|
||||
)),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
} else {
|
||||
controls =
|
||||
controls.push(self.connection_button("Connect…", Input::Select(network.clone()), ready, false));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Connect…",
|
||||
Input::Select(network.clone()),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
}
|
||||
if network.saved.is_some() && network.security.password() {
|
||||
controls =
|
||||
controls.push(self.connection_button("Password…", Input::Select(network.clone()), ready, false));
|
||||
controls = controls.push(self.connection_button(
|
||||
"Password…",
|
||||
Input::Select(network.clone()),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
}
|
||||
body = body.push(
|
||||
self.connection_card(
|
||||
@@ -140,7 +159,12 @@ impl App {
|
||||
body = body.push(self.wifi_credentials());
|
||||
}
|
||||
}
|
||||
body = body.push(self.connection_button("Join a hidden network…", Input::Hidden(true), ready, false));
|
||||
body = body.push(self.connection_button(
|
||||
"Join a hidden network…",
|
||||
Input::Hidden(true),
|
||||
ready,
|
||||
false,
|
||||
));
|
||||
if s.hidden {
|
||||
body = body.push(self.wifi_credentials());
|
||||
}
|
||||
@@ -152,7 +176,10 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon(crate::schema::Page::Connections.icon(), p.muted))
|
||||
.push(visuals::action_icon(
|
||||
crate::schema::Page::Connections.icon(),
|
||||
p.muted,
|
||||
))
|
||||
.push(
|
||||
column([])
|
||||
.spacing(2)
|
||||
@@ -183,7 +210,10 @@ impl App {
|
||||
} else {
|
||||
format!(
|
||||
"Join {}",
|
||||
s.selected.as_ref().map(|n| n.name.as_str()).unwrap_or("network")
|
||||
s.selected
|
||||
.as_ref()
|
||||
.map(|n| n.name.as_str())
|
||||
.unwrap_or("network")
|
||||
)
|
||||
},
|
||||
16.,
|
||||
@@ -198,7 +228,12 @@ impl App {
|
||||
.on_input(|name| message(Input::HiddenName(name))),
|
||||
);
|
||||
let mut choices = row([]).spacing(6);
|
||||
for security in [Security::Personal, Security::Sae, Security::Open, Security::Owe] {
|
||||
for security in [
|
||||
Security::Personal,
|
||||
Security::Sae,
|
||||
Security::Open,
|
||||
Security::Owe,
|
||||
] {
|
||||
choices = choices.push(self.connection_button(
|
||||
security.label(),
|
||||
Input::HiddenSecurity(security),
|
||||
@@ -208,7 +243,10 @@ impl App {
|
||||
}
|
||||
body = body.push(choices);
|
||||
}
|
||||
let security = target.as_ref().map(|n| n.security).unwrap_or(s.hidden_security);
|
||||
let security = target
|
||||
.as_ref()
|
||||
.map(|n| n.security)
|
||||
.unwrap_or(s.hidden_security);
|
||||
if security.password() {
|
||||
body = body.push(
|
||||
text_input("Network password", s.password.as_str())
|
||||
|
||||
@@ -19,7 +19,12 @@ impl Mode {
|
||||
}
|
||||
|
||||
pub fn config(self) -> String {
|
||||
format!("{}x{}@{:.3}", self.width, self.height, f64::from(self.refresh) / 1000.)
|
||||
format!(
|
||||
"{}x{}@{:.3}",
|
||||
self.width,
|
||||
self.height,
|
||||
f64::from(self.refresh) / 1000.
|
||||
)
|
||||
}
|
||||
|
||||
pub fn label(self) -> String {
|
||||
@@ -131,7 +136,9 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
|
||||
.find(|d| d.connector == matcher || d.identity == matcher);
|
||||
targets.push(Target {
|
||||
key: format!("{name}\0{matcher}"),
|
||||
title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()),
|
||||
title: live
|
||||
.map(|d| d.connector.clone())
|
||||
.unwrap_or_else(|| matcher.clone()),
|
||||
matcher,
|
||||
profile: Some(name.clone()),
|
||||
prefix: Some(prefix),
|
||||
@@ -226,7 +233,10 @@ pub(super) fn change_resolution(
|
||||
resolution: Resolution,
|
||||
automatic: bool,
|
||||
) -> Option<(Mode, bool)> {
|
||||
let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height));
|
||||
let modes = choices(
|
||||
display,
|
||||
&format!("{}x{}", resolution.width, resolution.height),
|
||||
);
|
||||
let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh));
|
||||
let previous = configured
|
||||
.split('@')
|
||||
@@ -327,7 +337,10 @@ mod tests {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(!snapshot.boolean(&format!("{prefix}.auto_refresh"), true));
|
||||
assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]");
|
||||
assert_eq!(
|
||||
snapshot.string(&format!("{prefix}.mode"), ""),
|
||||
"[email protected]"
|
||||
);
|
||||
assert_eq!(snapshot.number(&format!("{prefix}.scale"), 1.), 1.75);
|
||||
for edit in edits(
|
||||
prefix,
|
||||
@@ -341,14 +354,19 @@ mod tests {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false));
|
||||
assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]");
|
||||
assert_eq!(
|
||||
snapshot.string(&format!("{prefix}.mode"), ""),
|
||||
"[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_display_prefers_active_profile_and_survives_profile_reordering() {
|
||||
let snapshot =
|
||||
Snapshot::parse("output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }".into())
|
||||
.unwrap();
|
||||
let snapshot = Snapshot::parse(
|
||||
"output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }"
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let live = vec![Display {
|
||||
connector: "eDP-1".into(),
|
||||
identity: "panel".into(),
|
||||
@@ -359,13 +377,18 @@ mod tests {
|
||||
modes: vec![],
|
||||
}];
|
||||
let values = targets(&snapshot, &live);
|
||||
assert_eq!(selected(&values, None).unwrap().profile.as_deref(), Some("laptop"));
|
||||
assert_eq!(
|
||||
selected(&values, None).unwrap().profile.as_deref(),
|
||||
Some("laptop")
|
||||
);
|
||||
let dock = selected(&values, Some("dock\0HDMI-A-1")).unwrap();
|
||||
assert!(!dock.connected);
|
||||
let key = dock.key.clone();
|
||||
let reordered =
|
||||
Snapshot::parse("output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }".into())
|
||||
.unwrap();
|
||||
let reordered = Snapshot::parse(
|
||||
"output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }"
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let values = targets(&reordered, &live);
|
||||
assert_eq!(
|
||||
selected(&values, Some(&key)).unwrap().prefix.as_deref(),
|
||||
@@ -392,7 +415,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolution_changes_preserve_refresh_and_only_keep_valid_auto_policy() {
|
||||
let mode = |width, height, refresh| Mode { width, height, refresh };
|
||||
let mode = |width, height, refresh| Mode {
|
||||
width,
|
||||
height,
|
||||
refresh,
|
||||
};
|
||||
let display = Display {
|
||||
connector: "eDP-1".into(),
|
||||
identity: "panel".into(),
|
||||
@@ -427,6 +454,17 @@ mod tests {
|
||||
change_resolution(&display, "2880x1800@120", other, true),
|
||||
Some((mode(2560, 1440, 90000), false))
|
||||
);
|
||||
assert!(change_resolution(&display, "", Resolution { width: 1, height: 1 }, true).is_none());
|
||||
assert!(
|
||||
change_resolution(
|
||||
&display,
|
||||
"",
|
||||
Resolution {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
true
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,17 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon("M3 4h18v13H3z M12 17v4 M8 21h8", palette.accent))
|
||||
.push(visuals::action_icon(
|
||||
"M3 4h18v13H3z M12 17v4 M8 21h8",
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(targets.clone(), selected.clone(), |target| {
|
||||
Message::SelectDisplay(target.key)
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
targets.clone(),
|
||||
selected.clone(),
|
||||
|target| Message::SelectDisplay(target.key),
|
||||
)
|
||||
.placeholder("Select display")
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
@@ -86,7 +91,9 @@ impl App {
|
||||
}
|
||||
let modes = displays::choices(display, &configured);
|
||||
let mut refresh: Vec<_> = modes.iter().copied().map(Refresh::Manual).collect();
|
||||
if modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh))
|
||||
if modes
|
||||
.iter()
|
||||
.any(|mode| (59_000..=61_000).contains(&mode.refresh))
|
||||
&& let Some(high) = modes.last()
|
||||
{
|
||||
refresh.push(Refresh::Automatic(*high));
|
||||
@@ -101,7 +108,9 @@ impl App {
|
||||
let selected = refresh.iter().copied().find(|value| match value {
|
||||
Refresh::Automatic(_) => automatic,
|
||||
Refresh::Manual(mode) => {
|
||||
!automatic && configured_rate.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
|
||||
!automatic
|
||||
&& configured_rate
|
||||
.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
|
||||
}
|
||||
});
|
||||
let path = prefix.clone();
|
||||
@@ -109,10 +118,18 @@ impl App {
|
||||
self.display_control(
|
||||
"Refresh rate",
|
||||
ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(refresh, selected, move |value| match value {
|
||||
Refresh::Manual(mode) => Message::RefreshRate(path.clone(), mode, false),
|
||||
Refresh::Automatic(mode) => Message::RefreshRate(path.clone(), mode, true),
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
refresh,
|
||||
selected,
|
||||
move |value| match value {
|
||||
Refresh::Manual(mode) => {
|
||||
Message::RefreshRate(path.clone(), mode, false)
|
||||
}
|
||||
Refresh::Automatic(mode) => {
|
||||
Message::RefreshRate(path.clone(), mode, true)
|
||||
}
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
.padding([4, 8])
|
||||
@@ -157,7 +174,11 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn display_control<'a>(&self, label: &str, control: Element<'a, Message>) -> Element<'a, Message> {
|
||||
fn display_control<'a>(
|
||||
&self,
|
||||
label: &str,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
row([])
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
@@ -168,6 +189,11 @@ impl App {
|
||||
}
|
||||
|
||||
fn display_mode_field(&self, prefix: &str) -> Element<'static, Message> {
|
||||
self.field(schema::text(format!("{prefix}.mode"), "Resolution / refresh", "", ""))
|
||||
self.field(schema::text(
|
||||
format!("{prefix}.mode"),
|
||||
"Resolution / refresh",
|
||||
"",
|
||||
"",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use cosmic::widget::column;
|
||||
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals,
|
||||
widget,
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider,
|
||||
text_input, visuals, widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
@@ -39,7 +39,11 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn control_button_style(&self, palette: visuals::Palette, selected: bool) -> cosmic::theme::Button {
|
||||
fn control_button_style(
|
||||
&self,
|
||||
palette: visuals::Palette,
|
||||
selected: bool,
|
||||
) -> cosmic::theme::Button {
|
||||
if self.page == crate::schema::Page::Bar {
|
||||
visuals::panel_button(palette, selected)
|
||||
} else {
|
||||
@@ -61,7 +65,11 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::action_icon(
|
||||
icon,
|
||||
if message.is_some() { palette.text } else { palette.muted },
|
||||
if message.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.push(self.label(label.to_owned(), 12.)),
|
||||
)
|
||||
@@ -82,7 +90,11 @@ impl App {
|
||||
widget::tooltip(
|
||||
button::custom(visuals::action_icon(
|
||||
icon,
|
||||
if message.is_some() { palette.text } else { palette.muted },
|
||||
if message.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.name(label.to_owned())
|
||||
.padding(4)
|
||||
@@ -102,13 +114,16 @@ impl App {
|
||||
ferese_config::theme::Appearance::Dark => "dark",
|
||||
};
|
||||
field.path = format!("theme.{name}.material.opacity");
|
||||
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
field.description =
|
||||
format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(2).push(self.label(field.label.clone(), 13.));
|
||||
let mut labels = column([])
|
||||
.spacing(2)
|
||||
.push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
self.label(field.description.clone(), 11.)
|
||||
@@ -121,7 +136,11 @@ impl App {
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{}: {}",
|
||||
field.label,
|
||||
if enabled { "on" } else { "off" }
|
||||
))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
@@ -133,7 +152,8 @@ impl App {
|
||||
suffix,
|
||||
integer,
|
||||
} => {
|
||||
let margin_limit = (path == "panels.0.geometry.side_margins").then(|| self.panel_side_margin_limit());
|
||||
let margin_limit = (path == "panels.0.geometry.side_margins")
|
||||
.then(|| self.panel_side_margin_limit());
|
||||
if let Some(limit) = margin_limit {
|
||||
max = f64::from(limit.unwrap_or(0));
|
||||
}
|
||||
@@ -158,11 +178,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(8)
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
slider(min..=max, value, move |value| {
|
||||
Message::Range(field.clone(), value)
|
||||
})
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
.push(self.label(display, 12.).width(65))
|
||||
.into()
|
||||
@@ -208,7 +230,10 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
|
||||
.push(
|
||||
container(widget::Space::new().width(24).height(24))
|
||||
.class(visuals::surface(swatch, 6.)),
|
||||
)
|
||||
.push(input)
|
||||
.into()
|
||||
} else {
|
||||
|
||||
@@ -54,8 +54,12 @@ fn main() -> cosmic::iced::Result {
|
||||
}
|
||||
Some("--page") => {
|
||||
initial_page = match args.next().as_deref().and_then(|s| s.to_str()) {
|
||||
Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)),
|
||||
Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)),
|
||||
Some("connections" | "wifi") => {
|
||||
Some(InitialPage::Connections(connections::Tab::Wifi))
|
||||
}
|
||||
Some("bluetooth") => {
|
||||
Some(InitialPage::Connections(connections::Tab::Bluetooth))
|
||||
}
|
||||
Some("wallpaper") => Some(InitialPage::Wallpaper),
|
||||
Some("bar") => Some(InitialPage::Bar),
|
||||
_ => {
|
||||
@@ -65,7 +69,9 @@ fn main() -> cosmic::iced::Result {
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]");
|
||||
eprintln!(
|
||||
"Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -144,7 +150,10 @@ enum Message {
|
||||
WallpaperTarget(wallpaper_controls::Target),
|
||||
UseDefaultWallpaper(ferese_config::theme::Appearance),
|
||||
RestoreDefaultWallpapers,
|
||||
DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>),
|
||||
DefaultWallpaperThumbnail(
|
||||
ferese_config::theme::Appearance,
|
||||
Result<widget::image::Handle, String>,
|
||||
),
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
|
||||
@@ -295,7 +304,13 @@ impl cosmic::Application for App {
|
||||
native_palette,
|
||||
gallery_focus: None,
|
||||
family_sections,
|
||||
family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()),
|
||||
family_ids: std::sync::Arc::new(
|
||||
resolved
|
||||
.families
|
||||
.iter()
|
||||
.map(|family| family.id.clone())
|
||||
.collect(),
|
||||
),
|
||||
resolved,
|
||||
undo_revision: 0,
|
||||
auto_details: false,
|
||||
@@ -352,7 +367,8 @@ impl cosmic::Application for App {
|
||||
.displays
|
||||
.iter()
|
||||
.find(|d| d.connector == matcher || d.identity == matcher)
|
||||
&& let Some((mode, automatic)) = displays::change_resolution(display, &configured, size, automatic)
|
||||
&& let Some((mode, automatic)) =
|
||||
displays::change_resolution(display, &configured, size, automatic)
|
||||
{
|
||||
self.inputs.remove(&format!("{prefix}.mode"));
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
@@ -371,18 +387,26 @@ impl cosmic::Application for App {
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
}
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let wallpaper_changed =
|
||||
self.resolved.presented.tokens.background.path != snapshot.presented.tokens.background.path;
|
||||
let wallpaper_changed = self.resolved.presented.tokens.background.path
|
||||
!= snapshot.presented.tokens.background.path;
|
||||
let font_changed = self.resolved.presented.tokens.typography.font_family
|
||||
!= snapshot.presented.tokens.typography.font_family;
|
||||
if self.resolved.families != snapshot.families {
|
||||
self.family_ids =
|
||||
std::sync::Arc::new(snapshot.families.iter().map(|family| family.id.clone()).collect());
|
||||
self.family_sections = theme_controls::FamilySections::new(&snapshot.families, &self.draft);
|
||||
self.family_ids = std::sync::Arc::new(
|
||||
snapshot
|
||||
.families
|
||||
.iter()
|
||||
.map(|family| family.id.clone())
|
||||
.collect(),
|
||||
);
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&snapshot.families, &self.draft);
|
||||
}
|
||||
self.resolved = *snapshot;
|
||||
if font_changed {
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
}
|
||||
|
||||
let theme = self.update_theme();
|
||||
@@ -393,16 +417,28 @@ impl cosmic::Application for App {
|
||||
};
|
||||
}
|
||||
Message::ExternalConfig(result) => match result {
|
||||
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot)
|
||||
if snapshot.source == self.current.source
|
||||
|| snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot) => {
|
||||
if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() {
|
||||
self.status = "Config changed externally · Reload when your edits are finished".into();
|
||||
if self.saving
|
||||
|| !self.inputs.is_empty()
|
||||
|| !self.ranges.is_empty()
|
||||
|| !self.pending.is_empty()
|
||||
{
|
||||
self.status =
|
||||
"Config changed externally · Reload when your edits are finished"
|
||||
.into();
|
||||
} else {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
&self.resolved.families,
|
||||
&self.draft,
|
||||
);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
self.status = "Updated from your config".into();
|
||||
@@ -442,7 +478,9 @@ impl cosmic::Application for App {
|
||||
Message::Sidebar(open) => {
|
||||
self.sidebar_open = open;
|
||||
if open {
|
||||
return cosmic::iced::widget::operation::focus(widget::Id::new("settings-search"));
|
||||
return cosmic::iced::widget::operation::focus(widget::Id::new(
|
||||
"settings-search",
|
||||
));
|
||||
}
|
||||
}
|
||||
Message::Page(page) => {
|
||||
@@ -504,7 +542,9 @@ impl cosmic::Application for App {
|
||||
{
|
||||
let (id, appearance) = self.gallery_focus.take().unwrap();
|
||||
return cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(
|
||||
gallery_id(&id, appearance),
|
||||
),
|
||||
)
|
||||
.map(cosmic::Action::App);
|
||||
}
|
||||
@@ -532,12 +572,17 @@ impl cosmic::Application for App {
|
||||
Message::Release(field) => {
|
||||
if let Some(value) = self.ranges.remove(&field.path) {
|
||||
if field.path == "panels.0.background_opacity" {
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(value as f32 / 100.)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity(
|
||||
value as f32 / 100.,
|
||||
)));
|
||||
}
|
||||
if let Some(index) = field.path.strip_prefix("panels.0.corner_radius.")
|
||||
&& let Ok(index) = index.parse()
|
||||
{
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(index, value as f32)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetCorner(
|
||||
index,
|
||||
value as f32,
|
||||
)));
|
||||
}
|
||||
let integer = matches!(field.kind, Kind::Range { integer: true, .. });
|
||||
if let Some(key) = field.path.strip_prefix("panels.0.geometry.") {
|
||||
@@ -546,7 +591,10 @@ impl cosmic::Application for App {
|
||||
} else {
|
||||
value.into()
|
||||
};
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(key.into(), value)));
|
||||
return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry(
|
||||
key.into(),
|
||||
value,
|
||||
)));
|
||||
}
|
||||
return self.change(if integer {
|
||||
set(&field.path, value.round() as i64)
|
||||
@@ -575,7 +623,9 @@ impl cosmic::Application for App {
|
||||
match shlex::split(&value).filter(|v| !v.is_empty()) {
|
||||
Some(args) => set(
|
||||
&field.path,
|
||||
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>(),
|
||||
args.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
None => {
|
||||
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
|
||||
@@ -618,7 +668,9 @@ impl cosmic::Application for App {
|
||||
self.panel_group_selection = None;
|
||||
}
|
||||
Message::PanelPreviewResolved(resolution) => self.panel_preview_resolution = resolution,
|
||||
Message::PanelPreviewOverflow => self.panel_preview_overflow = !self.panel_preview_overflow,
|
||||
Message::PanelPreviewOverflow => {
|
||||
self.panel_preview_overflow = !self.panel_preview_overflow
|
||||
}
|
||||
Message::PanelPage(tab) => {
|
||||
self.panel_page = tab;
|
||||
return cosmic::iced::widget::scrollable::snap_to(
|
||||
@@ -632,13 +684,20 @@ impl cosmic::Application for App {
|
||||
self.ranges.retain(|path, _| !path.starts_with("panels."));
|
||||
let task = self.edit_many(edits);
|
||||
if let Ok(panel) = self.preview_panel() {
|
||||
if self.panel_selection.as_ref().is_some_and(|id| panel.item(id).is_none()) {
|
||||
if self
|
||||
.panel_selection
|
||||
.as_ref()
|
||||
.is_some_and(|id| panel.item(id).is_none())
|
||||
{
|
||||
self.panel_selection = None;
|
||||
}
|
||||
if self.panel_group_selection.as_ref().is_some_and(|id| {
|
||||
!panel_edit::Zone::ALL
|
||||
.into_iter()
|
||||
.any(|zone| zone.definition(&panel).groups.iter().any(|group| &group.id == id))
|
||||
!panel_edit::Zone::ALL.into_iter().any(|zone| {
|
||||
zone.definition(&panel)
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| &group.id == id)
|
||||
})
|
||||
}) {
|
||||
self.panel_group_selection = None;
|
||||
}
|
||||
@@ -661,7 +720,9 @@ impl cosmic::Application for App {
|
||||
Ok((snapshot, live)) => {
|
||||
self.undo = Some(std::mem::take(&mut self.saving_previous));
|
||||
self.current = snapshot;
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.draft = self.current.clone();
|
||||
|
||||
for edit in &self.pending {
|
||||
@@ -670,8 +731,10 @@ impl cosmic::Application for App {
|
||||
}
|
||||
}
|
||||
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
&self.resolved.families,
|
||||
&self.draft,
|
||||
);
|
||||
self.status = if live {
|
||||
"Saved · desktop updated"
|
||||
} else if self.path != store::config_path() {
|
||||
@@ -687,7 +750,12 @@ impl cosmic::Application for App {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
Message::ExpireUndo(revision)
|
||||
});
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail(), expire]);
|
||||
return Task::batch([
|
||||
self.update_theme(),
|
||||
self.flush(),
|
||||
self.load_thumbnail(),
|
||||
expire,
|
||||
]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -701,8 +769,11 @@ impl cosmic::Application for App {
|
||||
Ok(snapshot) => {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
@@ -735,7 +806,9 @@ impl cosmic::Application for App {
|
||||
let edits = visuals::choose_family(&id, appearance);
|
||||
let focus = if self.gallery_focus.is_none() {
|
||||
cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(
|
||||
&id, appearance,
|
||||
)),
|
||||
)
|
||||
.map(cosmic::Action::App)
|
||||
} else {
|
||||
@@ -748,7 +821,8 @@ impl cosmic::Application for App {
|
||||
Message::AutoAppearance(enabled) => {
|
||||
let mode = if enabled {
|
||||
"auto"
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light
|
||||
{
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
@@ -780,7 +854,8 @@ impl cosmic::Application for App {
|
||||
let directory = self.path.parent().map(ToOwned::to_owned);
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")? else {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut snapshot = Snapshot::parse(source)?;
|
||||
@@ -796,18 +871,26 @@ impl cosmic::Application for App {
|
||||
.collect();
|
||||
let mut id = format!("custom-{stem}");
|
||||
let mut suffix = 2;
|
||||
while snapshot.item(&format!("theme.custom_themes.{id}")).is_some() {
|
||||
while snapshot
|
||||
.item(&format!("theme.custom_themes.{id}"))
|
||||
.is_some()
|
||||
{
|
||||
id = format!("custom-{stem}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
|
||||
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
snapshot.edit(&set(
|
||||
&format!("theme.custom_themes.{id}.file"),
|
||||
path.as_str(),
|
||||
))?;
|
||||
let mut connection =
|
||||
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let value = connection.call(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
)?;
|
||||
let _: Vec<ferese_config::families::Family> =
|
||||
serde_json::from_value(value["families"].clone()).map_err(|e| e.to_string())?;
|
||||
serde_json::from_value(value["families"].clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Some((id, path)))
|
||||
})
|
||||
.await
|
||||
@@ -857,7 +940,9 @@ impl cosmic::Application for App {
|
||||
return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults());
|
||||
}
|
||||
Message::NewCommand(value) => self.new_command = value,
|
||||
Message::AddCommand if !self.saving => match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
|
||||
Message::AddCommand if !self.saving => match shlex::split(&self.new_command)
|
||||
.filter(|a| !a.is_empty())
|
||||
{
|
||||
Some(args) => {
|
||||
self.new_command.clear();
|
||||
return self.change(Edit::Add(
|
||||
@@ -865,14 +950,19 @@ impl cosmic::Application for App {
|
||||
vec![
|
||||
(
|
||||
"command".into(),
|
||||
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>().into(),
|
||||
args.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
),
|
||||
("enabled".into(), true.into()),
|
||||
("restart".into(), true.into()),
|
||||
],
|
||||
));
|
||||
}
|
||||
None => self.error = Some("Enter a program and arguments with balanced quotes.".into()),
|
||||
None => {
|
||||
self.error = Some("Enter a program and arguments with balanced quotes.".into())
|
||||
}
|
||||
},
|
||||
Message::AddSwipe(keys) if !self.saving => {
|
||||
let (action, argument) = match keys {
|
||||
@@ -881,7 +971,10 @@ impl cosmic::Application for App {
|
||||
"Swipe3Left" => ("focus", Some("right")),
|
||||
_ => ("focus", Some("left")),
|
||||
};
|
||||
let mut fields = vec![("keys".into(), keys.into()), ("action".into(), action.into())];
|
||||
let mut fields = vec![
|
||||
("keys".into(), keys.into()),
|
||||
("action".into(), action.into()),
|
||||
];
|
||||
|
||||
if let Some(argument) = argument {
|
||||
fields.push(("argument".into(), argument.into()));
|
||||
@@ -908,18 +1001,23 @@ impl cosmic::Application for App {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Rescaled(scale)) => {
|
||||
Some(Message::RenderScale(scale))
|
||||
}
|
||||
cosmic::iced::Event::PlatformSpecific(cosmic::iced::event::PlatformSpecific::Wayland(
|
||||
cosmic::iced::event::wayland::Event::Output(
|
||||
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_),
|
||||
_,
|
||||
cosmic::iced::Event::PlatformSpecific(
|
||||
cosmic::iced::event::PlatformSpecific::Wayland(
|
||||
cosmic::iced::event::wayland::Event::Output(
|
||||
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_),
|
||||
_,
|
||||
),
|
||||
),
|
||||
)) => Some(Message::ReadRenderScale(id)),
|
||||
) => Some(Message::ReadRenderScale(id)),
|
||||
_ => None,
|
||||
}),
|
||||
if self.sidebar_open {
|
||||
cosmic::iced::event::listen_with(|event, _, _| match event {
|
||||
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
key:
|
||||
cosmic::iced::keyboard::Key::Named(
|
||||
cosmic::iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
..
|
||||
}) => Some(Message::Sidebar(false)),
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Resized(size))
|
||||
@@ -933,9 +1031,11 @@ impl cosmic::Application for App {
|
||||
cosmic::iced::Subscription::none()
|
||||
},
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)),
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
ferese_theme_client::service::subscription()
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
if matches!(self.page, Page::Displays | Page::Bar) {
|
||||
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
|
||||
cosmic::iced::time::every(std::time::Duration::from_secs(2))
|
||||
.map(|_| Message::RefreshDisplays)
|
||||
} else {
|
||||
cosmic::iced::Subscription::none()
|
||||
},
|
||||
@@ -993,7 +1093,10 @@ impl App {
|
||||
|
||||
fn change(&mut self, edit: Edit) -> Task<Message> {
|
||||
if let Edit::Set(path, value) = &edit
|
||||
&& matches!(path.as_str(), "theme.background.path" | "theme.background.mode")
|
||||
&& matches!(
|
||||
path.as_str(),
|
||||
"theme.background.path" | "theme.background.mode"
|
||||
)
|
||||
{
|
||||
return self.apply_wallpaper_edits(wallpaper_controls::edits(
|
||||
wallpaper_controls::Target::Both,
|
||||
@@ -1093,7 +1196,8 @@ mod tests {
|
||||
let mut app = app();
|
||||
app.core.set_main_window_id(None);
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 0);
|
||||
app.core.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||
app.core
|
||||
.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||
assert_eq!(app.update(Message::DragWindow).units(), 1);
|
||||
}
|
||||
|
||||
@@ -1115,7 +1219,12 @@ mod tests {
|
||||
#[test]
|
||||
fn corner_sliders_hold_changes_until_release_and_preserve_other_corners() {
|
||||
let mut app = app();
|
||||
for edit in panel_edit::plan(&app.draft, panel_edit::Action::SetRadius("4px 8px 12px 16px".into())).unwrap() {
|
||||
for edit in panel_edit::plan(
|
||||
&app.draft,
|
||||
panel_edit::Action::SetRadius("4px 8px 12px 16px".into()),
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
app.draft.edit(&edit).unwrap();
|
||||
}
|
||||
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
|
||||
@@ -1139,7 +1248,8 @@ mod tests {
|
||||
assert_eq!(app.draft.source, before);
|
||||
let _ = app.update(Message::Release(field));
|
||||
let radii: ferese_panel::CornerRadii =
|
||||
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone())
|
||||
.unwrap();
|
||||
assert_eq!(radii.0, [4., 8., 23., 16.]);
|
||||
assert!(app.ranges.is_empty());
|
||||
assert_eq!(app.current.source, before);
|
||||
@@ -1150,7 +1260,9 @@ mod tests {
|
||||
use ferese_panel::{GroupId, ItemId};
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true; // An earlier save has not completed yet.
|
||||
@@ -1183,7 +1295,12 @@ mod tests {
|
||||
panels[0].start.groups[1].items[1].overflow,
|
||||
ferese_panel::OverflowPolicy::Always
|
||||
);
|
||||
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
|
||||
assert!(
|
||||
!panels[0].end.groups[0]
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1232,7 +1349,9 @@ mod tests {
|
||||
use ferese_panel::GroupId;
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true;
|
||||
@@ -1253,7 +1372,10 @@ mod tests {
|
||||
assert!(app.pending.is_empty());
|
||||
assert_eq!(app.draft.doc.value(), &expected);
|
||||
assert_eq!(app.panel_group_selection, Some(id));
|
||||
assert_eq!(app.draft.number("panels.0.start.groups.2.padding.1", 0.), 12.);
|
||||
assert_eq!(
|
||||
app.draft.number("panels.0.start.groups.2.padding.1", 0.),
|
||||
12.
|
||||
);
|
||||
let _ = app.page_view();
|
||||
}
|
||||
|
||||
@@ -1272,7 +1394,10 @@ mod tests {
|
||||
fn panel_pages_do_not_save_and_selection_opens_the_items_inspector() {
|
||||
let mut app = app();
|
||||
app.page = Page::Bar;
|
||||
for tab in [panel_controls::PanelPage::Arrange, panel_controls::PanelPage::Panels] {
|
||||
for tab in [
|
||||
panel_controls::PanelPage::Arrange,
|
||||
panel_controls::PanelPage::Panels,
|
||||
] {
|
||||
let _ = app.update(Message::PanelPage(tab));
|
||||
let _ = app.page_view();
|
||||
assert_eq!(app.panel_page, tab);
|
||||
@@ -1283,7 +1408,9 @@ mod tests {
|
||||
let _ = app.update(Message::PanelSelect(ferese_panel::ItemId("clock".into())));
|
||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||
assert!(app.panel_group_selection.is_none());
|
||||
let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId("status".into())));
|
||||
let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId(
|
||||
"status".into(),
|
||||
)));
|
||||
assert!(app.panel_selection.is_none());
|
||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||
}
|
||||
@@ -1293,7 +1420,9 @@ mod tests {
|
||||
use ferese_panel::{GroupId, ItemId};
|
||||
let mut app = app();
|
||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||
initial
|
||||
.edit(&panel_controls::initialize(&initial).unwrap())
|
||||
.unwrap();
|
||||
app.current = initial.clone();
|
||||
app.draft = initial.clone();
|
||||
app.saving = true;
|
||||
@@ -1314,22 +1443,30 @@ mod tests {
|
||||
let _ = app.update(Message::Saved(Ok((initial, false))));
|
||||
assert_eq!(app.draft.doc.value(), &expected);
|
||||
assert!(app.saving && app.pending.is_empty());
|
||||
assert_eq!(app.draft.string("panels.0.center.groups.1.items.0.id", ""), "network");
|
||||
assert_eq!(app.draft.string("panels.0.center.groups.1.items.1.id", ""), "clock");
|
||||
assert_eq!(
|
||||
app.draft.string("panels.0.center.groups.1.items.0.id", ""),
|
||||
"network"
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.string("panels.0.center.groups.1.items.1.id", ""),
|
||||
"clock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() {
|
||||
let mut app = app();
|
||||
app.path = store::config_path();
|
||||
app.current = Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap();
|
||||
app.current =
|
||||
Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap();
|
||||
app.resolved.presented.tokens.background.path = Some("/active.png".into());
|
||||
assert_eq!(app.active_wallpaper_path(), "/active.png");
|
||||
|
||||
app.page = Page::Wallpaper;
|
||||
let mut snapshot = app.resolved.clone();
|
||||
snapshot.presented.tokens.background.path =
|
||||
Some(ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into());
|
||||
snapshot.presented.tokens.background.path = Some(
|
||||
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into(),
|
||||
);
|
||||
let task = app.update(Message::ThemeChanged(Box::new(snapshot)));
|
||||
assert!(task.units() > 0);
|
||||
assert_eq!(
|
||||
@@ -1353,16 +1490,26 @@ mod tests {
|
||||
let mut app = app();
|
||||
app.saving = true;
|
||||
app.wallpaper.target = wallpaper_controls::Target::Light;
|
||||
app.draft = Snapshot::parse("theme { dark { background { path \"/custom-dark.png\"; }; }; }".into()).unwrap();
|
||||
app.inputs
|
||||
.insert("theme.light.background.path".into(), "/unfinished.png".into());
|
||||
let _ = app.update(Message::UseDefaultWallpaper(ferese_config::theme::Appearance::Light));
|
||||
app.draft = Snapshot::parse(
|
||||
"theme { dark { background { path \"/custom-dark.png\"; }; }; }".into(),
|
||||
)
|
||||
.unwrap();
|
||||
app.inputs.insert(
|
||||
"theme.light.background.path".into(),
|
||||
"/unfinished.png".into(),
|
||||
);
|
||||
let _ = app.update(Message::UseDefaultWallpaper(
|
||||
ferese_config::theme::Appearance::Light,
|
||||
));
|
||||
assert!(!app.inputs.contains_key("theme.light.background.path"));
|
||||
assert_eq!(
|
||||
app.draft.string("theme.light.background.path", ""),
|
||||
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light)
|
||||
);
|
||||
assert_eq!(app.draft.string("theme.dark.background.path", ""), "/custom-dark.png");
|
||||
assert_eq!(
|
||||
app.draft.string("theme.dark.background.path", ""),
|
||||
"/custom-dark.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1374,14 +1521,18 @@ mod tests {
|
||||
wallpaper_controls::Target::Light,
|
||||
Ok(Some("/chosen.png".into())),
|
||||
));
|
||||
assert_eq!(app.draft.string("theme.light.background.path", ""), "/chosen.png");
|
||||
assert_eq!(
|
||||
app.draft.string("theme.light.background.path", ""),
|
||||
"/chosen.png"
|
||||
);
|
||||
assert!(app.draft.item("theme.dark.background.path").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_build_controls_only_for_mounted_rows() {
|
||||
let mut app = shortcuts(100);
|
||||
app.visible_rows.extend((0..4).map(|index| ("bindings", index)));
|
||||
app.visible_rows
|
||||
.extend((0..4).map(|index| ("bindings", index)));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -1406,20 +1557,32 @@ mod tests {
|
||||
tree_nodes(&tree)
|
||||
};
|
||||
let visible = nodes(&app);
|
||||
app.visible_rows.extend((0..100).map(|index| ("bindings", index)));
|
||||
app.visible_rows
|
||||
.extend((0..100).map(|index| ("bindings", index)));
|
||||
let eager = nodes(&app);
|
||||
assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}");
|
||||
assert!(
|
||||
eager > visible + 96 * 10,
|
||||
"visible={visible}, eager={eager}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_rows_keep_unsaved_text_mounted_until_unfocus() {
|
||||
let mut app = shortcuts(10);
|
||||
let _ = app.update(Message::RowVisibility("bindings", 5, true));
|
||||
let _ = app.update(Message::Draft("bindings.5.argument".into(), "my command".into()));
|
||||
let _ = app.update(Message::Draft(
|
||||
"bindings.5.argument".into(),
|
||||
"my command".into(),
|
||||
));
|
||||
let _ = app.update(Message::RowVisibility("bindings", 5, false));
|
||||
assert!(app.visible_rows.contains(&("bindings", 5)));
|
||||
assert_eq!(app.inputs["bindings.5.argument"], "my command");
|
||||
let _ = app.update(Message::Commit(schema::text("bindings.5.argument", "Argument", "", "")));
|
||||
let _ = app.update(Message::Commit(schema::text(
|
||||
"bindings.5.argument",
|
||||
"Argument",
|
||||
"",
|
||||
"",
|
||||
)));
|
||||
assert!(!app.visible_rows.contains(&("bindings", 5)));
|
||||
assert_eq!(app.draft.string("bindings.5.argument", ""), "my command");
|
||||
}
|
||||
@@ -1484,7 +1647,8 @@ mod tests {
|
||||
let registrations = (0..7)
|
||||
.map(|index| format!("personal-{index} {{ file \"unused.kdl\"; }}\n"))
|
||||
.collect::<String>();
|
||||
app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}")).unwrap();
|
||||
app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}"))
|
||||
.unwrap();
|
||||
let mut snapshot = app.resolved.clone();
|
||||
for index in 0..7 {
|
||||
let mut family = snapshot.families[0].clone();
|
||||
@@ -1503,7 +1667,10 @@ mod tests {
|
||||
2,
|
||||
true,
|
||||
));
|
||||
assert!(app.gallery_focus.is_some(), "built-in row must not claim custom focus");
|
||||
assert!(
|
||||
app.gallery_focus.is_some(),
|
||||
"built-in row must not claim custom focus"
|
||||
);
|
||||
let task = app.update(Message::RowVisibility(list, 2, true));
|
||||
assert!(task.units() > 0);
|
||||
assert!(app.gallery_focus.is_none());
|
||||
@@ -1536,7 +1703,9 @@ mod tests {
|
||||
for _ in 0..100 {
|
||||
let started = std::time::Instant::now();
|
||||
let view = app.page_view();
|
||||
std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(view.as_widget()));
|
||||
std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(
|
||||
view.as_widget(),
|
||||
));
|
||||
samples.push(started.elapsed());
|
||||
}
|
||||
samples.sort_unstable();
|
||||
@@ -1553,7 +1722,9 @@ mod tests {
|
||||
#[ignore = "release scroll/render sample; requires both renderer backends"]
|
||||
fn profile_dense_pages_on_both_renderers() {
|
||||
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree};
|
||||
use cosmic::iced::advanced::{
|
||||
Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree,
|
||||
};
|
||||
use cosmic::iced::{Color, Event, Font, Pixels, Point, Rectangle, Size};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -1589,9 +1760,11 @@ mod tests {
|
||||
let mut view = app.page_view();
|
||||
let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Mouse(mouse::Event::WheelScrolled {
|
||||
@@ -1626,7 +1799,12 @@ mod tests {
|
||||
let draw_elapsed = draw_started.elapsed();
|
||||
drop(view);
|
||||
let render_started = std::time::Instant::now();
|
||||
let pixels = Headless::screenshot(&mut renderer, size, scale, theme.cosmic().bg_color().into());
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
size,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
std::hint::black_box(pixels);
|
||||
let render_elapsed = render_started.elapsed();
|
||||
|
||||
@@ -1643,7 +1821,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)] {
|
||||
for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)]
|
||||
{
|
||||
let mut times = samples
|
||||
.iter()
|
||||
.map(|sample| match index {
|
||||
@@ -1722,7 +1901,8 @@ mod tests {
|
||||
let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
|
||||
assert_eq!(app.current.source, external.source);
|
||||
assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
|
||||
app.inputs.insert("theme.colors.accent".into(), "#ffffff".into());
|
||||
app.inputs
|
||||
.insert("theme.colors.accent".into(), "#ffffff".into());
|
||||
let newer = Snapshot::parse("animations {\n speed 0.8\n}\n".into()).unwrap();
|
||||
let _ = app.update(Message::ExternalConfig(Ok(newer)));
|
||||
assert_eq!(app.current.source, external.source);
|
||||
|
||||
@@ -2,7 +2,10 @@ use cosmic::iced::advanced::widget::Id;
|
||||
use cosmic::iced::advanced::widget::operation::{Operation, Scrollable};
|
||||
use cosmic::iced::{Rectangle, Vector};
|
||||
|
||||
pub(super) fn gallery_list(appearance: Option<ferese_config::theme::Appearance>, custom: bool) -> &'static str {
|
||||
pub(super) fn gallery_list(
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
custom: bool,
|
||||
) -> &'static str {
|
||||
match (appearance, custom) {
|
||||
(Some(ferese_config::theme::Appearance::Light), false) => "theme-light",
|
||||
(Some(ferese_config::theme::Appearance::Dark), false) => "theme-dark",
|
||||
@@ -28,7 +31,10 @@ impl RevealRow {
|
||||
}
|
||||
|
||||
pub(super) fn for_target(target: Id) -> Self {
|
||||
Self { target, bounds: None }
|
||||
Self {
|
||||
target,
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row, schema, scrollable,
|
||||
text_input, visuals, widget,
|
||||
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row,
|
||||
schema, scrollable, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
|
||||
|
||||
impl App {
|
||||
pub(super) fn page_view(&self) -> Element<'_, Message> {
|
||||
cosmic::iced::widget::responsive(move |size| self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
cosmic::iced::widget::responsive(move |size| {
|
||||
self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height)
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn page_layout(&self, compact: bool, height: f32) -> Element<'_, Message> {
|
||||
@@ -53,27 +55,36 @@ impl App {
|
||||
let mut navigation = column([]).spacing(16);
|
||||
for (title, pages) in Page::NAVIGATION {
|
||||
let mut section = column([]).spacing(3).push(
|
||||
container(self.label(title, 11.).class(cosmic::theme::Text::Color(palette.muted))).padding([0, 8]),
|
||||
container(
|
||||
self.label(title, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
)
|
||||
.padding([0, 8]),
|
||||
);
|
||||
for &page in pages {
|
||||
let selected = self.page.navigation_page() == page;
|
||||
section = section.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::icon(
|
||||
page,
|
||||
if selected { palette.accent } else { palette.muted },
|
||||
))
|
||||
.push(self.label(page.title(), 13.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 8])
|
||||
.class(visuals::navigation_style(palette, selected))
|
||||
.name(page.title())
|
||||
.on_press(Message::Page(if selected { self.page } else { page })),
|
||||
);
|
||||
section =
|
||||
section.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(visuals::icon(
|
||||
page,
|
||||
if selected {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.muted
|
||||
},
|
||||
))
|
||||
.push(self.label(page.title(), 13.)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([8, 8])
|
||||
.class(visuals::navigation_style(palette, selected))
|
||||
.name(page.title())
|
||||
.on_press(Message::Page(if selected { self.page } else { page })),
|
||||
);
|
||||
}
|
||||
navigation = navigation.push(section);
|
||||
}
|
||||
@@ -169,7 +180,8 @@ impl App {
|
||||
.flat_map(|(_, pages)| pages.iter().copied())
|
||||
};
|
||||
if !pages().any(|p| p.matches(&self.search)) {
|
||||
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
|
||||
body = body
|
||||
.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
|
||||
}
|
||||
for page in pages().filter(|p| p.matches(&self.search)) {
|
||||
let destination = page.search_destination(&self.search);
|
||||
@@ -188,13 +200,18 @@ impl App {
|
||||
}
|
||||
} else {
|
||||
if self.page == Page::Windows {
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
body = body.push(visuals::preview_resolved(
|
||||
&self.draft,
|
||||
&self.resolved.presented,
|
||||
));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
body = body
|
||||
.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body =
|
||||
body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
@@ -213,7 +230,9 @@ impl App {
|
||||
if self.page == Page::Wallpaper {
|
||||
body = body.push(self.wallpaper_controls());
|
||||
}
|
||||
if self.page == Page::Bar && self.panel_page != crate::panel_controls::PanelPage::Arrange {
|
||||
if self.page == Page::Bar
|
||||
&& self.panel_page != crate::panel_controls::PanelPage::Arrange
|
||||
{
|
||||
body = body.push(self.panel_controls());
|
||||
}
|
||||
let fields = if self.page == Page::Bar {
|
||||
@@ -269,7 +288,11 @@ impl App {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(4)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
}
|
||||
|
||||
match self.page {
|
||||
@@ -313,14 +336,21 @@ impl App {
|
||||
Kind::Toggle(true),
|
||||
)))
|
||||
.push(
|
||||
container(self.settings_icon_button(
|
||||
"Remove login item",
|
||||
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
|
||||
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
|
||||
))
|
||||
container(
|
||||
self.settings_icon_button(
|
||||
"Remove login item",
|
||||
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
|
||||
(!self.saving)
|
||||
.then_some(Message::Remove("autostart".into(), index)),
|
||||
),
|
||||
)
|
||||
.padding([7, 10]),
|
||||
);
|
||||
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(4)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
}
|
||||
body = body.push(
|
||||
row([])
|
||||
@@ -333,12 +363,15 @@ impl App {
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(Message::NewCommand),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Add login item",
|
||||
"M12 5v14 M5 12h14",
|
||||
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
|
||||
false,
|
||||
)),
|
||||
.push(
|
||||
self.settings_button(
|
||||
"Add login item",
|
||||
"M12 5v14 M5 12h14",
|
||||
(!self.saving && !self.new_command.trim().is_empty())
|
||||
.then_some(Message::AddCommand),
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Page::Shortcuts => {
|
||||
@@ -373,36 +406,37 @@ impl App {
|
||||
body = body.push(gestures);
|
||||
|
||||
for index in 0..self.draft.records("bindings") {
|
||||
let content: Element<'static, Message> = if self.visible_rows.contains(&("bindings", index)) {
|
||||
let prefix = format!("bindings.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.keys"),
|
||||
"Shortcut",
|
||||
"Keys or swipe, such as Super+Return",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.action"),
|
||||
"Action",
|
||||
"Action name, such as spawn or focus",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.argument"),
|
||||
"Argument",
|
||||
"Command, direction, or action argument",
|
||||
"",
|
||||
)));
|
||||
container(group)
|
||||
.padding(4)
|
||||
.height(164)
|
||||
.class(visuals::surface(palette.card, 14.))
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().height(164).width(Length::Fill).into()
|
||||
};
|
||||
let content: Element<'static, Message> =
|
||||
if self.visible_rows.contains(&("bindings", index)) {
|
||||
let prefix = format!("bindings.{index}");
|
||||
let group = column([])
|
||||
.spacing(1)
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.keys"),
|
||||
"Shortcut",
|
||||
"Keys or swipe, such as Super+Return",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.action"),
|
||||
"Action",
|
||||
"Action name, such as spawn or focus",
|
||||
"",
|
||||
)))
|
||||
.push(self.field(schema::text(
|
||||
format!("{prefix}.argument"),
|
||||
"Argument",
|
||||
"Command, direction, or action argument",
|
||||
"",
|
||||
)));
|
||||
container(group)
|
||||
.padding(4)
|
||||
.height(164)
|
||||
.class(visuals::surface(palette.card, 14.))
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().height(164).width(Length::Fill).into()
|
||||
};
|
||||
|
||||
body = body.push(
|
||||
cosmic::iced::widget::sensor(content)
|
||||
@@ -481,14 +515,17 @@ impl App {
|
||||
} else {
|
||||
content = content.push(
|
||||
scrollable(
|
||||
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 0.,
|
||||
right: 12.,
|
||||
bottom: 0.,
|
||||
left: 2.,
|
||||
})
|
||||
.width(Length::Fill),
|
||||
container(cosmic::iced::widget::keyed_column([(
|
||||
self.page as usize,
|
||||
body.into(),
|
||||
)]))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 0.,
|
||||
right: 12.,
|
||||
bottom: 0.,
|
||||
left: 2.,
|
||||
})
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.id(widget::Id::new("settings-content"))
|
||||
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
|
||||
@@ -511,7 +548,14 @@ impl App {
|
||||
},
|
||||
palette.muted,
|
||||
),
|
||||
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
|
||||
self.label(
|
||||
if self.saving {
|
||||
"Saving…"
|
||||
} else {
|
||||
&self.status
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Top,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
@@ -546,7 +590,11 @@ impl App {
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.note(if self.saving { "Saving…" } else { &self.status }))
|
||||
.push(self.note(if self.saving {
|
||||
"Saving…"
|
||||
} else {
|
||||
&self.status
|
||||
}))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
.push(self.settings_button(
|
||||
"Undo",
|
||||
@@ -577,16 +625,21 @@ impl App {
|
||||
};
|
||||
let view = row([]).push(sidebar).push(
|
||||
container(
|
||||
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 20.,
|
||||
right: 20.,
|
||||
bottom: 8.,
|
||||
left: 20.,
|
||||
})
|
||||
.max_width(if self.page == Page::Bar { 1120 } else { 840 })
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
container(
|
||||
column([])
|
||||
.push(content.height(Length::Fill))
|
||||
.push(footer)
|
||||
.spacing(6),
|
||||
)
|
||||
.padding(cosmic::iced::Padding {
|
||||
top: 20.,
|
||||
right: 20.,
|
||||
bottom: 8.,
|
||||
left: 20.,
|
||||
})
|
||||
.max_width(if self.page == Page::Bar { 1120 } else { 840 })
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
@@ -598,13 +651,28 @@ impl App {
|
||||
let mut view = widget::popover(view).modal(true);
|
||||
if let Some(sidebar) = drawer {
|
||||
let dismiss = widget::mouse_area(
|
||||
container(widget::Space::new().width(Length::Fill).height(Length::Fill))
|
||||
.class(visuals::surface(cosmic::iced::Color::BLACK.scale_alpha(0.20), 0.)),
|
||||
container(
|
||||
widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.class(visuals::surface(
|
||||
cosmic::iced::Color::BLACK.scale_alpha(0.20),
|
||||
0.,
|
||||
)),
|
||||
)
|
||||
.on_press(Message::Sidebar(false));
|
||||
view = view
|
||||
.position(widget::popover::Position::Point(cosmic::iced::Point::ORIGIN))
|
||||
.popup(row([]).push(sidebar).push(dismiss).width(Length::Fill).height(height));
|
||||
.position(widget::popover::Position::Point(
|
||||
cosmic::iced::Point::ORIGIN,
|
||||
))
|
||||
.popup(
|
||||
row([])
|
||||
.push(sidebar)
|
||||
.push(dismiss)
|
||||
.width(Length::Fill)
|
||||
.height(height),
|
||||
);
|
||||
}
|
||||
let view: Element<'_, Message> = view.into();
|
||||
if self.profile_pages {
|
||||
|
||||
@@ -69,59 +69,68 @@ impl App {
|
||||
let mut rows = widget::column([]).spacing(12);
|
||||
match self.panel_page {
|
||||
PanelPage::Panels => {
|
||||
rows = rows.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.spacing(12)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.push(crate::visuals::action_icon(
|
||||
if edge == Edge::Top {
|
||||
"M3 5h18v14H3z M3 9h18"
|
||||
} else {
|
||||
"M3 5h18v14H3z M3 15h18"
|
||||
},
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
widget::column([])
|
||||
.spacing(3)
|
||||
.push(self.panel_heading(
|
||||
if edge == Edge::Top { "Top panel" } else { "Bottom panel" },
|
||||
14.,
|
||||
))
|
||||
.push(self.note("Shown on all displays"))
|
||||
.width(cosmic::iced::Length::Fill),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Edit panel",
|
||||
"M4 20l4-1 12-12-3-3L5 16z",
|
||||
Some(Message::PanelPage(PanelPage::Arrange)),
|
||||
false,
|
||||
))
|
||||
.push(
|
||||
self.settings_icon_button(
|
||||
rows =
|
||||
rows.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.spacing(12)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.push(crate::visuals::action_icon(
|
||||
if edge == Edge::Top {
|
||||
"M3 5h18v14H3z M3 9h18"
|
||||
} else {
|
||||
"M3 5h18v14H3z M3 15h18"
|
||||
},
|
||||
palette.accent,
|
||||
))
|
||||
.push(
|
||||
widget::column([])
|
||||
.spacing(3)
|
||||
.push(self.panel_heading(
|
||||
if edge == Edge::Top {
|
||||
"Top panel"
|
||||
} else {
|
||||
"Bottom panel"
|
||||
},
|
||||
14.,
|
||||
))
|
||||
.push(self.note("Shown on all displays"))
|
||||
.width(cosmic::iced::Length::Fill),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
"Edit panel",
|
||||
"M4 20l4-1 12-12-3-3L5 16z",
|
||||
Some(Message::PanelPage(PanelPage::Arrange)),
|
||||
false,
|
||||
))
|
||||
.push(self.settings_icon_button(
|
||||
"Remove custom panel",
|
||||
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5",
|
||||
self.draft
|
||||
.item("panels")
|
||||
.is_some()
|
||||
.then(|| Message::Change(store::Edit::Unset("panels".into()))),
|
||||
),
|
||||
),
|
||||
)
|
||||
.padding(14)
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
self.draft.item("panels").is_some().then(|| {
|
||||
Message::Change(store::Edit::Unset("panels".into()))
|
||||
}),
|
||||
)),
|
||||
)
|
||||
.padding(14)
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
|
||||
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
|
||||
let label = if position == Edge::Top { "Top" } else { "Bottom" };
|
||||
let label = if position == Edge::Top {
|
||||
"Top"
|
||||
} else {
|
||||
"Bottom"
|
||||
};
|
||||
widget::button::custom(self.label(label, 12.))
|
||||
.name(format!("Panel position: {label}"))
|
||||
.padding([8, 10])
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, edge == position))
|
||||
.on_press_maybe((edge != position).then_some(Message::PanelEdit(Action::SetEdge(position))))
|
||||
.on_press_maybe(
|
||||
(edge != position)
|
||||
.then_some(Message::PanelEdit(Action::SetEdge(position))),
|
||||
)
|
||||
.into()
|
||||
}))
|
||||
.spacing(2);
|
||||
@@ -151,11 +160,14 @@ impl App {
|
||||
"Outline",
|
||||
"Add a fine line around the panel shape.",
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
|
||||
.name("Outline")
|
||||
.on_press(Message::PanelEdit(Action::SetBorder(
|
||||
!self.draft.boolean("panels.0.border", false),
|
||||
))),
|
||||
ferese_theme::controls::switch(
|
||||
self.draft.boolean("panels.0.border", false),
|
||||
palette,
|
||||
)
|
||||
.name("Outline")
|
||||
.on_press(Message::PanelEdit(Action::SetBorder(
|
||||
!self.draft.boolean("panels.0.border", false),
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -179,7 +191,9 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(attached, palette)
|
||||
.name("Attach to screen edge")
|
||||
.on_press(Message::PanelEdit(Action::SetMorphAttached(!attached))),
|
||||
.on_press(Message::PanelEdit(Action::SetMorphAttached(
|
||||
!attached,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -193,7 +207,9 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name("Preview on hover")
|
||||
.on_press(Message::PanelEdit(Action::SetPeekOnHover(!enabled))),
|
||||
.on_press(Message::PanelEdit(Action::SetPeekOnHover(
|
||||
!enabled,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -206,10 +222,9 @@ impl App {
|
||||
.padding([8, 10])
|
||||
.width(cosmic::iced::Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, count == maximum))
|
||||
.on_press_maybe(
|
||||
(count != maximum)
|
||||
.then(|| Message::Change(store::set("activities.max_visible", count))),
|
||||
)
|
||||
.on_press_maybe((count != maximum).then(|| {
|
||||
Message::Change(store::set("activities.max_visible", count))
|
||||
}))
|
||||
.into()
|
||||
}))
|
||||
.spacing(2);
|
||||
@@ -232,7 +247,10 @@ impl App {
|
||||
widget::container(
|
||||
ferese_theme::controls::switch(show_badge, palette)
|
||||
.name("Show activity count")
|
||||
.on_press(Message::Change(store::set("activities.show_badge", !show_badge))),
|
||||
.on_press(Message::Change(store::set(
|
||||
"activities.show_badge",
|
||||
!show_badge,
|
||||
))),
|
||||
)
|
||||
.align_right(cosmic::iced::Length::Fill)
|
||||
.into(),
|
||||
@@ -246,7 +264,8 @@ impl App {
|
||||
.class(crate::visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
let pins = self.preview_panel().is_ok_and(|panel| {
|
||||
panel.geometry != ferese_panel::GeometryIntent::default() || panel.corner_radius.is_some()
|
||||
panel.geometry != ferese_panel::GeometryIntent::default()
|
||||
|| panel.corner_radius.is_some()
|
||||
});
|
||||
let mut settings = widget::column([]).spacing(4).push(
|
||||
widget::container(
|
||||
@@ -277,12 +296,18 @@ impl App {
|
||||
!matches!(
|
||||
preset.unwrap_or_default(),
|
||||
ferese_panel::PanelStyle::Flat | ferese_panel::PanelStyle::Notch
|
||||
) || !matches!(field.path.rsplit('.').next(), Some("edge_margin" | "side_margins"))
|
||||
) || !matches!(
|
||||
field.path.rsplit('.').next(),
|
||||
Some("edge_margin" | "side_margins")
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut field = field;
|
||||
if preset == Some(ferese_panel::PanelStyle::Morph) {
|
||||
if matches!(field.path.rsplit('.').next(), Some("inner_padding" | "side_margins")) {
|
||||
if matches!(
|
||||
field.path.rsplit('.').next(),
|
||||
Some("inner_padding" | "side_margins")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if field.path.ends_with(".height") {
|
||||
@@ -296,15 +321,23 @@ impl App {
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap();
|
||||
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
|
||||
*default = geometry[field.path.rsplit('.').next().unwrap()]
|
||||
.as_f64()
|
||||
.unwrap();
|
||||
}
|
||||
let pinned = self.draft.item(&field.path).is_some();
|
||||
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(field.path.clone())));
|
||||
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(
|
||||
field.path.clone(),
|
||||
)));
|
||||
let reset_label = format!("Reset {}", field.label);
|
||||
settings = settings.push(
|
||||
widget::row![
|
||||
self.field(field),
|
||||
self.settings_icon_button(&reset_label, "M3 12a9 9 0 1 0 3-6 M3 3v6h6", reset)
|
||||
self.settings_icon_button(
|
||||
&reset_label,
|
||||
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
|
||||
reset
|
||||
)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
@@ -381,7 +414,10 @@ impl App {
|
||||
|
||||
fn panel_opacity_controls(&self) -> Element<'static, Message> {
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
if self.draft.boolean("theme.accessibility.reduce_transparency", false) {
|
||||
if self
|
||||
.draft
|
||||
.boolean("theme.accessibility.reduce_transparency", false)
|
||||
{
|
||||
return self.panel_appearance_row(
|
||||
"Background opacity",
|
||||
"Reduce transparency is on. Turn it off in Accessibility to change opacity.",
|
||||
@@ -424,16 +460,21 @@ impl App {
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
crate::slider(0. ..=100., value, move |value| Message::Range(field.clone(), value))
|
||||
.step(1.)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(Length::Fill),
|
||||
crate::slider(0. ..=100., value, move |value| {
|
||||
Message::Range(field.clone(), value)
|
||||
})
|
||||
.step(1.)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
widget::container(self.label(format!("{value:.0}%"), 12.).align_x(Alignment::Center))
|
||||
.padding([6, 0])
|
||||
.width(48),
|
||||
widget::container(
|
||||
self.label(format!("{value:.0}%"), 12.)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding([6, 0])
|
||||
.width(48),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
"Use shell opacity",
|
||||
@@ -458,26 +499,30 @@ impl App {
|
||||
.preview_panel()
|
||||
.map(|panel| panel.resolved_geometry().height * 0.5)
|
||||
.unwrap_or(18.);
|
||||
let custom = self
|
||||
.draft
|
||||
.item("panels.0.corner_radius")
|
||||
.and_then(|value| serde_json::from_value::<ferese_panel::CornerRadii>(value.clone()).ok());
|
||||
let custom = self.draft.item("panels.0.corner_radius").and_then(|value| {
|
||||
serde_json::from_value::<ferese_panel::CornerRadii>(value.clone()).ok()
|
||||
});
|
||||
let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius));
|
||||
let modes = widget::row([("Automatic", false), ("Custom", true)].map(|(label, selected)| {
|
||||
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
||||
.name(format!("Corner radius: {label}"))
|
||||
.padding([8, 12])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, custom.is_some() == selected))
|
||||
.on_press_maybe((custom.is_some() != selected).then(|| {
|
||||
Message::PanelEdit(if selected {
|
||||
Action::SetRadius(format!("{shell_radius}px"))
|
||||
} else {
|
||||
Action::ClearRadius
|
||||
})
|
||||
}))
|
||||
.into()
|
||||
}))
|
||||
let modes = widget::row([("Automatic", false), ("Custom", true)].map(
|
||||
|(label, selected)| {
|
||||
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
||||
.name(format!("Corner radius: {label}"))
|
||||
.padding([8, 12])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(
|
||||
palette,
|
||||
custom.is_some() == selected,
|
||||
))
|
||||
.on_press_maybe((custom.is_some() != selected).then(|| {
|
||||
Message::PanelEdit(if selected {
|
||||
Action::SetRadius(format!("{shell_radius}px"))
|
||||
} else {
|
||||
Action::ClearRadius
|
||||
})
|
||||
}))
|
||||
.into()
|
||||
},
|
||||
))
|
||||
.spacing(2);
|
||||
let modes = widget::container(modes)
|
||||
.padding(3)
|
||||
@@ -491,7 +536,11 @@ impl App {
|
||||
(2, "Outer right", "M5 19h10a4 4 0 0 0 4-4V5"),
|
||||
] {
|
||||
let value = radii.0[index];
|
||||
let tint = if custom.is_some() { palette.text } else { palette.muted };
|
||||
let tint = if custom.is_some() {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted
|
||||
};
|
||||
corners.push(
|
||||
widget::container(
|
||||
widget::row([])
|
||||
@@ -499,14 +548,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(crate::visuals::action_icon(icon, palette.muted))
|
||||
.push(self.label(label, 12.).width(Length::Fill))
|
||||
.push(
|
||||
self.settings_icon_button(
|
||||
&format!("Decrease {label} radius"),
|
||||
"M5 12h14",
|
||||
(custom.is_some() && value > 0.)
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(index, (value - 1.).max(0.)))),
|
||||
),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
&format!("Decrease {label} radius"),
|
||||
"M5 12h14",
|
||||
(custom.is_some() && value > 0.).then_some(Message::PanelEdit(
|
||||
Action::SetCorner(index, (value - 1.).max(0.)),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
self.label(format!("{value} px"), 12.)
|
||||
.width(52)
|
||||
@@ -519,7 +567,10 @@ impl App {
|
||||
"M12 5v14 M5 12h14",
|
||||
custom
|
||||
.is_some()
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(index, value + 1.))),
|
||||
.then_some(Message::PanelEdit(Action::SetCorner(
|
||||
index,
|
||||
value + 1.,
|
||||
))),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -637,7 +688,10 @@ impl App {
|
||||
zone: placement.key(),
|
||||
group: group.id.clone(),
|
||||
},
|
||||
format!("panels.0.{}.groups.{group_index}.items.{item_index}", placement.key()),
|
||||
format!(
|
||||
"panels.0.{}.groups.{group_index}.items.{item_index}",
|
||||
placement.key()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -704,14 +758,20 @@ impl App {
|
||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut zones = widget::column([]).spacing(18);
|
||||
if panel.group_count() == ferese_panel::MAX_GROUPS {
|
||||
zones = zones.push(self.label(format!("{}-group limit reached", ferese_panel::MAX_GROUPS), 12.));
|
||||
zones = zones.push(self.label(
|
||||
format!("{}-group limit reached", ferese_panel::MAX_GROUPS),
|
||||
12.,
|
||||
));
|
||||
}
|
||||
for placement in Zone::ALL {
|
||||
let zone = placement.definition(panel);
|
||||
let mut groups = widget::column([]).spacing(8).push(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.panel_heading(placement.to_string(), 13.).width(Length::Fill))
|
||||
.push(
|
||||
self.panel_heading(placement.to_string(), 13.)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(
|
||||
self.settings_button(
|
||||
"Add group",
|
||||
@@ -733,7 +793,10 @@ impl App {
|
||||
widget::row([])
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.push(crate::visuals::action_icon("M3 7h18v13H3z M3 7V4h7l3 3", palette.muted))
|
||||
.push(crate::visuals::action_icon(
|
||||
"M3 7h18v13H3z M3 7V4h7l3 3",
|
||||
palette.muted,
|
||||
))
|
||||
.push(self.label(group_name(&group.id.0), 13.)),
|
||||
)
|
||||
.name(format!("Edit group {}", group.id.0))
|
||||
@@ -746,9 +809,11 @@ impl App {
|
||||
.on_press(Message::PanelGroupSelect(group.id.clone())),
|
||||
);
|
||||
let add_item = crate::visuals::panel_select(
|
||||
cosmic::iced::widget::pick_list(CATALOG.to_vec(), None::<CatalogItem>, move |kind| {
|
||||
Message::PanelEdit(Action::Add(kind.0, destination.clone()))
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
CATALOG.to_vec(),
|
||||
None::<CatalogItem>,
|
||||
move |kind| Message::PanelEdit(Action::Add(kind.0, destination.clone())),
|
||||
)
|
||||
.placeholder("Add item")
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
@@ -776,13 +841,15 @@ impl App {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
},
|
||||
))
|
||||
.push(self.label(item.kind.label(), 13.).width(Length::Fill).class(
|
||||
cosmic::theme::Text::Color(if item.visible {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
}),
|
||||
)),
|
||||
.push(
|
||||
self.label(item.kind.label(), 13.)
|
||||
.width(Length::Fill)
|
||||
.class(cosmic::theme::Text::Color(if item.visible {
|
||||
palette.text
|
||||
} else {
|
||||
palette.muted.scale_alpha(0.6)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.name(format!("Edit {} · {}", item.kind.label(), item.id.0))
|
||||
.padding([8, 6])
|
||||
@@ -796,7 +863,10 @@ impl App {
|
||||
widget::container(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(widget::mouse_area(grip).interaction(cosmic::iced::mouse::Interaction::Grab))
|
||||
.push(
|
||||
widget::mouse_area(grip)
|
||||
.interaction(cosmic::iced::mouse::Interaction::Grab),
|
||||
)
|
||||
.push(select),
|
||||
)
|
||||
.id(crate::panel_drag::item_id(&item.id))
|
||||
@@ -806,7 +876,8 @@ impl App {
|
||||
}
|
||||
let mut contents = widget::column([]).spacing(4).push(heading);
|
||||
if items.is_empty() {
|
||||
contents = contents.push(widget::container(self.note("Drop items here")).padding([8, 6]));
|
||||
contents = contents
|
||||
.push(widget::container(self.note("Drop items here")).padding([8, 6]));
|
||||
} else {
|
||||
contents = contents.push(
|
||||
widget::flex_row(items)
|
||||
@@ -854,7 +925,11 @@ impl App {
|
||||
.class(crate::visuals::surface(palette.card, 10.)),
|
||||
);
|
||||
}
|
||||
zones = zones.push(widget::container(groups).padding([0, 4]).width(Length::Fill));
|
||||
zones = zones.push(
|
||||
widget::container(groups)
|
||||
.padding([0, 4])
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
crate::panel_drag::frame(zones.into(), panel, palette.accent)
|
||||
}
|
||||
@@ -899,7 +974,11 @@ mod tests {
|
||||
use cosmic::Application;
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/unused/range.kdl".into(), store::Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/range.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
assert_eq!(app.panel_side_margin_limit(), None);
|
||||
@@ -928,19 +1007,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn customization_uses_canonical_defaults_and_retains_other_fields() {
|
||||
let mut snapshot = store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
|
||||
let mut snapshot =
|
||||
store::Snapshot::parse("// keep\nanimations { speed 0.8; }\n".into()).unwrap();
|
||||
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
|
||||
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Flat));
|
||||
assert_eq!(panels[0].resolved_geometry(), ferese_panel::PanelGeometry::default());
|
||||
assert_eq!(
|
||||
panels[0].resolved_geometry(),
|
||||
ferese_panel::PanelGeometry::default()
|
||||
);
|
||||
assert!(panels[0].center.groups[0].items[0].visible);
|
||||
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
||||
assert!(snapshot.source.contains("// keep"));
|
||||
let id = snapshot.string("panels.0.end.groups.0.items.2.id", "");
|
||||
let mut changed = snapshot;
|
||||
changed
|
||||
.edit(&store::set("panels.0.end.groups.0.items.2.overflow", "always"))
|
||||
.edit(&store::set(
|
||||
"panels.0.end.groups.0.items.2.overflow",
|
||||
"always",
|
||||
))
|
||||
.unwrap();
|
||||
let changed = store::Snapshot::parse(changed.source).unwrap();
|
||||
assert_eq!(changed.string("panels.0.end.groups.0.items.2.id", ""), id);
|
||||
@@ -978,7 +1065,11 @@ mod tests {
|
||||
.unwrap();
|
||||
let app = App::init(
|
||||
crate::Core::default(),
|
||||
("/unused/editor.kdl".into(), store::Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/editor.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
let panel = Panel::default();
|
||||
|
||||
@@ -27,7 +27,11 @@ enum Region {
|
||||
Empty(Zone),
|
||||
}
|
||||
|
||||
pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Color) -> Element<'a, Message> {
|
||||
pub(super) fn frame<'a>(
|
||||
content: Element<'a, Message>,
|
||||
panel: &Panel,
|
||||
accent: Color,
|
||||
) -> Element<'a, Message> {
|
||||
let mut regions = Vec::new();
|
||||
for zone in Zone::ALL {
|
||||
if zone.definition(panel).groups.is_empty() {
|
||||
@@ -40,7 +44,10 @@ pub(super) fn frame<'a>(content: Element<'a, Message>, panel: &Panel, accent: Co
|
||||
};
|
||||
regions.push((group_id(&group.id), Region::Group(destination.clone())));
|
||||
for item in &group.items {
|
||||
regions.push((item_id(&item.id), Region::Item(item.id.clone(), destination.clone())));
|
||||
regions.push((
|
||||
item_id(&item.id),
|
||||
Region::Item(item.id.clone(), destination.clone()),
|
||||
));
|
||||
regions.push((grip_id(&item.id), Region::Grip(item.id.clone())));
|
||||
}
|
||||
}
|
||||
@@ -84,7 +91,9 @@ impl Target {
|
||||
fn action(self, item: ItemId) -> Action {
|
||||
match self {
|
||||
Self::Group {
|
||||
destination, before, ..
|
||||
destination,
|
||||
before,
|
||||
..
|
||||
} => Action::Place(item, destination, before),
|
||||
Self::Empty(zone, _) => Action::PlaceInZone(item, zone),
|
||||
}
|
||||
@@ -111,7 +120,11 @@ impl widget::Operation for Bounds<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangle) -> Option<Target> {
|
||||
fn target_at(
|
||||
regions: &[(Region, Rectangle)],
|
||||
cursor: Point,
|
||||
viewport: &Rectangle,
|
||||
) -> Option<Target> {
|
||||
if !viewport.contains(cursor) {
|
||||
return None;
|
||||
}
|
||||
@@ -125,12 +138,15 @@ fn target_at(regions: &[(Region, Rectangle)], cursor: Point, viewport: &Rectangl
|
||||
let items: Vec<_> = regions
|
||||
.iter()
|
||||
.filter_map(|(region, bounds)| match region {
|
||||
Region::Item(id, group) if group.group == destination.group => Some((id, *bounds)),
|
||||
Region::Item(id, group) if group.group == destination.group => {
|
||||
Some((id, *bounds))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let next = items.iter().find(|(_, item)| {
|
||||
cursor.y < item.y || cursor.y <= item.y + item.height && cursor.x < item.center_x()
|
||||
cursor.y < item.y
|
||||
|| cursor.y <= item.y + item.height && cursor.x < item.center_x()
|
||||
});
|
||||
let (before, marker) = next.map_or((None, *bounds), |(id, item)| {
|
||||
(
|
||||
@@ -171,13 +187,24 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut widget::Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
|
||||
if tree.state.downcast_ref::<State>().drag.as_ref().is_some_and(|drag| {
|
||||
!self
|
||||
.regions
|
||||
.iter()
|
||||
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
|
||||
}) {
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
if tree
|
||||
.state
|
||||
.downcast_ref::<State>()
|
||||
.drag
|
||||
.as_ref()
|
||||
.is_some_and(|drag| {
|
||||
!self
|
||||
.regions
|
||||
.iter()
|
||||
.any(|(_, region)| matches!(region, Region::Grip(id) if id == &drag.item))
|
||||
})
|
||||
{
|
||||
tree.state.downcast_mut::<State>().drag = None;
|
||||
}
|
||||
self.content
|
||||
@@ -220,10 +247,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(cursor) = cursor.position().filter(|point| viewport.contains(*point))
|
||||
&& let Some((Region::Grip(item), _)) = bounds
|
||||
.found
|
||||
.iter()
|
||||
.find(|(region, bounds)| matches!(region, Region::Grip(_)) && bounds.contains(cursor))
|
||||
&& let Some((Region::Grip(item), _)) =
|
||||
bounds.found.iter().find(|(region, bounds)| {
|
||||
matches!(region, Region::Grip(_)) && bounds.contains(cursor)
|
||||
})
|
||||
{
|
||||
let source = bounds
|
||||
.found
|
||||
@@ -261,7 +288,9 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.drag.is_some() => {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
if state.drag.is_some() =>
|
||||
{
|
||||
let drag = state.drag.take().unwrap();
|
||||
if drag.moved {
|
||||
if let Some(target) = cursor
|
||||
@@ -304,9 +333,15 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
use cosmic::iced::advanced::Renderer as _;
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
if let Some(drag) = tree
|
||||
.state
|
||||
.downcast_ref::<State>()
|
||||
@@ -327,7 +362,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Color { a: 0.12, ..self.accent },
|
||||
Color {
|
||||
a: 0.12,
|
||||
..self.accent
|
||||
},
|
||||
);
|
||||
}
|
||||
let ghost = Rectangle {
|
||||
@@ -346,7 +384,10 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Color { a: 0.12, ..self.accent },
|
||||
Color {
|
||||
a: 0.12,
|
||||
..self.accent
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -362,9 +403,13 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
if tree.state.downcast_ref::<State>().drag.is_some() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
fn a11y_nodes(
|
||||
@@ -373,7 +418,9 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
@@ -394,9 +441,13 @@ impl Widget<Message, Theme, Renderer> for Editor<'_> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +469,12 @@ mod tests {
|
||||
let items = group.items.iter().map(|item| {
|
||||
container(
|
||||
row([])
|
||||
.push(container(text("::")).width(20).height(32).id(grip_id(&item.id)))
|
||||
.push(
|
||||
container(text("::"))
|
||||
.width(20)
|
||||
.height(32)
|
||||
.id(grip_id(&item.id)),
|
||||
)
|
||||
.push(
|
||||
button::custom(text(item.id.0.clone()))
|
||||
.width(60)
|
||||
@@ -489,12 +545,19 @@ mod tests {
|
||||
let mut view = view(&panel);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::with_size(Size::new(500., 200.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
snapshot.edit(&set("panels", serde_json::json!([panel]))).unwrap();
|
||||
for (destination, empty) in [(Point::new(332., 20.), false), (Point::new(180., 20.), true)] {
|
||||
snapshot
|
||||
.edit(&set("panels", serde_json::json!([panel])))
|
||||
.unwrap();
|
||||
for (destination, empty) in [
|
||||
(Point::new(332., 20.), false),
|
||||
(Point::new(180., 20.), true),
|
||||
] {
|
||||
let mut messages = Vec::new();
|
||||
send(
|
||||
&mut view,
|
||||
@@ -510,7 +573,9 @@ mod tests {
|
||||
&mut tree,
|
||||
&node,
|
||||
&renderer,
|
||||
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: destination,
|
||||
}),
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
@@ -550,9 +615,11 @@ mod tests {
|
||||
let mut view = view(&panel);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let viewport = Rectangle::with_size(Size::new(500., 200.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
for cancel in [true, false] {
|
||||
let mut messages = Vec::new();
|
||||
let outside = Point::new(600., 20.);
|
||||
@@ -624,7 +691,9 @@ mod tests {
|
||||
&mut tree,
|
||||
&node,
|
||||
&renderer,
|
||||
Event::Mouse(mouse::Event::CursorMoved { position: destination }),
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: destination,
|
||||
}),
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
@@ -646,7 +715,10 @@ mod tests {
|
||||
destination,
|
||||
&mut messages,
|
||||
);
|
||||
assert!(messages.is_empty(), "cancellation must not save a valid drop");
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"cancellation must not save a valid drop"
|
||||
);
|
||||
}
|
||||
for point in [Point::new(12., 20.), Point::new(60., 20.)] {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
@@ -111,21 +111,28 @@ pub(super) fn group_record_path(panel: &Panel, id: &GroupId) -> Result<String, S
|
||||
}
|
||||
|
||||
pub(super) fn destinations(panel: &Panel) -> Vec<Destination> {
|
||||
[("start", &panel.start), ("center", &panel.center), ("end", &panel.end)]
|
||||
.into_iter()
|
||||
.flat_map(|(zone, definition)| {
|
||||
definition.groups.iter().map(move |group| Destination {
|
||||
zone,
|
||||
group: group.id.clone(),
|
||||
})
|
||||
[
|
||||
("start", &panel.start),
|
||||
("center", &panel.center),
|
||||
("end", &panel.end),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(zone, definition)| {
|
||||
definition.groups.iter().map(move |group| Destination {
|
||||
zone,
|
||||
group: group.id.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_path(panel: &Panel, destination: &Destination) -> Result<String, String> {
|
||||
// The zone is a display label; a queued selection follows the group's ID
|
||||
// even if another edit has since moved it to a different zone.
|
||||
Ok(format!("{}.items", group_record_path(panel, &destination.group)?))
|
||||
Ok(format!(
|
||||
"{}.items",
|
||||
group_record_path(panel, &destination.group)?
|
||||
))
|
||||
}
|
||||
|
||||
fn locate(panel: &Panel, id: &ItemId) -> Result<(String, usize), String> {
|
||||
@@ -136,7 +143,11 @@ fn locate(panel: &Panel, id: &ItemId) -> Result<(String, usize), String> {
|
||||
"center" => &panel.center,
|
||||
_ => &panel.end,
|
||||
};
|
||||
let group = zone.groups.iter().find(|group| group.id == destination.group).unwrap();
|
||||
let group = zone
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.id == destination.group)
|
||||
.unwrap();
|
||||
if let Some(index) = group.items.iter().position(|item| &item.id == id) {
|
||||
return Ok((path, index));
|
||||
}
|
||||
@@ -168,8 +179,13 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
edits.extend(plan(&candidate, action)?);
|
||||
return Ok(edits);
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").ok_or("Customize items first.")?.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let panels: Vec<Panel> = serde_json::from_value(
|
||||
snapshot
|
||||
.item("panels")
|
||||
.ok_or("Customize items first.")?
|
||||
.clone(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ferese_panel::validate(&panels)?;
|
||||
let panel = &panels[0];
|
||||
let earlier = matches!(&action, Action::Earlier(_));
|
||||
@@ -227,7 +243,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
} else {
|
||||
(index + 1 < items.len()).then_some(index + 1)
|
||||
};
|
||||
let Some(next) = next else { return Ok(Vec::new()) };
|
||||
let Some(next) = next else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
items.swap(index, next);
|
||||
vec![set(&path, items)]
|
||||
}
|
||||
@@ -318,7 +336,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
return Err("Unknown panel item setting.".into());
|
||||
}
|
||||
let (path, index) = locate(panel, &id)?;
|
||||
if field == "style" && !matches!(panel.item(&id).unwrap().kind, ItemKind::Workspaces { .. }) {
|
||||
if field == "style"
|
||||
&& !matches!(panel.item(&id).unwrap().kind, ItemKind::Workspaces { .. })
|
||||
{
|
||||
return Err("Workspace style belongs to Workspaces items.".into());
|
||||
}
|
||||
vec![set(&format!("{path}.{index}.{field}"), value)]
|
||||
@@ -348,7 +368,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
.find(|id| panel.item(&ItemId(id.clone())).is_none())
|
||||
.unwrap();
|
||||
let mut items = records(snapshot, &path);
|
||||
items.push(serde_json::to_value(Item::new(&id, kind)).map_err(|error| error.to_string())?);
|
||||
items.push(
|
||||
serde_json::to_value(Item::new(&id, kind)).map_err(|error| error.to_string())?,
|
||||
);
|
||||
vec![set(&path, items)]
|
||||
}
|
||||
Action::AddGroup(zone) => {
|
||||
@@ -389,7 +411,9 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
} else {
|
||||
(index + 1 < groups.len()).then_some(index + 1)
|
||||
};
|
||||
let Some(next) = next else { return Ok(Vec::new()) };
|
||||
let Some(next) = next else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
groups.swap(index, next);
|
||||
vec![set(&path, groups)]
|
||||
}
|
||||
@@ -398,13 +422,17 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
if !zone.definition(panel).groups[index].items.is_empty() {
|
||||
return Err("Move or remove this group's items before removing the group.".into());
|
||||
}
|
||||
vec![Edit::Remove(format!("panels.0.{}.groups", zone.key()), index)]
|
||||
vec![Edit::Remove(
|
||||
format!("panels.0.{}.groups", zone.key()),
|
||||
index,
|
||||
)]
|
||||
}
|
||||
Action::SetGroup(id, field, value) => {
|
||||
let (field, value) = match field.as_str() {
|
||||
"padding_vertical" | "padding_horizontal" => {
|
||||
let (zone, index) = locate_group(panel, &id)?;
|
||||
let mut padding = serde_json::to_value(zone.definition(panel).groups[index].padding).unwrap();
|
||||
let mut padding =
|
||||
serde_json::to_value(zone.definition(panel).groups[index].padding).unwrap();
|
||||
padding[usize::from(field == "padding_horizontal")] = value;
|
||||
("padding".to_owned(), padding)
|
||||
}
|
||||
@@ -413,7 +441,10 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
||||
if !matches!(field.as_str(), "spacing" | "padding" | "island_padding") {
|
||||
return Err("Unknown panel group setting.".into());
|
||||
}
|
||||
vec![set(&format!("{}.{field}", group_record_path(panel, &id)?), value)]
|
||||
vec![set(
|
||||
&format!("{}.{field}", group_record_path(panel, &id)?),
|
||||
value,
|
||||
)]
|
||||
}
|
||||
};
|
||||
let mut candidate = snapshot.clone();
|
||||
@@ -431,12 +462,21 @@ mod tests {
|
||||
#[test]
|
||||
fn first_geometry_edit_materializes_the_panel_atomically() {
|
||||
let mut snapshot = Snapshot::parse("// keep\nanimations { speed 0.8; }".into()).unwrap();
|
||||
let edits = plan(&snapshot, Action::SetGeometry("side_margins".into(), 18.into())).unwrap();
|
||||
assert_eq!(edits.len(), 2, "initialize and edit share one save/undo transaction");
|
||||
let edits = plan(
|
||||
&snapshot,
|
||||
Action::SetGeometry("side_margins".into(), 18.into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edits.len(),
|
||||
2,
|
||||
"initialize and edit share one save/undo transaction"
|
||||
);
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].resolved_geometry().height, 36.);
|
||||
assert_eq!(panels[0].resolved_geometry().edge_margin, 0);
|
||||
assert_eq!(panels[0].geometry.side_margins, Some(18));
|
||||
@@ -506,15 +546,21 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn morph_attachment_restores_floating_margin_and_preserves_clearance() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into())
|
||||
.unwrap();
|
||||
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let mut snapshot = Snapshot::parse(
|
||||
"panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let before =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
apply(&mut snapshot, Action::SetMorphAttached(true));
|
||||
let attached = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let attached =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(attached[0].resolved_geometry().edge_margin, 0);
|
||||
assert_eq!(attached[0].resolved_geometry().window_clearance, 3);
|
||||
assert_eq!(attached[0].exclusive_zone(false), before[0].exclusive_zone(false));
|
||||
assert_eq!(
|
||||
attached[0].exclusive_zone(false),
|
||||
before[0].exclusive_zone(false)
|
||||
);
|
||||
assert!(attached[0].shape_parameters().attached);
|
||||
apply(&mut snapshot, Action::SetMorphAttached(false));
|
||||
assert_eq!(
|
||||
@@ -525,10 +571,14 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn hover_preference_round_trips_without_changing_shape_or_items() {
|
||||
let mut snapshot = Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into()).unwrap();
|
||||
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let mut snapshot =
|
||||
Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into())
|
||||
.unwrap();
|
||||
let before =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
apply(&mut snapshot, Action::SetPeekOnHover(false));
|
||||
let after = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let after =
|
||||
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(!after[0].morph.peek_on_hover);
|
||||
let mut expected = before.clone();
|
||||
expected[0].morph.peek_on_hover = false;
|
||||
@@ -569,7 +619,8 @@ animations { speed 0.8; }
|
||||
serde_json::from_value(value).unwrap()
|
||||
};
|
||||
apply(&mut snapshot, Action::SetBorder(false));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(!panels[0].border);
|
||||
assert_eq!(panels[0].start, original[0].start);
|
||||
assert_eq!(panels[0].center, original[0].center);
|
||||
@@ -585,7 +636,8 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetCorner(0, 9.));
|
||||
apply(&mut snapshot, Action::SetCorner(3, 21.));
|
||||
let radii: ferese_panel::CornerRadii =
|
||||
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone())
|
||||
.unwrap();
|
||||
assert_eq!(radii.0, [9., 8., 12., 21.]);
|
||||
let previous = snapshot.source.clone();
|
||||
for action in [
|
||||
@@ -600,15 +652,19 @@ animations { speed 0.8; }
|
||||
|
||||
#[test]
|
||||
fn panel_corner_override_is_independent_and_clearing_or_removing_restores_inheritance() {
|
||||
let mut snapshot = Snapshot::parse("theme { geometry { shell-radius 14; }; }".into()).unwrap();
|
||||
let mut snapshot =
|
||||
Snapshot::parse("theme { geometry { shell-radius 14; }; }".into()).unwrap();
|
||||
assert!(plan(&snapshot, Action::ClearRadius).unwrap().is_empty());
|
||||
apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into()));
|
||||
let resolve = |snapshot: &Snapshot, radius| {
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
panels[0].resolved_radius(radius).0
|
||||
};
|
||||
assert_eq!(resolve(&snapshot, 14.), [4., 8., 12., 16.]);
|
||||
snapshot.edit(&set("theme.geometry.shell_radius", 20)).unwrap();
|
||||
snapshot
|
||||
.edit(&set("theme.geometry.shell_radius", 20))
|
||||
.unwrap();
|
||||
assert_eq!(resolve(&snapshot, 20.), [4., 8., 12., 16.]);
|
||||
let original = snapshot.source.clone();
|
||||
assert!(plan(&snapshot, Action::SetRadius("-4px".into())).is_err());
|
||||
@@ -631,7 +687,8 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom));
|
||||
let before = snapshot.item("panels.0.start").unwrap().clone();
|
||||
assert_eq!(snapshot.string("panels.0.edge", ""), "bottom");
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let battery = panels[0].item(&ItemId("battery".into())).unwrap();
|
||||
assert_eq!(battery.kind, ItemKind::Battery { percentage: true });
|
||||
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top));
|
||||
@@ -661,7 +718,8 @@ animations { speed 0.8; }
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let battery = panels[0].item(&ItemId("battery".into())).unwrap();
|
||||
assert_eq!(battery.kind, ItemKind::Battery { percentage: true });
|
||||
assert!(!battery.visible);
|
||||
@@ -681,12 +739,18 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Move(ItemId("clock".into()), target("start", "navigation")),
|
||||
);
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups.0.items"), ["overview", "clock"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["overview", "clock"]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.metadata.custom_note", ""),
|
||||
"keep"
|
||||
);
|
||||
assert_eq!(snapshot.string("panels.0.end.groups.0.items.0.overflow", ""), "always");
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.end.groups.0.items.0.overflow", ""),
|
||||
"always"
|
||||
);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
assert_eq!(snapshot.number("animations.speed", 1.0), 0.8);
|
||||
}
|
||||
@@ -694,7 +758,10 @@ animations { speed 0.8; }
|
||||
#[test]
|
||||
fn repeated_kind_additions_get_unique_ids_and_remove_only_the_addressed_instance() {
|
||||
let mut snapshot = snapshot();
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "status")),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "clock-2", "network", "clock-3"]
|
||||
@@ -704,7 +771,10 @@ animations { speed 0.8; }
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "network", "clock-3"]
|
||||
);
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "status")),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock", "network", "clock-3", "clock-2"]
|
||||
@@ -721,7 +791,10 @@ animations { speed 0.8; }
|
||||
);
|
||||
assert!(plan(&snapshot, pending).unwrap().is_empty());
|
||||
apply(&mut snapshot, Action::Earlier(ItemId("network".into())));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups.0.items"), ["network", "overview"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["network", "overview"]
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::Earlier(ItemId("network".into())))
|
||||
.unwrap()
|
||||
@@ -738,9 +811,18 @@ animations { speed 0.8; }
|
||||
Action::Move(ItemId("network".into()), target("start", "navigation")),
|
||||
);
|
||||
apply(&mut snapshot, pending);
|
||||
assert_eq!(snapshot.string("panels.0.start.groups.0.items.1.id", ""), "network");
|
||||
assert_eq!(snapshot.string("panels.0.start.groups.0.items.1.overflow", ""), "never");
|
||||
assert_eq!(snapshot.string("panels.0.end.groups.0.items.1.id", ""), "clock-2");
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.id", ""),
|
||||
"network"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.start.groups.0.items.1.overflow", ""),
|
||||
"never"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.string("panels.0.end.groups.0.items.1.id", ""),
|
||||
"clock-2"
|
||||
);
|
||||
assert!(
|
||||
plan(
|
||||
&snapshot,
|
||||
@@ -766,7 +848,10 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::Remove(ItemId("title".into())));
|
||||
assert_eq!(snapshot.records("panels.0.center.groups"), 1);
|
||||
assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 0);
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("center", "title")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("center", "title")),
|
||||
);
|
||||
assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 1);
|
||||
}
|
||||
|
||||
@@ -784,7 +869,10 @@ animations { speed 0.8; }
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
let complete = Snapshot::parse(snapshot.source).unwrap();
|
||||
assert_eq!(ids(&complete, "panels.0.center.groups.0.items"), ["title", "clock"]);
|
||||
assert_eq!(
|
||||
ids(&complete, "panels.0.center.groups.0.items"),
|
||||
["title", "clock"]
|
||||
);
|
||||
let restored = Snapshot::parse(original.source).unwrap();
|
||||
assert_eq!(restored.doc.value(), original.doc.value());
|
||||
}
|
||||
@@ -801,7 +889,10 @@ animations { speed 0.8; }
|
||||
apply(&mut snapshot, Action::SetOpacity(0.45));
|
||||
apply(&mut snapshot, Action::MoveGroup(id.clone(), Zone::Start));
|
||||
apply(&mut snapshot, Action::EarlierGroup(id.clone()));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups"), ["status", "navigation"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups"),
|
||||
["status", "navigation"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups.0.items"),
|
||||
["clock", "clock-2", "network"]
|
||||
@@ -813,11 +904,26 @@ animations { speed 0.8; }
|
||||
assert_eq!(snapshot.number("panels.0.start.groups.0.spacing", 0.), 7.5);
|
||||
assert!((snapshot.number("panels.0.background_opacity", 1.) - 0.45).abs() < 0.0001);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
assert!(plan(&snapshot, Action::EarlierGroup(id.clone())).unwrap().is_empty());
|
||||
assert!(
|
||||
plan(&snapshot, Action::EarlierGroup(id.clone()))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
apply(&mut snapshot, Action::LaterGroup(id.clone()));
|
||||
assert_eq!(ids(&snapshot, "panels.0.start.groups"), ["navigation", "status"]);
|
||||
assert!(plan(&snapshot, Action::LaterGroup(id.clone())).unwrap().is_empty());
|
||||
assert!(plan(&snapshot, Action::MoveGroup(id, Zone::Start)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.start.groups"),
|
||||
["navigation", "status"]
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::LaterGroup(id.clone()))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::MoveGroup(id, Zone::Start))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
||||
}
|
||||
|
||||
@@ -860,10 +966,16 @@ animations { speed 0.8; }
|
||||
for _ in 0..3 {
|
||||
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
||||
}
|
||||
assert_eq!(ids(&snapshot, "panels.0.end.groups"), ["end-1", "end-2", "end-3"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups"),
|
||||
["end-1", "end-2", "end-3"]
|
||||
);
|
||||
apply(&mut snapshot, Action::SetBorder(false));
|
||||
assert!(!snapshot.boolean("panels.0.border", true));
|
||||
apply(&mut snapshot, Action::Add(ItemKind::Clock, target("end", "end-2")));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Add(ItemKind::Clock, target("end", "end-2")),
|
||||
);
|
||||
let original = snapshot.source.clone();
|
||||
assert!(plan(&snapshot, Action::RemoveGroup(GroupId("end-2".into()))).is_err());
|
||||
assert_eq!(snapshot.source, original);
|
||||
@@ -896,7 +1008,13 @@ animations { speed 0.8; }
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(plan(&snapshot, Action::MoveGroup(GroupId("missing".into()), Zone::Start)).is_err());
|
||||
assert!(
|
||||
plan(
|
||||
&snapshot,
|
||||
Action::MoveGroup(GroupId("missing".into()), Zone::Start)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(snapshot.source, original);
|
||||
}
|
||||
|
||||
@@ -904,7 +1022,10 @@ animations { speed 0.8; }
|
||||
fn drop_reorders_exact_instances_and_preserves_authored_fields() {
|
||||
let mut snapshot = snapshot();
|
||||
let id = ItemId("clock".into());
|
||||
apply(&mut snapshot, Action::Place(id.clone(), target("end", "status"), None));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), None),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock-2", "network", "clock"]
|
||||
@@ -914,9 +1035,12 @@ animations { speed 0.8; }
|
||||
"keep"
|
||||
);
|
||||
assert!(
|
||||
plan(&snapshot, Action::Place(id.clone(), target("end", "status"), None))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
plan(
|
||||
&snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), None)
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
plan(
|
||||
@@ -928,7 +1052,11 @@ animations { speed 0.8; }
|
||||
);
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Place(id.clone(), target("end", "status"), Some(ItemId("clock-2".into()))),
|
||||
Action::Place(
|
||||
id.clone(),
|
||||
target("end", "status"),
|
||||
Some(ItemId("clock-2".into())),
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
@@ -938,8 +1066,14 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Place(id, target("center", "title"), Some(ItemId("title".into()))),
|
||||
);
|
||||
assert_eq!(ids(&snapshot, "panels.0.center.groups.0.items"), ["clock", "title"]);
|
||||
assert_eq!(ids(&snapshot, "panels.0.end.groups.0.items"), ["clock-2", "network"]);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.center.groups.0.items"),
|
||||
["clock", "title"]
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&snapshot, "panels.0.end.groups.0.items"),
|
||||
["clock-2", "network"]
|
||||
);
|
||||
assert!(snapshot.source.contains("// first clock"));
|
||||
}
|
||||
|
||||
@@ -961,7 +1095,11 @@ animations { speed 0.8; }
|
||||
assert_eq!(snapshot.source, original);
|
||||
apply(&mut snapshot, Action::Remove(ItemId("title".into())));
|
||||
apply(&mut snapshot, Action::RemoveGroup(GroupId("title".into())));
|
||||
let edits = plan(&snapshot, Action::PlaceInZone(ItemId("clock".into()), Zone::Center)).unwrap();
|
||||
let edits = plan(
|
||||
&snapshot,
|
||||
Action::PlaceInZone(ItemId("clock".into()), Zone::Center),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edits.len(), 3, "create and move through one save");
|
||||
for edit in edits {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
@@ -984,9 +1122,14 @@ animations { speed 0.8; }
|
||||
for style in ferese_panel::WorkspaceStyle::ALL {
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::Set(ItemId("workspaces".into()), "style".into(), style.key().into()),
|
||||
Action::Set(
|
||||
ItemId("workspaces".into()),
|
||||
"style".into(),
|
||||
style.key().into(),
|
||||
),
|
||||
);
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panels[0];
|
||||
assert_eq!(
|
||||
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
||||
@@ -1003,7 +1146,8 @@ animations { speed 0.8; }
|
||||
&mut snapshot,
|
||||
Action::Move(ItemId("workspaces".into()), target("end", "status")),
|
||||
);
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panels[0];
|
||||
assert_eq!(
|
||||
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
||||
@@ -1025,7 +1169,8 @@ animations { speed 0.8; }
|
||||
Action::Set(ItemId("title".into()), "visible".into(), true.into()),
|
||||
);
|
||||
assert!(snapshot.source.contains("// keep") && snapshot.source.contains("custom-note"));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert!(panels[0].item(&ItemId("title".into())).unwrap().visible);
|
||||
}
|
||||
#[test]
|
||||
@@ -1037,7 +1182,10 @@ animations { speed 0.8; }
|
||||
Action::ClearGeometry("panels.0.geometry.side_margins".into()),
|
||||
);
|
||||
assert_eq!(snapshot.item("panels.0.geometry.side_margins"), None);
|
||||
assert_eq!(snapshot.item("panels.0.geometry.height"), Some(&serde_json::json!(32)));
|
||||
assert_eq!(
|
||||
snapshot.item("panels.0.geometry.height"),
|
||||
Some(&serde_json::json!(32))
|
||||
);
|
||||
assert!(snapshot.item("panels.0.corner_radius").is_some());
|
||||
apply(&mut snapshot, Action::ResetShape);
|
||||
assert_eq!(snapshot.item("panels.0.geometry"), None);
|
||||
@@ -1050,13 +1198,20 @@ animations { speed 0.8; }
|
||||
fn panel_presets_preserve_group_layout_items_and_comments() {
|
||||
let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; center { group title { padding 2 7; item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap();
|
||||
let groups = snapshot.item("panels.0.center.groups").unwrap().clone();
|
||||
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Flat));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::ApplyPreset(ferese_panel::PanelStyle::Flat),
|
||||
);
|
||||
assert_eq!(snapshot.string("panels.0.style", ""), "flat");
|
||||
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||
assert!(snapshot.source.contains("// keep"));
|
||||
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Floating));
|
||||
apply(
|
||||
&mut snapshot,
|
||||
Action::ApplyPreset(ferese_panel::PanelStyle::Floating),
|
||||
);
|
||||
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
let panels: Vec<Panel> =
|
||||
serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Floating));
|
||||
for surface in ["none", "inset", "island"] {
|
||||
assert!(
|
||||
|
||||
@@ -18,7 +18,10 @@ impl App {
|
||||
.push(
|
||||
widget::row([])
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.panel_heading(title.to_owned(), 16.).width(Length::Fill))
|
||||
.push(
|
||||
self.panel_heading(title.to_owned(), 16.)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.push(self.settings_icon_button(
|
||||
"Deselect",
|
||||
"M6 6l12 12 M6 18L18 6",
|
||||
@@ -33,7 +36,10 @@ impl App {
|
||||
pub(super) fn panel_divider(&self) -> Element<'static, Message> {
|
||||
let p = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
widget::container(widget::Space::new().height(1).width(Length::Fill))
|
||||
.class(crate::visuals::surface(ferese_theme::mix(p.card, p.text, 0.09), 0.))
|
||||
.class(crate::visuals::surface(
|
||||
ferese_theme::mix(p.card, p.text, 0.09),
|
||||
0.,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -57,7 +63,9 @@ impl App {
|
||||
cosmic::iced::widget::pick_list(
|
||||
crate::panel_edit::destinations(panel),
|
||||
Some(destination.clone()),
|
||||
move |destination| Message::PanelEdit(Action::Move(item_id.clone(), destination)),
|
||||
move |destination| {
|
||||
Message::PanelEdit(Action::Move(item_id.clone(), destination))
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(13)
|
||||
@@ -68,7 +76,12 @@ impl App {
|
||||
);
|
||||
rows = rows.push(self.panel_field(
|
||||
item,
|
||||
Field::new(format!("{path}.visible"), "Show item", "", Kind::Toggle(true)),
|
||||
Field::new(
|
||||
format!("{path}.visible"),
|
||||
"Show item",
|
||||
"",
|
||||
Kind::Toggle(true),
|
||||
),
|
||||
));
|
||||
rows = rows.push(self.panel_divider());
|
||||
if matches!(item.kind, ItemKind::Workspaces { .. }) {
|
||||
@@ -83,14 +96,20 @@ impl App {
|
||||
"Choose when to use the overflow menu.",
|
||||
Kind::Choice {
|
||||
default: "auto",
|
||||
choices: &[("never", "Keep visible"), ("auto", "When needed"), ("always", "Always")],
|
||||
choices: &[
|
||||
("never", "Keep visible"),
|
||||
("auto", "When needed"),
|
||||
("always", "Always"),
|
||||
],
|
||||
},
|
||||
),
|
||||
));
|
||||
let representations = item.kind.representations();
|
||||
if representations.len() > 1 {
|
||||
let choices: &'static [(&'static str, &'static str)] = match representations {
|
||||
[Representation::Wide, Representation::Icon] => &[("wide", "Full"), ("icon", "Icon")],
|
||||
[Representation::Wide, Representation::Icon] => {
|
||||
&[("wide", "Full"), ("icon", "Icon")]
|
||||
}
|
||||
_ => &[("wide", "Full"), ("compact", "Compact"), ("icon", "Icon")],
|
||||
};
|
||||
rows = rows.push(self.panel_field(
|
||||
@@ -128,12 +147,15 @@ impl App {
|
||||
.position(|candidate| candidate.id == item.id)
|
||||
.unwrap();
|
||||
rows.push(self.panel_divider())
|
||||
.push(self.inspector_actions(
|
||||
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
|
||||
(index + 1 < group.items.len()).then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
|
||||
"Remove item",
|
||||
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
|
||||
))
|
||||
.push(
|
||||
self.inspector_actions(
|
||||
(index > 0).then(|| Message::PanelEdit(Action::Earlier(item.id.clone()))),
|
||||
(index + 1 < group.items.len())
|
||||
.then(|| Message::PanelEdit(Action::Later(item.id.clone()))),
|
||||
"Remove item",
|
||||
Some(Message::PanelEdit(Action::Remove(item.id.clone()))),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -159,16 +181,24 @@ impl App {
|
||||
.padding([8, 10])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(palette, style == selected))
|
||||
.on_press_maybe(
|
||||
(style != selected)
|
||||
.then(|| Message::PanelEdit(Action::Set(item.id.clone(), "style".into(), style.key().into()))),
|
||||
),
|
||||
.on_press_maybe((style != selected).then(|| {
|
||||
Message::PanelEdit(Action::Set(
|
||||
item.id.clone(),
|
||||
"style".into(),
|
||||
style.key().into(),
|
||||
))
|
||||
})),
|
||||
);
|
||||
}
|
||||
choices.into()
|
||||
}
|
||||
|
||||
pub(super) fn group_inspector(&self, panel: &Panel, group: &Group, placement: Zone) -> Element<'static, Message> {
|
||||
pub(super) fn group_inspector(
|
||||
&self,
|
||||
panel: &Panel,
|
||||
group: &Group,
|
||||
placement: Zone,
|
||||
) -> Element<'static, Message> {
|
||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let id = group.id.clone();
|
||||
let mut rows = widget::column([])
|
||||
@@ -182,9 +212,11 @@ impl App {
|
||||
.spacing(8)
|
||||
.push(self.label("Placement", 13.))
|
||||
.push(crate::visuals::panel_select(
|
||||
cosmic::iced::widget::pick_list(Zone::ALL.to_vec(), Some(placement), move |zone| {
|
||||
Message::PanelEdit(Action::MoveGroup(id.clone(), zone))
|
||||
})
|
||||
cosmic::iced::widget::pick_list(
|
||||
Zone::ALL.to_vec(),
|
||||
Some(placement),
|
||||
move |zone| Message::PanelEdit(Action::MoveGroup(id.clone(), zone)),
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(13)
|
||||
.padding([8, 10])
|
||||
@@ -196,16 +228,28 @@ impl App {
|
||||
let mut spacing = widget::column([]).spacing(12);
|
||||
for (name, label, value, maximum) in [
|
||||
("spacing", "Item spacing", group.spacing, 64.),
|
||||
("padding_vertical", "Vertical padding", f32::from(group.padding[0]), 32.),
|
||||
(
|
||||
"padding_vertical",
|
||||
"Vertical padding",
|
||||
f32::from(group.padding[0]),
|
||||
32.,
|
||||
),
|
||||
(
|
||||
"padding_horizontal",
|
||||
"Horizontal padding",
|
||||
f32::from(group.padding[1]),
|
||||
32.,
|
||||
),
|
||||
("island_padding", "Island padding", group.island_padding, 32.),
|
||||
(
|
||||
"island_padding",
|
||||
"Island padding",
|
||||
group.island_padding,
|
||||
32.,
|
||||
),
|
||||
] {
|
||||
if panel.group_surface() != ferese_panel::GroupSurface::Island && name == "island_padding" {
|
||||
if panel.group_surface() != ferese_panel::GroupSurface::Island
|
||||
&& name == "island_padding"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let change = |value: f32| {
|
||||
@@ -263,7 +307,8 @@ impl App {
|
||||
),
|
||||
);
|
||||
if !group.items.is_empty() {
|
||||
rows = rows.push(self.note("Move or remove this group’s items before removing the group."));
|
||||
rows = rows
|
||||
.push(self.note("Move or remove this group’s items before removing the group."));
|
||||
}
|
||||
rows.into()
|
||||
}
|
||||
@@ -294,7 +339,10 @@ impl App {
|
||||
let values = serde_json::to_value(item).expect("serializable panel item");
|
||||
match field.kind {
|
||||
Kind::Choice { default, choices } => {
|
||||
let value = values.get(&name).and_then(serde_json::Value::as_str).unwrap_or(default);
|
||||
let value = values
|
||||
.get(&name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(default);
|
||||
if name == "overflow" {
|
||||
let selected = choices
|
||||
.iter()
|
||||
@@ -311,7 +359,11 @@ impl App {
|
||||
.collect::<Vec<_>>(),
|
||||
selected,
|
||||
move |choice| {
|
||||
Message::PanelEdit(Action::Set(id.clone(), name.clone(), choice.0.into()))
|
||||
Message::PanelEdit(Action::Set(
|
||||
id.clone(),
|
||||
name.clone(),
|
||||
choice.0.into(),
|
||||
))
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
@@ -329,16 +381,23 @@ impl App {
|
||||
.padding([8, 4])
|
||||
.width(Length::Fill)
|
||||
.class(crate::visuals::panel_button(p, value == key))
|
||||
.on_press(Message::PanelEdit(Action::Set(id.clone(), name.clone(), key.into())))
|
||||
.on_press(Message::PanelEdit(Action::Set(
|
||||
id.clone(),
|
||||
name.clone(),
|
||||
key.into(),
|
||||
)))
|
||||
.into()
|
||||
}))
|
||||
.spacing(2)
|
||||
.width(Length::Fill);
|
||||
let mut rows = widget::column([]).spacing(8).push(self.label(field.label, 13.)).push(
|
||||
widget::container(options)
|
||||
.padding(3)
|
||||
.class(crate::visuals::surface(p.sidebar, 9.)),
|
||||
);
|
||||
let mut rows = widget::column([])
|
||||
.spacing(8)
|
||||
.push(self.label(field.label, 13.))
|
||||
.push(
|
||||
widget::container(options)
|
||||
.padding(3)
|
||||
.class(crate::visuals::surface(p.sidebar, 9.)),
|
||||
);
|
||||
if !field.description.is_empty() {
|
||||
rows = rows.push(self.note(&field.description));
|
||||
}
|
||||
@@ -355,7 +414,11 @@ impl App {
|
||||
.push(self.label(field.label.clone(), 13.).width(Length::Fill))
|
||||
.push(
|
||||
ferese_theme::controls::switch(enabled, p)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{}: {}",
|
||||
field.label,
|
||||
if enabled { "on" } else { "off" }
|
||||
))
|
||||
.on_press(Message::PanelEdit(Action::Set(id, name, (!enabled).into()))),
|
||||
)
|
||||
.into()
|
||||
|
||||
@@ -20,7 +20,10 @@ fn available(item: &Item) -> bool {
|
||||
item.available(SAMPLE_AVAILABILITY)
|
||||
}
|
||||
|
||||
fn panel_background(theme: &ferese_config::theme::ResolvedTheme, panel: &Panel) -> cosmic::iced::Color {
|
||||
fn panel_background(
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
panel: &Panel,
|
||||
) -> cosmic::iced::Color {
|
||||
let fill = if panel.style == ferese_panel::PanelStyle::Morph {
|
||||
&theme.tokens.colors.surface_raised
|
||||
} else {
|
||||
@@ -81,7 +84,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
if !self.panel_preview_resolution.overflow.is_empty() {
|
||||
rows = rows.push(container(crate::scrollable(items).height(Length::Shrink)).max_height(160.));
|
||||
rows = rows.push(
|
||||
container(crate::scrollable(items).height(Length::Shrink)).max_height(160.),
|
||||
);
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
@@ -97,7 +102,8 @@ impl App {
|
||||
let panels = match self.draft.item("panels") {
|
||||
Some(panels) => panels.clone(),
|
||||
None => {
|
||||
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)? else {
|
||||
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
panels
|
||||
@@ -124,7 +130,9 @@ impl App {
|
||||
.into(),
|
||||
ferese_theme::panel_surface::Options {
|
||||
parameters: panel.shape_parameters(),
|
||||
bounds: Some(cosmic::iced::advanced::widget::Id::new("morph-preview-body")),
|
||||
bounds: Some(cosmic::iced::advanced::widget::Id::new(
|
||||
"morph-preview-body",
|
||||
)),
|
||||
groups: Vec::new(),
|
||||
scale: self.render_scale,
|
||||
high_contrast: palette.high_contrast,
|
||||
@@ -186,7 +194,10 @@ impl App {
|
||||
.flat_map(|(position, zone)| {
|
||||
zone.groups.iter().map(move |group| {
|
||||
(
|
||||
cosmic::iced::advanced::widget::Id::new(format!("preview-group:{}", group.id.0)),
|
||||
cosmic::iced::advanced::widget::Id::new(format!(
|
||||
"preview-group:{}",
|
||||
group.id.0
|
||||
)),
|
||||
position,
|
||||
)
|
||||
})
|
||||
@@ -233,7 +244,11 @@ impl App {
|
||||
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
|
||||
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
|
||||
end = row([])
|
||||
.spacing(if has_end { panel.zone_spacing(&panel.end) } else { 0. })
|
||||
.spacing(if has_end {
|
||||
panel.zone_spacing(&panel.end)
|
||||
} else {
|
||||
0.
|
||||
})
|
||||
.align_y(Alignment::Center)
|
||||
.push(trigger)
|
||||
.push(end)
|
||||
@@ -247,7 +262,9 @@ impl App {
|
||||
.push(start)
|
||||
.push(cosmic::widget::Space::new().width(Length::Fill))
|
||||
.push(end),
|
||||
container(center).center_x(Length::Fill).center_y(Length::Fill),
|
||||
container(center)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
]
|
||||
.into()
|
||||
},
|
||||
@@ -287,21 +304,34 @@ impl App {
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
|
||||
let mut groups = row([]).spacing(panel.zone_spacing(zone)).align_y(Alignment::Center);
|
||||
fn preview_zone(
|
||||
&self,
|
||||
panel: &Panel,
|
||||
zone: &Zone,
|
||||
resolution: &Resolution,
|
||||
) -> Element<'static, Message> {
|
||||
let mut groups = row([])
|
||||
.spacing(panel.zone_spacing(zone))
|
||||
.align_y(Alignment::Center);
|
||||
for group in &zone.groups {
|
||||
let mut controls = row([]).align_y(Alignment::Center);
|
||||
let mut count = 0;
|
||||
for item in &group.items {
|
||||
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id) else {
|
||||
let Some(Placement::Visible {
|
||||
representation,
|
||||
width,
|
||||
}) = resolution.items.get(&item.id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if *width <= 0. {
|
||||
continue;
|
||||
}
|
||||
if count > 0 {
|
||||
controls =
|
||||
controls.push(cosmic::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
||||
controls = controls.push(
|
||||
cosmic::widget::Space::new()
|
||||
.width(item.gap_before.unwrap_or(group.spacing)),
|
||||
);
|
||||
}
|
||||
controls = controls.push(self.preview_control(item, *representation, Some(*width)));
|
||||
count += 1;
|
||||
@@ -345,16 +375,21 @@ impl App {
|
||||
width: Option<f32>,
|
||||
) -> Element<'static, Message> {
|
||||
use ferese_theme::icons;
|
||||
let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
|
||||
let control_height =
|
||||
(self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
if let ItemKind::Workspaces { style } = item.kind {
|
||||
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
|
||||
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
||||
.name("Edit Workspaces")
|
||||
.padding([0, 2])
|
||||
.height(control_height)
|
||||
.on_press(Message::PanelSelect(item.id.clone()))
|
||||
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
|
||||
let control =
|
||||
button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
||||
.name("Edit Workspaces")
|
||||
.padding([0, 2])
|
||||
.height(control_height)
|
||||
.on_press(Message::PanelSelect(item.id.clone()))
|
||||
.class(preview_button(
|
||||
palette,
|
||||
self.panel_selection.as_ref() == Some(&item.id),
|
||||
));
|
||||
return container(control)
|
||||
.id(format!("preview-item:{}", item.id.0))
|
||||
.width(width.map_or(Length::Shrink, Length::Fixed))
|
||||
@@ -400,7 +435,10 @@ impl App {
|
||||
.height(Length::Fill)
|
||||
.spacing(5)
|
||||
.align_y(Alignment::Center)
|
||||
.push(container(icons::tinted(source, 16, palette.text)).id(format!("preview-icon:{}", item.id.0)));
|
||||
.push(
|
||||
container(icons::tinted(source, 16, palette.text))
|
||||
.id(format!("preview-icon:{}", item.id.0)),
|
||||
);
|
||||
|
||||
if !label.is_empty() {
|
||||
content = content.push(
|
||||
@@ -484,7 +522,9 @@ fn preview_button(palette: visuals::Palette, selected: bool) -> cosmic::theme::B
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::Application;
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Layout, Shell, clipboard, layout, mouse, renderer::Headless, widget,
|
||||
};
|
||||
use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size};
|
||||
|
||||
#[test]
|
||||
@@ -564,14 +604,18 @@ mod tests {
|
||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let viewport = Rectangle::with_size(Size::new(width, 80.));
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for _ in 0..3 {
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
&Event::Window(cosmic::iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
@@ -629,7 +673,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
if width == 2400. {
|
||||
let target = bounds.items.iter().find(|(id, _)| id == &clock).unwrap().1.center();
|
||||
let target = bounds
|
||||
.items
|
||||
.iter()
|
||||
.find(|(id, _)| id == &clock)
|
||||
.unwrap()
|
||||
.1
|
||||
.center();
|
||||
for event in [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
@@ -648,7 +698,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(message, Message::PanelSelect(id) if id == &clock))
|
||||
.filter(
|
||||
|message| matches!(message, Message::PanelSelect(id) if id == &clock)
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
@@ -680,7 +732,10 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||
for background in [
|
||||
ferese_panel::PanelStyle::Flat,
|
||||
ferese_panel::PanelStyle::Floating,
|
||||
] {
|
||||
let mut panel = Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
panel.apply_preset(background);
|
||||
// Match a three-group layout with media in navigation and a larger status group.
|
||||
@@ -711,7 +766,12 @@ mod tests {
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.map(|item| (widget::Id::new(format!("preview-item:{}", item.id.0)), item.id.clone()))
|
||||
.map(|item| {
|
||||
(
|
||||
widget::Id::new(format!("preview-item:{}", item.id.0)),
|
||||
item.id.clone(),
|
||||
)
|
||||
})
|
||||
.chain(std::iter::once((
|
||||
widget::Id::new("preview-item:_overflow"),
|
||||
ItemId("_overflow".into()),
|
||||
@@ -727,7 +787,10 @@ mod tests {
|
||||
.find(|(id, _)| id == &title.id)
|
||||
.expect("sample title remains visible")
|
||||
.1;
|
||||
assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}");
|
||||
assert!(
|
||||
(center.center_x() - width / 2.).abs() < 0.1,
|
||||
"{center:?} at {width}"
|
||||
);
|
||||
// Match the shell: titles yield space down to their shared minimum.
|
||||
assert!(center.width >= title.kind.minimum_width().unwrap().min(natural) - 0.1);
|
||||
assert!(center.width <= natural + 0.1);
|
||||
@@ -746,7 +809,11 @@ mod tests {
|
||||
} else {
|
||||
side.x - center.x - center.width
|
||||
};
|
||||
assert!(gap >= 7.9, "{background:?} at {width}: {} crowds title ({gap}px)", id.0);
|
||||
assert!(
|
||||
gap >= 7.9,
|
||||
"{background:?} at {width}: {} crowds title ({gap}px)",
|
||||
id.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -774,7 +841,10 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||
for background in [
|
||||
ferese_panel::PanelStyle::Flat,
|
||||
ferese_panel::PanelStyle::Floating,
|
||||
] {
|
||||
let panel = Panel::from_preset(background);
|
||||
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
@@ -796,25 +866,48 @@ mod tests {
|
||||
let mut controls = Bounds {
|
||||
targets: ids
|
||||
.iter()
|
||||
.map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id.clone()))
|
||||
.map(|id| {
|
||||
(
|
||||
widget::Id::new(format!("preview-item:{}", id.0)),
|
||||
id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut icons = Bounds {
|
||||
targets: ids
|
||||
.iter()
|
||||
.map(|id| (widget::Id::new(format!("preview-icon:{}", id.0)), id.clone()))
|
||||
.map(|id| {
|
||||
(
|
||||
widget::Id::new(format!("preview-icon:{}", id.0)),
|
||||
id.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut controls);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut icons);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut controls,
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut icons,
|
||||
);
|
||||
assert!(!controls.items.is_empty());
|
||||
assert_eq!(controls.items.len(), icons.items.len());
|
||||
for (id, icon) in &icons.items {
|
||||
let control = controls.items.iter().find(|(item, _)| item == id).unwrap().1;
|
||||
let control = controls
|
||||
.items
|
||||
.iter()
|
||||
.find(|(item, _)| item == id)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert!(
|
||||
(icon.center_y() - control.center_y()).abs() < 0.01,
|
||||
"{background:?} at {width}: {} icon {icon:?} is off-center in {control:?}",
|
||||
@@ -863,14 +956,17 @@ mod tests {
|
||||
let mut baseline_bounds = None;
|
||||
for state in ["idle", "hovered", "pressed", "selected"] {
|
||||
app.panel_selection = (state == "selected").then(|| item.id.clone());
|
||||
let mut view: Element<'_, Message> = container(app.preview_control(&item, Representation::Icon, None))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
let mut view: Element<'_, Message> =
|
||||
container(app.preview_control(&item, Representation::Icon, None))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut bounds = Bounds {
|
||||
targets: vec![(widget::Id::new("preview-item:battery"), item.id.clone())],
|
||||
..Default::default()
|
||||
@@ -917,11 +1013,18 @@ mod tests {
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(96, 48), 1., backdrop);
|
||||
if let Some(baseline) = &baseline {
|
||||
let mut changed = 0;
|
||||
for (index, (before, after)) in baseline.chunks_exact(4).zip(pixels.chunks_exact(4)).enumerate() {
|
||||
for (index, (before, after)) in baseline
|
||||
.chunks_exact(4)
|
||||
.zip(pixels.chunks_exact(4))
|
||||
.enumerate()
|
||||
{
|
||||
if before != after {
|
||||
changed += 1;
|
||||
assert!(
|
||||
target.contains(Point::new((index % 96) as f32 + 0.5, (index / 96) as f32 + 0.5)),
|
||||
target.contains(Point::new(
|
||||
(index % 96) as f32 + 0.5,
|
||||
(index / 96) as f32 + 0.5
|
||||
)),
|
||||
"{state} paints outside the hit area"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ pub enum Page {
|
||||
impl Page {
|
||||
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
|
||||
("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]),
|
||||
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
|
||||
(
|
||||
"Interaction",
|
||||
&[Self::Windows, Self::Keyboard, Self::Accessibility],
|
||||
),
|
||||
(
|
||||
"System",
|
||||
&[
|
||||
@@ -43,7 +46,10 @@ impl Page {
|
||||
pub fn subpages(self) -> &'static [(Self, &'static str)] {
|
||||
match self.navigation_page() {
|
||||
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
|
||||
Self::Keyboard => &[(Self::Keyboard, "Keyboard & mouse"), (Self::Shortcuts, "Shortcuts")],
|
||||
Self::Keyboard => &[
|
||||
(Self::Keyboard, "Keyboard & mouse"),
|
||||
(Self::Shortcuts, "Shortcuts"),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -114,7 +120,9 @@ impl Page {
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accessibility => "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6",
|
||||
Self::Accessibility => {
|
||||
"M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6"
|
||||
}
|
||||
Self::Appearance => {
|
||||
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
|
||||
}
|
||||
@@ -132,12 +140,18 @@ impl Page {
|
||||
}
|
||||
Self::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0",
|
||||
Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8",
|
||||
Self::Connections => "M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01",
|
||||
Self::Connections => {
|
||||
"M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(self, query: &str) -> bool {
|
||||
self.matches_own(query) || self.subpages().iter().any(|(page, _)| page.matches_own(query))
|
||||
self.matches_own(query)
|
||||
|| self
|
||||
.subpages()
|
||||
.iter()
|
||||
.any(|(page, _)| page.matches_own(query))
|
||||
}
|
||||
|
||||
fn matches_own(self, query: &str) -> bool {
|
||||
@@ -182,7 +196,12 @@ pub struct Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn new(path: impl Into<String>, label: impl Into<String>, description: impl Into<String>, kind: Kind) -> Self {
|
||||
pub fn new(
|
||||
path: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
kind: Kind,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
label: label.into(),
|
||||
@@ -241,7 +260,15 @@ pub fn text(
|
||||
description: impl Into<String>,
|
||||
default: &'static str,
|
||||
) -> Field {
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
Field::new(
|
||||
path,
|
||||
label,
|
||||
description,
|
||||
Kind::Text {
|
||||
default,
|
||||
argv: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choice(
|
||||
|
||||
@@ -75,7 +75,11 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
|
||||
.unwrap();
|
||||
let mut app = App::init(
|
||||
Core::default(),
|
||||
("/unused/editor.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/unused/editor.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
app.page = Page::Bar;
|
||||
@@ -95,7 +99,10 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(200.) },
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(200.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
@@ -129,7 +136,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
||||
if matches!(
|
||||
text,
|
||||
"Shell corner radius" | "Shell opacity" | "Background blur" | "Interface font" | "Focus border"
|
||||
"Shell corner radius"
|
||||
| "Shell opacity"
|
||||
| "Background blur"
|
||||
| "Interface font"
|
||||
| "Focus border"
|
||||
) {
|
||||
self.labels.push(bounds);
|
||||
}
|
||||
@@ -169,7 +180,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
let source = format!("theme {{ split #{split}; family \"ferese-blue\"; }}");
|
||||
let mut app = App::init(
|
||||
Core::default(),
|
||||
(PathBuf::from("/unused/scroll-test.kdl"), Snapshot::parse(source), None),
|
||||
(
|
||||
PathBuf::from("/unused/scroll-test.kdl"),
|
||||
Snapshot::parse(source),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
app.page = Page::Appearance;
|
||||
@@ -188,9 +203,11 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
let mut view = app.page_view();
|
||||
let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget()));
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
let event = if !(4..52).contains(&frame) {
|
||||
Event::Window(window::Event::RedrawRequested(std::time::Instant::now()))
|
||||
} else {
|
||||
@@ -233,7 +250,8 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
if (4..28).contains(&frame) {
|
||||
assert!(
|
||||
position.offset > previous
|
||||
|| position.offset >= position.content_height - position.viewport_height - 0.5,
|
||||
|| position.offset
|
||||
>= position.content_height - position.viewport_height - 0.5,
|
||||
"scroll stopped at {position:?}"
|
||||
);
|
||||
}
|
||||
@@ -264,23 +282,35 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &renderer, &mut labels);
|
||||
let started = std::time::Instant::now();
|
||||
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
physical,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
if (4..52).contains(&frame) {
|
||||
render_times.push(started.elapsed().as_micros());
|
||||
}
|
||||
if let Some(viewport) = labels.viewport {
|
||||
for bounds in labels.labels {
|
||||
let bounds = bounds - labels.translation;
|
||||
if bounds.y < viewport.y + 2. || bounds.y + bounds.height > viewport.y + viewport.height - 2. {
|
||||
if bounds.y < viewport.y + 2.
|
||||
|| bounds.y + bounds.height > viewport.y + viewport.height - 2.
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut ink = 0;
|
||||
for y in (bounds.y * scale).ceil() as u32..((bounds.y + bounds.height) * scale).floor() as u32 {
|
||||
for x in
|
||||
(bounds.x * scale).ceil() as u32..((bounds.x + bounds.width) * scale).floor() as u32
|
||||
for y in (bounds.y * scale).ceil() as u32
|
||||
..((bounds.y + bounds.height) * scale).floor() as u32
|
||||
{
|
||||
for x in (bounds.x * scale).ceil() as u32
|
||||
..((bounds.x + bounds.width) * scale).floor() as u32
|
||||
{
|
||||
let index = ((y * physical.width + x) * 4) as usize;
|
||||
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
|
||||
if pixels[index..index + 3]
|
||||
.iter()
|
||||
.any(|channel| *channel > 140)
|
||||
{
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +327,10 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
for message in messages {
|
||||
if matches!(message, Message::RowVisibility(..)) {
|
||||
visibility_messages += 1;
|
||||
assert!(frame < 56, "visibility kept changing after scrolling stopped");
|
||||
assert!(
|
||||
frame < 56,
|
||||
"visibility kept changing after scrolling stopped"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.update(message);
|
||||
@@ -305,7 +338,10 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
}
|
||||
|
||||
render_times.sort_unstable();
|
||||
assert!(checked_labels > 20, "scroll check must include visible field labels");
|
||||
assert!(
|
||||
checked_labels > 20,
|
||||
"scroll check must include visible field labels"
|
||||
);
|
||||
eprintln!(
|
||||
"backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}",
|
||||
render_times[render_times.len() / 2],
|
||||
@@ -323,7 +359,9 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
.expect("rendering tests require fontconfig");
|
||||
assert!(matched.status.success());
|
||||
let matched = String::from_utf8(matched.stdout).unwrap();
|
||||
let (family, path) = matched.split_once('\n').expect("rendering tests require a system font");
|
||||
let (family, path) = matched
|
||||
.split_once('\n')
|
||||
.expect("rendering tests require a system font");
|
||||
let font = ferese_theme::font(Some(family.split(',').next().unwrap()));
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
.write()
|
||||
@@ -393,7 +431,9 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
for (_, bounds, clip) in &mut self.labels[self.traversal_start..] {
|
||||
*bounds = *bounds - translation;
|
||||
*clip = Some(clip.map_or(viewport, |clip| {
|
||||
(clip - translation).intersection(&viewport).unwrap_or_default()
|
||||
(clip - translation)
|
||||
.intersection(&viewport)
|
||||
.unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -438,28 +478,37 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
} else {
|
||||
panel_controls::PanelPage::Arrange
|
||||
};
|
||||
app.panel_selection = (selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
|
||||
app.panel_group_selection = (selection == "status").then(|| ferese_panel::GroupId("status".into()));
|
||||
app.panel_selection =
|
||||
(selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
|
||||
app.panel_group_selection =
|
||||
(selection == "status").then(|| ferese_panel::GroupId("status".into()));
|
||||
let bounds = Rectangle::with_size(Size::new(width, 650.));
|
||||
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
|
||||
let theme = app.native_palette.native_theme();
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
let mut checked = 0;
|
||||
for offset in [0., 180., 400.] {
|
||||
for id in ["settings-content", "panel-inspector-scroll"] {
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new(id),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
let mut scroll =
|
||||
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new(id),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut scroll,
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
}
|
||||
renderer.reset(bounds);
|
||||
view.as_widget().draw(
|
||||
@@ -475,10 +524,13 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
mouse::Cursor::Unavailable,
|
||||
&bounds,
|
||||
);
|
||||
if let Some(mut overlay) =
|
||||
view.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
|
||||
{
|
||||
if let Some(mut overlay) = view.as_widget_mut().overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&bounds,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let node = overlay.layout(&renderer, bounds.size());
|
||||
renderer.with_layer(bounds, |renderer| {
|
||||
@@ -496,9 +548,18 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
});
|
||||
}
|
||||
let mut labels = Labels::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||
let pixels = Headless::screenshot(&mut renderer, physical, scale, theme.cosmic().bg_color().into());
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut labels,
|
||||
);
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
physical,
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
for (label, rect, clip) in labels.labels {
|
||||
let clip = clip.unwrap_or(bounds);
|
||||
if rect.width <= 0.
|
||||
@@ -511,10 +572,17 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
continue;
|
||||
}
|
||||
let mut ink = 0;
|
||||
for y in (rect.y * scale).ceil() as u32..((rect.y + rect.height) * scale).floor() as u32 {
|
||||
for x in (rect.x * scale).ceil() as u32..((rect.x + rect.width) * scale).floor() as u32 {
|
||||
for y in (rect.y * scale).ceil() as u32
|
||||
..((rect.y + rect.height) * scale).floor() as u32
|
||||
{
|
||||
for x in (rect.x * scale).ceil() as u32
|
||||
..((rect.x + rect.width) * scale).floor() as u32
|
||||
{
|
||||
let index = ((y * physical.width + x) * 4) as usize;
|
||||
if pixels[index..index + 3].iter().any(|channel| *channel > 140) {
|
||||
if pixels[index..index + 3]
|
||||
.iter()
|
||||
.any(|channel| *channel > 140)
|
||||
{
|
||||
ink += 1;
|
||||
}
|
||||
}
|
||||
@@ -526,7 +594,10 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 6, "state={selection} must exercise visible panel labels");
|
||||
assert!(
|
||||
checked >= 6,
|
||||
"state={selection} must exercise visible panel labels"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,7 +647,12 @@ fn render_panel_review_states() {
|
||||
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
|
||||
for (name, tab, item, group) in [
|
||||
("panels", panel_controls::PanelPage::Panels, None, None),
|
||||
("floating-groups", panel_controls::PanelPage::Panels, None, None),
|
||||
(
|
||||
"floating-groups",
|
||||
panel_controls::PanelPage::Panels,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"group-spacing-inspector",
|
||||
panel_controls::PanelPage::Arrange,
|
||||
@@ -585,7 +661,12 @@ fn render_panel_review_states() {
|
||||
),
|
||||
("corners", panel_controls::PanelPage::Panels, None, None),
|
||||
("morph", panel_controls::PanelPage::Panels, None, None),
|
||||
("preview-hover", panel_controls::PanelPage::Panels, None, None),
|
||||
(
|
||||
"preview-hover",
|
||||
panel_controls::PanelPage::Panels,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
("navigation", panel_controls::PanelPage::Arrange, None, None),
|
||||
("shortcuts", panel_controls::PanelPage::Panels, None, None),
|
||||
("motion", panel_controls::PanelPage::Panels, None, None),
|
||||
@@ -616,10 +697,14 @@ fn render_panel_review_states() {
|
||||
};
|
||||
app.draft = Snapshot::parse(String::new()).unwrap();
|
||||
if matches!(name, "floating-groups" | "group-spacing-inspector") {
|
||||
let mut panel = ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
let mut panel =
|
||||
ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||
panel.geometry.edge_margin = Some(6);
|
||||
app.draft
|
||||
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
|
||||
.edit(&crate::store::set(
|
||||
"panels",
|
||||
serde_json::to_value(vec![panel]).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
if name == "morph" {
|
||||
@@ -652,9 +737,11 @@ fn render_panel_review_states() {
|
||||
let theme = app.native_palette.native_theme();
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
#[derive(Default)]
|
||||
struct HoverTarget(Option<Rectangle>);
|
||||
impl Operation for HoverTarget {
|
||||
@@ -669,9 +756,18 @@ fn render_panel_review_states() {
|
||||
}
|
||||
let cursor = if name == "preview-hover" {
|
||||
let mut target = HoverTarget::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut target);
|
||||
mouse::Cursor::Available(target.0.expect("battery is visible in the review preview").center())
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut target,
|
||||
);
|
||||
mouse::Cursor::Available(
|
||||
target
|
||||
.0
|
||||
.expect("battery is visible in the review preview")
|
||||
.center(),
|
||||
)
|
||||
} else {
|
||||
mouse::Cursor::Unavailable
|
||||
};
|
||||
@@ -690,10 +786,13 @@ fn render_panel_review_states() {
|
||||
cursor,
|
||||
&bounds,
|
||||
);
|
||||
if let Some(mut overlay) =
|
||||
view.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &bounds, Vector::ZERO)
|
||||
{
|
||||
if let Some(mut overlay) = view.as_widget_mut().overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&bounds,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let layout = overlay.layout(&renderer, bounds.size());
|
||||
renderer.with_layer(bounds, |renderer| {
|
||||
@@ -784,7 +883,9 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
for selection in [
|
||||
None,
|
||||
Some(Message::PanelSelect(ferese_panel::ItemId("battery".into()))),
|
||||
Some(Message::PanelGroupSelect(ferese_panel::GroupId("status".into()))),
|
||||
Some(Message::PanelGroupSelect(ferese_panel::GroupId(
|
||||
"status".into(),
|
||||
))),
|
||||
Some(Message::PanelClearSelection),
|
||||
] {
|
||||
if let Some(selection) = selection {
|
||||
@@ -799,10 +900,14 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
|
||||
);
|
||||
if initial.is_none() {
|
||||
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset { x: None, y: Some(180.) },
|
||||
);
|
||||
let mut scroll =
|
||||
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("settings-content"),
|
||||
cosmic::iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(180.),
|
||||
},
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), &renderer, &mut scroll);
|
||||
}
|
||||
@@ -816,7 +921,10 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
let editor = panes.editor.unwrap();
|
||||
let inspector = panes.inspector.unwrap();
|
||||
assert!(editor.x + editor.width < inspector.x, "panes overlap");
|
||||
assert!(inspector.x + inspector.width <= width, "inspector leaves window");
|
||||
assert!(
|
||||
inspector.x + inspector.width <= width,
|
||||
"inspector leaves window"
|
||||
);
|
||||
assert!(editor.height > 100. && inspector.height > 100.);
|
||||
if let Some(initial) = &initial {
|
||||
assert_eq!(&panes, initial);
|
||||
@@ -890,22 +998,31 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
|
||||
assert_eq!(bounds.sidebar.is_none(), compact);
|
||||
assert_eq!(bounds.toggle.is_some(), compact);
|
||||
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 204. });
|
||||
assert_eq!(bounds.page.unwrap().width, if compact { width } else { width - 204. });
|
||||
assert_eq!(
|
||||
bounds.page.unwrap().width,
|
||||
if compact { width } else { width - 204. }
|
||||
);
|
||||
}
|
||||
let _ = app.update(Message::Sidebar(true));
|
||||
assert!(app.sidebar_open);
|
||||
let mut view = app.page_view();
|
||||
let mut tree = Tree::new(&view);
|
||||
let size = Size::new(740., 650.);
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
|
||||
let mut messages = Vec::new();
|
||||
{
|
||||
let viewport = Rectangle::with_size(size);
|
||||
let mut overlay = view
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree, Layout::new(&node), &renderer, &viewport, Vector::ZERO)
|
||||
.overlay(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&viewport,
|
||||
Vector::ZERO,
|
||||
)
|
||||
.expect("compact navigation overlay");
|
||||
let overlay = overlay.as_overlay_mut();
|
||||
let layout = overlay.layout(&renderer, size);
|
||||
@@ -937,6 +1054,9 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
|
||||
assert!(!app.sidebar_open);
|
||||
let _ = app.update(Message::Sidebar(true));
|
||||
let _ = app.update(Message::Page(Page::Bar));
|
||||
assert!(!app.sidebar_open, "reselecting the current page closes navigation too");
|
||||
assert!(
|
||||
!app.sidebar_open,
|
||||
"reselecting the current page closes navigation too"
|
||||
);
|
||||
assert!(app.pending.is_empty() && !app.saving);
|
||||
}
|
||||
@@ -49,7 +49,10 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn string(&self, path: &str, fallback: &str) -> String {
|
||||
self.item(path).and_then(Value::as_str).unwrap_or(fallback).to_owned()
|
||||
self.item(path)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub fn number(&self, path: &str, fallback: f64) -> f64 {
|
||||
@@ -61,7 +64,9 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn records(&self, path: &str) -> usize {
|
||||
self.item(path).and_then(Value::as_array).map_or(0, |a| a.len())
|
||||
self.item(path)
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, |a| a.len())
|
||||
}
|
||||
|
||||
pub fn argv(&self, path: &str) -> String {
|
||||
@@ -129,7 +134,9 @@ pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
.arg(format!("--file-filter={filter}"))
|
||||
.arg("--file-filter=All files | *")
|
||||
.output()
|
||||
.map_err(|e| format!("Could not open the file chooser: {e}. Install zenity to choose files."))?;
|
||||
.map_err(|e| {
|
||||
format!("Could not open the file chooser: {e}. Install zenity to choose files.")
|
||||
})?;
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -139,7 +146,8 @@ pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
}
|
||||
@@ -204,7 +212,11 @@ fn save_with(
|
||||
if file.source() != expected {
|
||||
return Err("Your config changed in another app. Reload settings before saving.".into());
|
||||
}
|
||||
file.replace(desired, Some(&path.with_extension("kdl.settings-backup")), validate)?;
|
||||
file.replace(
|
||||
desired,
|
||||
Some(&path.with_extension("kdl.settings-backup")),
|
||||
validate,
|
||||
)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
@@ -332,11 +344,17 @@ window-rule app-id="mine" floating=#true
|
||||
|
||||
#[test]
|
||||
fn nested_array_edits_and_login_items_roundtrip() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into())
|
||||
.unwrap();
|
||||
snapshot.edit(&set("output_profiles.0.outputs.0.scale", 1.75)).unwrap();
|
||||
assert_eq!(snapshot.number("output_profiles.0.outputs.0.scale", 1.), 1.75);
|
||||
let mut snapshot = Snapshot::parse(
|
||||
"output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot
|
||||
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.number("output_profiles.0.outputs.0.scale", 1.),
|
||||
1.75
|
||||
);
|
||||
snapshot
|
||||
.edit(&Edit::Add(
|
||||
"autostart".into(),
|
||||
@@ -366,12 +384,17 @@ window-rule app-id="mine" floating=#true
|
||||
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
|
||||
fn all_gui_controls_and_presets_pass_compositor_validation() {
|
||||
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
|
||||
let initial = Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap();
|
||||
let initial =
|
||||
Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let validate = |snapshot: &Snapshot| {
|
||||
fs::write(&path, &snapshot.source).unwrap();
|
||||
let result = Command::new(&binary).arg("--check-config").arg(&path).output().unwrap();
|
||||
let result = Command::new(&binary)
|
||||
.arg("--check-config")
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
@@ -385,7 +408,9 @@ window-rule app-id="mine" floating=#true
|
||||
use crate::schema::Kind;
|
||||
let values: Vec<Value> = match field.kind {
|
||||
Kind::Toggle(_) => vec![true.into(), false.into()],
|
||||
Kind::Range { min, max, integer, .. } => {
|
||||
Kind::Range {
|
||||
min, max, integer, ..
|
||||
} => {
|
||||
if integer {
|
||||
vec![(min as i64).into(), (max as i64).into()]
|
||||
} else {
|
||||
@@ -393,7 +418,9 @@ window-rule app-id="mine" floating=#true
|
||||
}
|
||||
}
|
||||
Kind::Text { default, .. } => vec![default.into()],
|
||||
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
|
||||
Kind::Choice { choices, .. } => {
|
||||
choices.iter().map(|(v, _)| (*v).into()).collect()
|
||||
}
|
||||
};
|
||||
for value in values {
|
||||
let mut snapshot = initial.clone();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row,
|
||||
schema, set, text_input, visuals, widget,
|
||||
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container,
|
||||
gallery, row, schema, set, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -10,7 +10,10 @@ pub(super) struct FamilySections {
|
||||
}
|
||||
|
||||
impl FamilySections {
|
||||
pub(super) fn new(families: &[ferese_config::families::Family], snapshot: &crate::store::Snapshot) -> Self {
|
||||
pub(super) fn new(
|
||||
families: &[ferese_config::families::Family],
|
||||
snapshot: &crate::store::Snapshot,
|
||||
) -> Self {
|
||||
let (custom, built_in) = (0..families.len()).partition(|&index| {
|
||||
snapshot
|
||||
.item(&format!("theme.custom_themes.{}", families[index].id))
|
||||
@@ -73,7 +76,10 @@ impl App {
|
||||
button::custom(self.label(label, 12.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([4, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.class(visuals::button_style(
|
||||
palette,
|
||||
index == self.theme_file_target,
|
||||
))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
@@ -86,7 +92,11 @@ impl App {
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(self.settings_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
if file.is_empty() {
|
||||
"Choose…"
|
||||
} else {
|
||||
"Replace…"
|
||||
},
|
||||
"M3 7V5h6l2 2h10v13H3z",
|
||||
Some(Message::PickThemeFile(path)),
|
||||
false,
|
||||
@@ -171,7 +181,11 @@ impl App {
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
.width(if field.path.ends_with("timezone") {
|
||||
150
|
||||
} else {
|
||||
75
|
||||
});
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
@@ -189,7 +203,10 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'_, Message> {
|
||||
pub(super) fn theme_gallery(
|
||||
&self,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> Element<'_, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
|
||||
self.label(
|
||||
@@ -254,7 +271,10 @@ impl App {
|
||||
let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) {
|
||||
self.theme_gallery_row(chunk, appearance, custom)
|
||||
} else {
|
||||
widget::Space::new().height(height).width(Length::Fill).into()
|
||||
widget::Space::new()
|
||||
.height(height)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
cosmic::iced::widget::sensor(
|
||||
container(content)
|
||||
@@ -293,7 +313,10 @@ impl App {
|
||||
) -> Option<(&'static str, usize)> {
|
||||
let index = self.family_ids.iter().position(|family| family == id)?;
|
||||
let (custom, position) = self.family_sections.position(index)?;
|
||||
Some((crate::navigation::gallery_list(appearance, custom), position / 3))
|
||||
Some((
|
||||
crate::navigation::gallery_list(appearance, custom),
|
||||
position / 3,
|
||||
))
|
||||
}
|
||||
|
||||
fn theme_gallery_row(
|
||||
|
||||
@@ -2,7 +2,9 @@ use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::controls::{
|
||||
button_style, navigation_style, settings_input as input_style, surface,
|
||||
};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
@@ -100,16 +102,24 @@ pub fn split(snapshot: &Snapshot) -> bool {
|
||||
.item("theme.split")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
|
||||
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
|
||||
ferese_config::families::family_id(
|
||||
&snapshot.string("theme.light.preset", "ferese-blue-light"),
|
||||
) != ferese_config::families::family_id(
|
||||
&snapshot.string("theme.dark.preset", "ferese-blue"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
|
||||
pub fn family_selection(
|
||||
snapshot: &Snapshot,
|
||||
appearance: Option<ferese_config::theme::Appearance>,
|
||||
) -> String {
|
||||
let Some(appearance) = appearance else {
|
||||
return snapshot.string(
|
||||
"theme.family",
|
||||
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
|
||||
ferese_config::families::family_id(
|
||||
&snapshot.string("theme.dark.preset", "ferese-blue"),
|
||||
),
|
||||
);
|
||||
};
|
||||
let kind = if appearance == ferese_config::theme::Appearance::Light {
|
||||
@@ -161,7 +171,11 @@ pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearan
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
|
||||
pub fn toggle_split(
|
||||
snapshot: &Snapshot,
|
||||
enabled: bool,
|
||||
active: ferese_config::theme::Appearance,
|
||||
) -> Vec<Edit> {
|
||||
let mut edits = vec![];
|
||||
if enabled {
|
||||
let id = family_selection(snapshot, None);
|
||||
@@ -183,14 +197,19 @@ pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::t
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
|
||||
edits.push(set(
|
||||
"theme.family",
|
||||
family_selection(snapshot, Some(active)),
|
||||
));
|
||||
}
|
||||
edits.push(set("theme.split", enabled));
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn paint_style_overrides(snapshot: &Snapshot, edit: &Edit) -> Vec<Edit> {
|
||||
let Edit::Set(path, _) = edit else { return vec![] };
|
||||
let Edit::Set(path, _) = edit else {
|
||||
return vec![];
|
||||
};
|
||||
let section = match path.as_str() {
|
||||
"theme.focus_ring.style" => "focus_ring",
|
||||
"theme.border.style" => "border",
|
||||
@@ -223,7 +242,9 @@ pub fn preset(index: usize) -> Vec<Edit> {
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else { return false };
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
@@ -242,27 +263,38 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
|
||||
}
|
||||
|
||||
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
|
||||
pub fn preview_resolved<'a>(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'a, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
|
||||
fn preview(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'static, Message> {
|
||||
let p = Palette::from_resolved(theme);
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
let muted = hex(p.muted);
|
||||
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
|
||||
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
|
||||
let radius = snapshot
|
||||
.number("theme.geometry.window_radius", 14.)
|
||||
.clamp(0., 28.);
|
||||
let bar_y = snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.;
|
||||
let bar_margin = snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.;
|
||||
let bar_height = snapshot.number("panels.0.geometry.height", 28.) * 0.65;
|
||||
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
|
||||
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
|
||||
snapshot
|
||||
.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.number(
|
||||
"theme.material.opacity",
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY,
|
||||
)
|
||||
.clamp(0., 1.)
|
||||
} else {
|
||||
1.
|
||||
@@ -344,7 +376,11 @@ mod tests {
|
||||
assert_eq!(theme.tokens.focus_ring.gradient.unwrap().from, from);
|
||||
}
|
||||
|
||||
assert!(snapshot.item(&format!("theme.{mode}.focus_ring.gradient")).is_some());
|
||||
assert!(
|
||||
snapshot
|
||||
.item(&format!("theme.{mode}.focus_ring.gradient"))
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,7 +447,10 @@ mod tests {
|
||||
family_selection(&snapshot, Some(ferese_config::theme::Appearance::Dark)),
|
||||
"catppuccin"
|
||||
);
|
||||
assert_eq!(snapshot.string("theme.dark.background.path", ""), "/wallpaper.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.dark.background.path", ""),
|
||||
"/wallpaper.png"
|
||||
);
|
||||
assert_eq!(snapshot.string("theme.border.style", ""), "solid");
|
||||
for mode in ["light", "dark"] {
|
||||
assert!(snapshot.item(&format!("theme.{mode}.focus_ring")).is_none());
|
||||
@@ -432,7 +471,10 @@ mod tests {
|
||||
};
|
||||
let actual = resolve(&snapshot);
|
||||
let expected = resolve(&reference);
|
||||
assert_eq!(actual.tokens.focus_ring, expected.tokens.focus_ring, "{id}/{mode}");
|
||||
assert_eq!(
|
||||
actual.tokens.focus_ring, expected.tokens.focus_ring,
|
||||
"{id}/{mode}"
|
||||
);
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&actual).accent_gradient,
|
||||
Palette::from_resolved(&expected).accent_gradient
|
||||
@@ -463,8 +505,16 @@ mod tests {
|
||||
};
|
||||
let palette = Palette::from_resolved(&theme);
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
assert!(
|
||||
contrast(palette.text, background) >= 4.5,
|
||||
"{} text",
|
||||
preset.name
|
||||
);
|
||||
assert!(
|
||||
contrast(palette.muted, background) >= 4.5,
|
||||
"{} muted text",
|
||||
preset.name
|
||||
);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
if preset.appearance == other.appearance {
|
||||
@@ -486,18 +536,27 @@ mod tests {
|
||||
"dark"
|
||||
};
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
|
||||
.edit(&set(
|
||||
&format!("theme.{kind}.surface.bar.text_primary"),
|
||||
"#123456",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
|
||||
.edit(&set(
|
||||
&format!("theme.{kind}.focus_ring.gradient.to"),
|
||||
"#123456",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
assert!(preset(usize::MAX).is_empty());
|
||||
assert!(!preset_selected(&Snapshot::parse(String::new()).unwrap(), usize::MAX));
|
||||
assert!(!preset_selected(
|
||||
&Snapshot::parse(String::new()).unwrap(),
|
||||
usize::MAX
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -510,7 +569,10 @@ mod tests {
|
||||
..ferese_config::theme::default_theme()
|
||||
};
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
|
||||
Palette::from_resolved(&resolved)
|
||||
.native_theme()
|
||||
.cosmic()
|
||||
.is_dark,
|
||||
item.appearance == ferese_config::theme::Appearance::Dark
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,12 @@ impl Preview {
|
||||
true
|
||||
}
|
||||
|
||||
fn finish(&mut self, path: String, desired: &str, result: Result<widget::image::Handle, String>) {
|
||||
fn finish(
|
||||
&mut self,
|
||||
path: String,
|
||||
desired: &str,
|
||||
result: Result<widget::image::Handle, String>,
|
||||
) {
|
||||
if self.requested.as_deref() != Some(&path) {
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +176,11 @@ fn thumbnail(path: String, builtin: Option<Appearance>) -> Task<Message> {
|
||||
limits.max_image_width = Some(16384);
|
||||
limits.max_image_height = Some(16384);
|
||||
reader.limits(limits);
|
||||
let (width, height) = if builtin.is_some() { (400, 225) } else { (1000, 500) };
|
||||
let (width, height) = if builtin.is_some() {
|
||||
(400, 225)
|
||||
} else {
|
||||
(1000, 500)
|
||||
};
|
||||
let pixels = reader
|
||||
.decode()
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -240,9 +249,12 @@ impl App {
|
||||
return path;
|
||||
}
|
||||
|
||||
ferese_config::theme::theme_path(self.path.parent().unwrap_or(std::path::Path::new(".")), path.as_ref())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
ferese_config::theme::theme_path(
|
||||
self.path.parent().unwrap_or(std::path::Path::new(".")),
|
||||
path.as_ref(),
|
||||
)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn load_thumbnail(&mut self) -> Task<Message> {
|
||||
@@ -266,7 +278,11 @@ impl App {
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn wallpaper_thumbnail(&mut self, path: String, result: Result<widget::image::Handle, String>) {
|
||||
pub(super) fn wallpaper_thumbnail(
|
||||
&mut self,
|
||||
path: String,
|
||||
result: Result<widget::image::Handle, String>,
|
||||
) {
|
||||
let desired = self.active_wallpaper_path();
|
||||
self.wallpaper.active.finish(path, &desired, result);
|
||||
}
|
||||
@@ -293,9 +309,13 @@ impl App {
|
||||
schema::fields(Page::Wallpaper)
|
||||
.into_iter()
|
||||
.map(|mut field| {
|
||||
field.path = field.path.replace("theme.background", self.wallpaper.target.prefix());
|
||||
field.path = field
|
||||
.path
|
||||
.replace("theme.background", self.wallpaper.target.prefix());
|
||||
if let Kind::Text { default, .. } = &mut field.kind {
|
||||
*default = ferese_config::default_wallpaper_for(self.wallpaper.target.appearance(active));
|
||||
*default = ferese_config::default_wallpaper_for(
|
||||
self.wallpaper.target.appearance(active),
|
||||
);
|
||||
}
|
||||
|
||||
field
|
||||
@@ -319,7 +339,9 @@ impl App {
|
||||
pub(super) fn wallpaper_controls(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let preview = &self.wallpaper.active;
|
||||
let mut body = column([]).spacing(8).push(self.label("Current wallpaper", 13.));
|
||||
let mut body = column([])
|
||||
.spacing(8)
|
||||
.push(self.label("Current wallpaper", 13.));
|
||||
let current: Element<'_, Message> = if let Some(handle) = &preview.handle {
|
||||
widget::image(handle.clone())
|
||||
.border_radius(palette.radius.min(10.))
|
||||
@@ -333,13 +355,20 @@ impl App {
|
||||
if preview.requested.is_some() {
|
||||
"Loading preview…"
|
||||
} else {
|
||||
preview.error.as_deref().unwrap_or("No wallpaper image selected.")
|
||||
preview
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("No wallpaper image selected.")
|
||||
},
|
||||
palette,
|
||||
180.,
|
||||
)
|
||||
};
|
||||
body = body.push(widget::container(current).height(180).center_x(Length::Fill));
|
||||
body = body.push(
|
||||
widget::container(current)
|
||||
.height(180)
|
||||
.center_x(Length::Fill),
|
||||
);
|
||||
|
||||
body = body.push(
|
||||
row([])
|
||||
@@ -347,9 +376,13 @@ impl App {
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Apply to", 12.))
|
||||
.push(ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(Target::ALL, Some(self.wallpaper.target), Message::WallpaperTarget)
|
||||
.font(self.font)
|
||||
.text_size(12),
|
||||
cosmic::iced::widget::pick_list(
|
||||
Target::ALL,
|
||||
Some(self.wallpaper.target),
|
||||
Message::WallpaperTarget,
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12),
|
||||
palette,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
@@ -378,11 +411,18 @@ impl App {
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.into()
|
||||
} else {
|
||||
self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
|
||||
self.wallpaper_placeholder(
|
||||
preview.error.as_deref().unwrap_or("Loading preview…"),
|
||||
palette,
|
||||
110.,
|
||||
)
|
||||
};
|
||||
let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(palette.text)))
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill);
|
||||
let label = widget::container(
|
||||
self.label(name, 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.text)),
|
||||
)
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill);
|
||||
let tile = column([]).push(image).push(label);
|
||||
|
||||
defaults = defaults.push(
|
||||
@@ -467,7 +507,11 @@ mod tests {
|
||||
for width in [420., 760.] {
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/preview/config.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
let dimensions = |app: &App| {
|
||||
@@ -495,7 +539,11 @@ mod tests {
|
||||
.finish("/active.png".into(), "/active.png", Ok(handle.clone()));
|
||||
|
||||
for index in 0..app.wallpaper.defaults.len() {
|
||||
app.wallpaper.defaults[index].finish("/default.png".into(), "/default.png", Ok(handle.clone()));
|
||||
app.wallpaper.defaults[index].finish(
|
||||
"/default.png".into(),
|
||||
"/default.png",
|
||||
Ok(handle.clone()),
|
||||
);
|
||||
assert_eq!(dimensions(&app), initial);
|
||||
}
|
||||
|
||||
@@ -526,13 +574,21 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut app = App::init(
|
||||
cosmic::app::Core::default(),
|
||||
("/preview/config.kdl".into(), Snapshot::parse(String::new()), None),
|
||||
(
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
for appearance in [Appearance::Light, Appearance::Dark] {
|
||||
let path = ferese_config::default_wallpaper_for(appearance);
|
||||
let pixels = image::open(path).unwrap().thumbnail(400, 225).into_rgba8();
|
||||
let handle = widget::image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw());
|
||||
let handle = widget::image::Handle::from_rgba(
|
||||
pixels.width(),
|
||||
pixels.height(),
|
||||
pixels.into_raw(),
|
||||
);
|
||||
assert!(app.wallpaper.defaults[index(appearance)].request(path));
|
||||
app.default_wallpaper_thumbnail(appearance, Ok(handle));
|
||||
}
|
||||
@@ -596,7 +652,11 @@ mod tests {
|
||||
1.25,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
let preview = if page == Page::Wallpaper { "wallpaper" } else { "theme" };
|
||||
let preview = if page == Page::Wallpaper {
|
||||
"wallpaper"
|
||||
} else {
|
||||
"theme"
|
||||
};
|
||||
let path = format!("/tmp/ferese-{preview}-{backend}-{name}.png");
|
||||
image::save_buffer(&path, &pixels, 1250, 975, image::ColorType::Rgba8).unwrap();
|
||||
eprintln!("Preview page: {path}");
|
||||
@@ -617,13 +677,17 @@ mod tests {
|
||||
assert_eq!(snapshot.string("theme.background.path", ""), "/shared.png");
|
||||
assert!(snapshot.item("theme.light.background.path").is_none());
|
||||
assert!(snapshot.item("theme.dark.background.path").is_none());
|
||||
assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.background.lock_path", ""),
|
||||
"/lock.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_one_mode_preserves_the_other_and_inherits_shared_settings() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("theme { background { path \"/shared.png\"; mode \"fit\"; }; }".into()).unwrap();
|
||||
Snapshot::parse("theme { background { path \"/shared.png\"; mode \"fit\"; }; }".into())
|
||||
.unwrap();
|
||||
for edit in edits(Target::Light, "path", "/light.png") {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
@@ -641,11 +705,18 @@ mod tests {
|
||||
}
|
||||
|
||||
for target in Target::ALL {
|
||||
assert!(snapshot.item(&format!("{}.path", target.prefix())).is_none());
|
||||
assert!(
|
||||
snapshot
|
||||
.item(&format!("{}.path", target.prefix()))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(snapshot.string("theme.background.mode", ""), "fit");
|
||||
assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png");
|
||||
assert_eq!(
|
||||
snapshot.string("theme.background.lock_path", ""),
|
||||
"/lock.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let path = match std::path::absolute(path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -31,7 +33,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let mut watcher = match watcher {
|
||||
Ok(watcher) => watcher,
|
||||
Err(error) => {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -46,7 +50,9 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
.map_err(notify::Error::io)
|
||||
.and_then(|()| watcher.watch(directory, RecursiveMode::NonRecursive));
|
||||
if let Err(error) = watch {
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,11 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
|
||||
let snapshot = tokio::task::spawn_blocking(move || Snapshot::read(&file))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
if output.send(Message::ExternalConfig(snapshot)).await.is_err() {
|
||||
if output
|
||||
.send(Message::ExternalConfig(snapshot))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if events.recv().await.is_none() {
|
||||
@@ -90,7 +100,10 @@ mod tests {
|
||||
let path = directory.path().join("ferese/config.kdl");
|
||||
let stream = changes(&path);
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(Message::ExternalConfig(Ok(_)))
|
||||
));
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let source = "animations { speed 0.75; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
@@ -117,7 +130,10 @@ mod tests {
|
||||
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
|
||||
let stream = changes(&path);
|
||||
pin_mut!(stream);
|
||||
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
|
||||
assert!(matches!(
|
||||
stream.next().await,
|
||||
Some(Message::ExternalConfig(Ok(_)))
|
||||
));
|
||||
for source in ["bad [", "animations {\n speed 0.75\n}\n"] {
|
||||
let temporary = directory.path().join("replacement");
|
||||
std::fs::write(&temporary, source).unwrap();
|
||||
|
||||
@@ -18,7 +18,12 @@ use std::time::Duration;
|
||||
|
||||
// Expanded lists retain result rows under DND. Compact selection applies DND
|
||||
// separately, so rendered rows and keyboard focus always share this projection.
|
||||
fn list_entries<'a>(snapshot: &'a Snapshot, native: &'a [Activity], now: u64, output: &str) -> Vec<&'a Activity> {
|
||||
fn list_entries<'a>(
|
||||
snapshot: &'a Snapshot,
|
||||
native: &'a [Activity],
|
||||
now: u64,
|
||||
output: &str,
|
||||
) -> Vec<&'a Activity> {
|
||||
ranked(
|
||||
snapshot.activities.iter().chain(native.iter()),
|
||||
now,
|
||||
@@ -87,7 +92,10 @@ struct FocusedActivity {
|
||||
focused: Option<Id>,
|
||||
}
|
||||
impl cosmic::iced::advanced::widget::Operation<Option<Id>> for FocusedActivity {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<Id>>)) {
|
||||
fn traverse(
|
||||
&mut self,
|
||||
operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<Id>>),
|
||||
) {
|
||||
operate(self);
|
||||
}
|
||||
fn focusable(
|
||||
@@ -124,13 +132,17 @@ fn icon(name: &str) -> &'static [u8] {
|
||||
}
|
||||
fn progress_percent(activity: &Activity) -> Option<u8> {
|
||||
match activity.data.value {
|
||||
Value::Progress(value) if activity.state == State::Running => Some((value.clamp(0., 1.) * 100.).round() as u8),
|
||||
Value::Progress(value) if activity.state == State::Running => {
|
||||
Some((value.clamp(0., 1.) * 100.).round() as u8)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(activity: &Activity) -> Option<String> {
|
||||
progress_percent(activity).is_none().then(|| label(activity))
|
||||
progress_percent(activity)
|
||||
.is_none()
|
||||
.then(|| label(activity))
|
||||
}
|
||||
|
||||
fn label(activity: &Activity) -> String {
|
||||
@@ -147,7 +159,12 @@ fn label(activity: &Activity) -> String {
|
||||
Value::Text(text) => text.clone(),
|
||||
Value::Progress(progress) => format!("{}%", (progress * 100.).round() as u32),
|
||||
Value::Countdown { .. } | Value::PausedCountdown { .. } => {
|
||||
let seconds = activity.data.value.remaining_ms(now_ms()).unwrap_or(0).div_ceil(1_000);
|
||||
let seconds = activity
|
||||
.data
|
||||
.value
|
||||
.remaining_ms(now_ms())
|
||||
.unwrap_or(0)
|
||||
.div_ceil(1_000);
|
||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||
}
|
||||
Value::Indeterminate => {
|
||||
@@ -293,17 +310,28 @@ impl FereseShell {
|
||||
.map(|output| output.name.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, &name);
|
||||
let entries = list_entries(
|
||||
&self.activities.snapshot,
|
||||
&self.activities.native,
|
||||
now,
|
||||
&name,
|
||||
);
|
||||
let compact: Vec<_> = entries
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media)
|
||||
.filter(|entry| {
|
||||
entry.eligible(now, dnd)
|
||||
&& entry.data.source != ferese_activities::Source::Media
|
||||
})
|
||||
.collect();
|
||||
let presentation = self.activities.flat.entry(output.bar).or_default();
|
||||
presentation
|
||||
.selection
|
||||
.select(&compact, self.activities.snapshot.limits, dnd, 0);
|
||||
let rows = entries.iter().map(|entry| entry.id.clone()).collect::<Vec<_>>();
|
||||
let rows = entries
|
||||
.iter()
|
||||
.map(|entry| entry.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
reconcile_focus(&mut presentation.focus, &presentation.rows, &rows);
|
||||
presentation.rows = rows;
|
||||
continue;
|
||||
@@ -313,7 +341,12 @@ impl FereseShell {
|
||||
.output_for_bar(output.bar)
|
||||
.map(|output| output.name.as_str())
|
||||
.unwrap_or("");
|
||||
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, name);
|
||||
let entries = list_entries(
|
||||
&self.activities.snapshot,
|
||||
&self.activities.native,
|
||||
now,
|
||||
name,
|
||||
);
|
||||
let workspace_width = self.hub_workspace_width(output.bar) as f32;
|
||||
let panel = self.panel_for_bar(output.bar);
|
||||
let available = super::panel_hub::content_width(panel, output.size) as f32
|
||||
@@ -328,16 +361,22 @@ impl FereseShell {
|
||||
let compact: Vec<_> = entries
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media)
|
||||
.filter(|entry| {
|
||||
entry.eligible(now, dnd)
|
||||
&& entry.data.source != ferese_activities::Source::Media
|
||||
})
|
||||
.collect();
|
||||
let compact = if self.compact_activities_configured(output.bar) {
|
||||
compact
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
presentation
|
||||
.selection
|
||||
.select(&compact, self.activities.snapshot.limits, dnd, protected);
|
||||
presentation.selection.select(
|
||||
&compact,
|
||||
self.activities.snapshot.limits,
|
||||
dnd,
|
||||
protected,
|
||||
);
|
||||
let rows: Vec<_> = entries.iter().map(|entry| entry.id.clone()).collect();
|
||||
reconcile_focus(&mut presentation.focus, &presentation.rows, &rows);
|
||||
presentation.rows = rows;
|
||||
@@ -355,11 +394,14 @@ impl FereseShell {
|
||||
.unwrap_or(now)
|
||||
.saturating_sub(entry.result_flash_ms),
|
||||
);
|
||||
presentation.tints.entry(entry.id.clone()).or_insert_with(|| {
|
||||
let mut value = AnimatedValue::new(if age < 400 { 1. } else { 0. });
|
||||
value.set_target(0.);
|
||||
value
|
||||
});
|
||||
presentation
|
||||
.tints
|
||||
.entry(entry.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let mut value = AnimatedValue::new(if age < 400 { 1. } else { 0. });
|
||||
value.set_target(0.);
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
let target = f64::from(presentation.selection.visible.len() == 2);
|
||||
@@ -367,9 +409,12 @@ impl FereseShell {
|
||||
.divider
|
||||
.get_or_insert_with(|| AnimatedValue::new(0.))
|
||||
.retarget_preserving_motion(target);
|
||||
presentation
|
||||
.compact
|
||||
.update(&entries, &presentation.selection, self.render.font, available);
|
||||
presentation.compact.update(
|
||||
&entries,
|
||||
&presentation.selection,
|
||||
self.render.font,
|
||||
available,
|
||||
);
|
||||
let compact_width = presentation.compact.width;
|
||||
if !presentation.selection.visible.is_empty() {
|
||||
runtime.idle_content_width = Some(f64::from(compact_width));
|
||||
@@ -402,29 +447,35 @@ impl FereseShell {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.outputs.iter().filter(|output| !output.hidden).any(|output| {
|
||||
let list = self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == status_ui::Menu::Activities && menu.anchor.parent == output.bar);
|
||||
let routed = self
|
||||
.output_for_bar(output.bar)
|
||||
.is_some_and(|output| activity.data.output.as_deref() == Some(&output.name));
|
||||
if let Some(runtime) = &output.runtime.hub {
|
||||
let runtime = runtime.borrow();
|
||||
routed
|
||||
&& (runtime.activities.selection.visible.contains(&activity.id)
|
||||
|| runtime.hub.mode()
|
||||
== ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Activities))
|
||||
} else {
|
||||
routed
|
||||
&& (list
|
||||
|| self.activities.flat.get(&output.bar).is_some_and(|presentation| {
|
||||
self.compact_activities_configured(output.bar)
|
||||
&& presentation.selection.visible.contains(&activity.id)
|
||||
}))
|
||||
}
|
||||
})
|
||||
self.outputs
|
||||
.iter()
|
||||
.filter(|output| !output.hidden)
|
||||
.any(|output| {
|
||||
let list = self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == status_ui::Menu::Activities && menu.anchor.parent == output.bar
|
||||
});
|
||||
let routed = self.output_for_bar(output.bar).is_some_and(|output| {
|
||||
activity.data.output.as_deref() == Some(&output.name)
|
||||
});
|
||||
if let Some(runtime) = &output.runtime.hub {
|
||||
let runtime = runtime.borrow();
|
||||
routed
|
||||
&& (runtime.activities.selection.visible.contains(&activity.id)
|
||||
|| runtime.hub.mode()
|
||||
== ferese_panel::morph::Mode::Expanded(
|
||||
ferese_panel::morph::Content::Activities,
|
||||
))
|
||||
} else {
|
||||
routed
|
||||
&& (list
|
||||
|| self.activities.flat.get(&output.bar).is_some_and(
|
||||
|presentation| {
|
||||
self.compact_activities_configured(output.bar)
|
||||
&& presentation.selection.visible.contains(&activity.id)
|
||||
},
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
if visible {
|
||||
cosmic::iced::time::every(Duration::from_secs(1)).map(|_| ())
|
||||
@@ -456,20 +507,24 @@ impl FereseShell {
|
||||
reduced,
|
||||
ring_color,
|
||||
);
|
||||
let center: Element<'_, cosmic::Action<Message>> = if let Some(percent) = progress_percent(activity) {
|
||||
self.render
|
||||
.text(percent.to_string())
|
||||
.size(if percent == 100 { 9. } else { 10. })
|
||||
.line_height(cosmic::iced::widget::text::LineHeight::Relative(1.))
|
||||
.class(theme::Text::Color(color))
|
||||
.into()
|
||||
} else {
|
||||
icons::tinted(icon(&activity.data.icon), 14, color).into()
|
||||
};
|
||||
let center: Element<'_, cosmic::Action<Message>> =
|
||||
if let Some(percent) = progress_percent(activity) {
|
||||
self.render
|
||||
.text(percent.to_string())
|
||||
.size(if percent == 100 { 9. } else { 10. })
|
||||
.line_height(cosmic::iced::widget::text::LineHeight::Relative(1.))
|
||||
.class(theme::Text::Color(color))
|
||||
.into()
|
||||
} else {
|
||||
icons::tinted(icon(&activity.data.icon), 14, color).into()
|
||||
};
|
||||
cosmic::iced::widget::stack![ring, container(center).center_x(26).center_y(26)].into()
|
||||
}
|
||||
pub fn activity_available(&self, id: window::Id) -> bool {
|
||||
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
|
||||
let name = self
|
||||
.output_for_bar(id)
|
||||
.map(|output| output.name.as_str())
|
||||
.unwrap_or("");
|
||||
ranked(
|
||||
self.activities
|
||||
.snapshot
|
||||
@@ -537,16 +592,17 @@ impl FereseShell {
|
||||
return (container(self.render.text("")).width(0).into(), false);
|
||||
};
|
||||
let palette = self.config.theme.palette();
|
||||
let result_age = activity
|
||||
.result_deadline
|
||||
.map(|deadline| now_ms().saturating_sub(deadline.saturating_sub(activity.result_flash_ms)));
|
||||
let foreground = if result_age.is_some_and(|age| age < 400) && activity.state == State::Failed {
|
||||
palette.error
|
||||
} else if result_age.is_some_and(|age| age < 400) && activity.state == State::Done {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.text
|
||||
};
|
||||
let result_age = activity.result_deadline.map(|deadline| {
|
||||
now_ms().saturating_sub(deadline.saturating_sub(activity.result_flash_ms))
|
||||
});
|
||||
let foreground =
|
||||
if result_age.is_some_and(|age| age < 400) && activity.state == State::Failed {
|
||||
palette.error
|
||||
} else if result_age.is_some_and(|age| age < 400) && activity.state == State::Done {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.text
|
||||
};
|
||||
let mut content = row![self.activity_icon(activity, foreground, width.is_some())]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center);
|
||||
@@ -573,7 +629,8 @@ impl FereseShell {
|
||||
)),
|
||||
);
|
||||
}
|
||||
let hidden = presentation.selection.hidden + presentation.selection.visible.len().saturating_sub(1);
|
||||
let hidden =
|
||||
presentation.selection.hidden + presentation.selection.visible.len().saturating_sub(1);
|
||||
if hidden > 0 && !self.notifications.dnd && self.activities.snapshot.limits.show_badge {
|
||||
content = content.push(self.render.text(format!("+{hidden}")).size(11));
|
||||
}
|
||||
@@ -613,10 +670,16 @@ impl FereseShell {
|
||||
))
|
||||
});
|
||||
(
|
||||
cosmic::iced::widget::mouse_area(crate::motion::button(self.render, button, palette.text, selected, 1.))
|
||||
.on_enter(cosmic::Action::App(Message::ActivityHover(id, true)))
|
||||
.on_exit(cosmic::Action::App(Message::ActivityHover(id, false)))
|
||||
.into(),
|
||||
cosmic::iced::widget::mouse_area(crate::motion::button(
|
||||
self.render,
|
||||
button,
|
||||
palette.text,
|
||||
selected,
|
||||
1.,
|
||||
))
|
||||
.on_enter(cosmic::Action::App(Message::ActivityHover(id, true)))
|
||||
.on_exit(cosmic::Action::App(Message::ActivityHover(id, false)))
|
||||
.into(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -711,7 +774,13 @@ impl FereseShell {
|
||||
id: window::Id,
|
||||
) -> Option<impl cosmic::iced::advanced::widget::Operation<Option<Id>> + use<>> {
|
||||
let bar = self.activity_list_bar(id)?;
|
||||
let hub = self.outputs.iter().find(|entry| entry.bar == bar)?.runtime.hub.as_ref();
|
||||
let hub = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == bar)?
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref();
|
||||
let rows = if let Some(hub) = hub {
|
||||
hub.borrow().activities.rows.clone()
|
||||
} else {
|
||||
@@ -729,13 +798,21 @@ impl FereseShell {
|
||||
key: Option<cosmic::iced::keyboard::key::Named>,
|
||||
) -> Option<Task<Message>> {
|
||||
Some(
|
||||
cosmic::iced::advanced::widget::operate(self.activity_focus_query(id)?)
|
||||
.map(move |focused| cosmic::Action::App(Message::ActivityNavigation(id, focused, key))),
|
||||
cosmic::iced::advanced::widget::operate(self.activity_focus_query(id)?).map(
|
||||
move |focused| cosmic::Action::App(Message::ActivityNavigation(id, focused, key)),
|
||||
),
|
||||
)
|
||||
}
|
||||
pub fn activity_key(&self, id: window::Id, key: cosmic::iced::keyboard::key::Named) -> Option<Task<Message>> {
|
||||
pub fn activity_key(
|
||||
&self,
|
||||
id: window::Id,
|
||||
key: cosmic::iced::keyboard::key::Named,
|
||||
) -> Option<Task<Message>> {
|
||||
use cosmic::iced::keyboard::key::Named;
|
||||
if !matches!(key, Named::ArrowUp | Named::ArrowDown | Named::Home | Named::End) {
|
||||
if !matches!(
|
||||
key,
|
||||
Named::ArrowUp | Named::ArrowDown | Named::Home | Named::End
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
self.query_activity_focus(id, Some(key))
|
||||
@@ -747,7 +824,13 @@ impl FereseShell {
|
||||
key: Option<cosmic::iced::keyboard::key::Named>,
|
||||
) -> Option<Task<Message>> {
|
||||
let bar = self.activity_list_bar(id)?;
|
||||
let hub = self.outputs.iter().find(|entry| entry.bar == bar)?.runtime.hub.as_ref();
|
||||
let hub = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == bar)?
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref();
|
||||
let mut runtime = hub.map(|hub| hub.borrow_mut());
|
||||
let presentation = if let Some(runtime) = &mut runtime {
|
||||
&mut runtime.activities
|
||||
@@ -773,9 +856,14 @@ impl FereseShell {
|
||||
_ => presentation.rows.len().saturating_sub(1),
|
||||
};
|
||||
presentation.focus = presentation.rows.get(next).cloned();
|
||||
Some(presentation.focus.as_ref().map_or_else(Task::none, |focus| {
|
||||
cosmic::widget::button::focus(activity_widget_id(bar, focus))
|
||||
}))
|
||||
Some(
|
||||
presentation
|
||||
.focus
|
||||
.as_ref()
|
||||
.map_or_else(Task::none, |focus| {
|
||||
cosmic::widget::button::focus(activity_widget_id(bar, focus))
|
||||
}),
|
||||
)
|
||||
}
|
||||
pub fn activity_result_tint(&self, id: window::Id) -> Option<(cosmic::iced::Color, f32)> {
|
||||
let runtime = self
|
||||
@@ -787,23 +875,28 @@ impl FereseShell {
|
||||
.as_ref()?
|
||||
.borrow();
|
||||
let palette = self.config.theme.palette();
|
||||
runtime.activities.selection.visible.iter().find_map(|identity| {
|
||||
let entry = self
|
||||
.activities
|
||||
.snapshot
|
||||
.activities
|
||||
.iter()
|
||||
.find(|entry| &entry.id == identity)?;
|
||||
let alpha = runtime.activities.tints.get(identity)?.current as f32;
|
||||
(alpha > 0.).then_some((
|
||||
if entry.state == State::Failed {
|
||||
palette.error
|
||||
} else {
|
||||
palette.accent
|
||||
},
|
||||
alpha.clamp(0., 1.) * 0.4,
|
||||
))
|
||||
})
|
||||
runtime
|
||||
.activities
|
||||
.selection
|
||||
.visible
|
||||
.iter()
|
||||
.find_map(|identity| {
|
||||
let entry = self
|
||||
.activities
|
||||
.snapshot
|
||||
.activities
|
||||
.iter()
|
||||
.find(|entry| &entry.id == identity)?;
|
||||
let alpha = runtime.activities.tints.get(identity)?.current as f32;
|
||||
(alpha > 0.).then_some((
|
||||
if entry.state == State::Failed {
|
||||
palette.error
|
||||
} else {
|
||||
palette.accent
|
||||
},
|
||||
alpha.clamp(0., 1.) * 0.4,
|
||||
))
|
||||
})
|
||||
}
|
||||
pub fn activity_compact(
|
||||
&self,
|
||||
@@ -843,12 +936,14 @@ impl FereseShell {
|
||||
.map_or(1., |value| value.current as f32)
|
||||
.clamp(0., 1.);
|
||||
let divider_color = palette.text.scale_alpha(opacity * alpha * 0.25);
|
||||
strip = strip.push(container(cosmic::iced::widget::Space::new().width(1).height(18)).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(divider_color.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
strip = strip.push(
|
||||
container(cosmic::iced::widget::Space::new().width(1).height(18)).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(divider_color.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
let semantic = if activity.state == State::Failed {
|
||||
palette.error
|
||||
@@ -936,7 +1031,10 @@ impl FereseShell {
|
||||
pressed: Box::new(move |_, _| appearance),
|
||||
disabled: Box::new(move |_| appearance),
|
||||
})
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Activities))),
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(
|
||||
id,
|
||||
status_ui::Menu::Activities,
|
||||
))),
|
||||
);
|
||||
let budget = presentation.compact.width
|
||||
+ if self.hub_workspace_width(id) > 0. {
|
||||
@@ -947,13 +1045,29 @@ impl FereseShell {
|
||||
container(controls)
|
||||
.padding([0., compact::PADDING])
|
||||
.height(Length::Fill)
|
||||
.width(if intrinsic { Length::Fixed(budget) } else { Length::Fill })
|
||||
.width(if intrinsic {
|
||||
Length::Fixed(budget)
|
||||
} else {
|
||||
Length::Fill
|
||||
})
|
||||
.into()
|
||||
}
|
||||
pub fn activity_list(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub fn activity_list(
|
||||
&self,
|
||||
id: window::Id,
|
||||
opacity: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let palette = self.config.theme.palette();
|
||||
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
|
||||
let entries = list_entries(&self.activities.snapshot, &self.activities.native, now_ms(), name);
|
||||
let name = self
|
||||
.output_for_bar(id)
|
||||
.map(|output| output.name.as_str())
|
||||
.unwrap_or("");
|
||||
let entries = list_entries(
|
||||
&self.activities.snapshot,
|
||||
&self.activities.native,
|
||||
now_ms(),
|
||||
name,
|
||||
);
|
||||
let focus = self
|
||||
.outputs
|
||||
.iter()
|
||||
@@ -1031,7 +1145,10 @@ impl FereseShell {
|
||||
Template::Media { artist, playing } => {
|
||||
body = body.push(
|
||||
self.render
|
||||
.text(format!("{artist} · {}", if *playing { "Playing" } else { "Paused" }))
|
||||
.text(format!(
|
||||
"{artist} · {}",
|
||||
if *playing { "Playing" } else { "Paused" }
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
@@ -1042,7 +1159,8 @@ impl FereseShell {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if activity.data.source == ferese_activities::Source::Media && activity.id.session == 0 {
|
||||
if activity.data.source == ferese_activities::Source::Media && activity.id.session == 0
|
||||
{
|
||||
body = body.push(
|
||||
button::custom(icons::tinted(icons::MEDIA_PLAY, 16, palette.text))
|
||||
.name("Play or pause")
|
||||
@@ -1085,7 +1203,10 @@ impl FereseShell {
|
||||
pressed: Box::new(move |focused, _| appearance(0.10, focused)),
|
||||
disabled: Box::new(move |_| appearance(0.025, false)),
|
||||
})
|
||||
.on_press(cosmic::Action::App(Message::ActivityActivate(id, activity.id.clone())))
|
||||
.on_press(cosmic::Action::App(Message::ActivityActivate(
|
||||
id,
|
||||
activity.id.clone()
|
||||
)))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -19,19 +19,35 @@ pub(super) struct Compact {
|
||||
}
|
||||
|
||||
impl Compact {
|
||||
pub fn update(&mut self, entries: &[&Activity], selection: &Selection, font: Font, available: f32) {
|
||||
pub fn update(
|
||||
&mut self,
|
||||
entries: &[&Activity],
|
||||
selection: &Selection,
|
||||
font: Font,
|
||||
available: f32,
|
||||
) {
|
||||
if self.font != Some(font) {
|
||||
self.font = Some(font);
|
||||
self.titles.clear();
|
||||
self.badge = None;
|
||||
}
|
||||
self.titles.retain(|id, _| selection.visible.contains(id));
|
||||
for entry in entries.iter().filter(|entry| selection.visible.contains(&entry.id)) {
|
||||
for entry in entries
|
||||
.iter()
|
||||
.filter(|entry| selection.visible.contains(&entry.id))
|
||||
{
|
||||
let title = &entry.data.title;
|
||||
if self.titles.get(&entry.id).is_none_or(|(cached, _)| cached != title) {
|
||||
if self
|
||||
.titles
|
||||
.get(&entry.id)
|
||||
.is_none_or(|(cached, _)| cached != title)
|
||||
{
|
||||
self.titles.insert(
|
||||
entry.id.clone(),
|
||||
(title.clone(), text_width(title, 12., font).ceil().clamp(48., 100.)),
|
||||
(
|
||||
title.clone(),
|
||||
text_width(title, 12., font).ceil().clamp(48., 100.),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +81,9 @@ impl Compact {
|
||||
}
|
||||
|
||||
pub fn text_width(&self, id: &Id) -> f32 {
|
||||
self.titles.get(id).map_or(48., |(_, width)| width * self.text_scale)
|
||||
self.titles
|
||||
.get(id)
|
||||
.map_or(48., |(_, width)| width * self.text_scale)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,20 @@ use ferese_ipc::activities::{PomodoroAction as Action, now_ms};
|
||||
use ferese_theme::{icons, menus};
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn pomodoro_controls(&self, bar: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(super) fn pomodoro_controls(
|
||||
&self,
|
||||
bar: window::Id,
|
||||
opacity: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let palette = self.config.theme;
|
||||
let primary = color_with_opacity(palette.text_primary, opacity);
|
||||
let muted = color_with_opacity(palette.text_muted, opacity);
|
||||
let accent = color_with_opacity(palette.accent, opacity);
|
||||
let current = self.activities.snapshot.pomodoro.as_ref();
|
||||
let session = current.map(|current| current.id.clone());
|
||||
let settings = current.map_or(&self.config.activities.pomodoro, |current| ¤t.timer.settings);
|
||||
let settings = current.map_or(&self.config.activities.pomodoro, |current| {
|
||||
¤t.timer.settings
|
||||
});
|
||||
let (phase, seconds, description, label, action, symbol) = match current {
|
||||
None => (
|
||||
"Focus timer",
|
||||
@@ -47,11 +53,17 @@ impl FereseShell {
|
||||
let interval = if timer.phase == Phase::Work {
|
||||
format!(
|
||||
"Interval {} of {}",
|
||||
timer.completed_work.saturating_add(1).min(settings.intervals),
|
||||
timer
|
||||
.completed_work
|
||||
.saturating_add(1)
|
||||
.min(settings.intervals),
|
||||
settings.intervals
|
||||
)
|
||||
} else {
|
||||
format!("{} of {} complete", timer.completed_work, settings.intervals)
|
||||
format!(
|
||||
"{} of {} complete",
|
||||
timer.completed_work, settings.intervals
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{}{interval}",
|
||||
@@ -109,8 +121,14 @@ impl FereseShell {
|
||||
let heading = row![
|
||||
badge,
|
||||
column![
|
||||
self.render.text("Pomodoro").size(13).class(theme::Text::Color(primary)),
|
||||
self.render.text(phase).size(11).class(theme::Text::Color(muted)),
|
||||
self.render
|
||||
.text("Pomodoro")
|
||||
.size(13)
|
||||
.class(theme::Text::Color(primary)),
|
||||
self.render
|
||||
.text(phase)
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Length::Fill),
|
||||
@@ -125,9 +143,12 @@ impl FereseShell {
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
let primary_button = button::custom(
|
||||
row![icons::tinted(symbol, 16, accent), self.render.text(label).size(13)]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
row![
|
||||
icons::tinted(symbol, 16, accent),
|
||||
self.render.text(label).size(13)
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.id(session.as_ref().map_or_else(
|
||||
|| cosmic::widget::Id::new(format!("pomodoro-start-{bar:?}")),
|
||||
@@ -137,9 +158,15 @@ impl FereseShell {
|
||||
.padding([7, 10])
|
||||
.width(Length::Fill)
|
||||
.on_press(message(action));
|
||||
let mut controls = row![motion::button(self.render, primary_button, accent, true, opacity)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
let mut controls = row![motion::button(
|
||||
self.render,
|
||||
primary_button,
|
||||
accent,
|
||||
true,
|
||||
opacity
|
||||
)]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center);
|
||||
let secondary = |label: &'static str, symbol, action| {
|
||||
let control = button::custom(icons::tinted(symbol, 16, primary))
|
||||
.name(label)
|
||||
@@ -147,7 +174,10 @@ impl FereseShell {
|
||||
.on_press(message(action));
|
||||
tooltip(
|
||||
motion::button(self.render, control, primary, false, opacity),
|
||||
self.render.text(label).size(12).class(theme::Text::Color(primary)),
|
||||
self.render
|
||||
.text(label)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(primary)),
|
||||
cosmic::widget::tooltip::Position::Top,
|
||||
)
|
||||
};
|
||||
@@ -171,7 +201,10 @@ impl FereseShell {
|
||||
menus::section(
|
||||
column![
|
||||
heading,
|
||||
self.render.text(description).size(11).class(theme::Text::Color(muted)),
|
||||
self.render
|
||||
.text(description)
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
controls,
|
||||
]
|
||||
.spacing(10)
|
||||
|
||||
@@ -45,14 +45,21 @@ impl Default for RingState {
|
||||
}
|
||||
}
|
||||
impl RingState {
|
||||
fn advance(&mut self, progress: Option<f32>, animate: bool, reduced: bool, now: Instant) -> bool {
|
||||
let elapsed = self
|
||||
.sampled
|
||||
.map_or(Duration::ZERO, |before| now.saturating_duration_since(before));
|
||||
fn advance(
|
||||
&mut self,
|
||||
progress: Option<f32>,
|
||||
animate: bool,
|
||||
reduced: bool,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let elapsed = self.sampled.map_or(Duration::ZERO, |before| {
|
||||
now.saturating_duration_since(before)
|
||||
});
|
||||
let initial = self.sampled.is_none();
|
||||
self.sampled = Some(now);
|
||||
self.elapsed = self.elapsed.saturating_add(elapsed);
|
||||
self.value.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
|
||||
self.value
|
||||
.retarget_preserving_motion(f64::from(progress.unwrap_or(0.)));
|
||||
if initial || reduced || !animate {
|
||||
self.value.snap();
|
||||
} else {
|
||||
@@ -114,7 +121,10 @@ impl canvas::Program<cosmic::Action<Message>, Theme, cosmic::Renderer> for Ring
|
||||
.clamp(0., 1.),
|
||||
)
|
||||
} else if self.animate && !self.reduced {
|
||||
(state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU, 0.23)
|
||||
(
|
||||
state.elapsed.as_secs_f32() / 3. * std::f32::consts::TAU,
|
||||
0.23,
|
||||
)
|
||||
} else {
|
||||
(0., 0.)
|
||||
};
|
||||
@@ -151,7 +161,12 @@ mod tests {
|
||||
state.value.retarget_preserving_motion(0.9);
|
||||
assert_eq!(state.value.velocity, velocity);
|
||||
for frame in 1..=200 {
|
||||
state.advance(Some(0.9), true, false, now + Duration::from_millis(50 + frame * 16));
|
||||
state.advance(
|
||||
Some(0.9),
|
||||
true,
|
||||
false,
|
||||
now + Duration::from_millis(50 + frame * 16),
|
||||
);
|
||||
}
|
||||
assert!(!state.value.is_animating());
|
||||
assert!(!state.advance(None, false, false, now + Duration::from_secs(5)));
|
||||
|
||||
+240
-72
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon, button, color,
|
||||
container, control, motion, row, status_ui, theme, window,
|
||||
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon,
|
||||
button, color, container, control, motion, row, status_ui, theme, window,
|
||||
};
|
||||
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
|
||||
#[cfg(test)]
|
||||
@@ -16,7 +16,10 @@ use crate::panel_layout::Placement;
|
||||
mod binding_mode_tests;
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task<Message> {
|
||||
pub(super) fn binding_mode_transition(
|
||||
&mut self,
|
||||
was_active: bool,
|
||||
) -> cosmic::app::Task<Message> {
|
||||
match (was_active, self.snapshot.binding_mode.is_some()) {
|
||||
(false, true) => {
|
||||
let mut tasks = vec![self.destroy_menu(), self.dismiss_workspace_tooltip()];
|
||||
@@ -39,25 +42,35 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> {
|
||||
let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?;
|
||||
self.snapshot.outputs.iter().find(|output| output.name == name)
|
||||
let name = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == id)?
|
||||
.name
|
||||
.as_deref()?;
|
||||
self.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.name == name)
|
||||
}
|
||||
|
||||
pub(super) fn active_bar(&self) -> Option<window::Id> {
|
||||
self.outputs
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
self.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
|
||||
self.snapshot.outputs.iter().any(|output| {
|
||||
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
|
||||
})
|
||||
})
|
||||
.or_else(|| self.outputs.first())
|
||||
.map(|output| output.bar)
|
||||
}
|
||||
|
||||
pub(super) fn bar_hidden(&self, id: window::Id) -> bool {
|
||||
output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id))
|
||||
output_bar_hidden(
|
||||
&self.snapshot,
|
||||
self.output_for_bar(id).map(|output| output.id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||
@@ -75,7 +88,8 @@ impl FereseShell {
|
||||
return self.view_hub(id);
|
||||
}
|
||||
let mut shell_theme = self.config.theme.for_bar();
|
||||
shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
|
||||
shell_theme.bar_background[3] =
|
||||
(shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
|
||||
let output = self.outputs.iter().find(|output| output.bar == id);
|
||||
let effects = output.and_then(|output| output.effects.as_ref());
|
||||
let compositor_material = effects.is_some();
|
||||
@@ -91,7 +105,11 @@ impl FereseShell {
|
||||
let mut samples = Vec::new();
|
||||
for zone in [&panel.center, &panel.start, &panel.end] {
|
||||
for group in &zone.groups {
|
||||
for item in group.items.iter().filter(|item| item.available(availability)) {
|
||||
for item in group
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.available(availability))
|
||||
{
|
||||
if matches!(item.kind, ItemKind::FocusedWindow)
|
||||
&& focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty()
|
||||
{
|
||||
@@ -171,7 +189,10 @@ impl FereseShell {
|
||||
availability,
|
||||
|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0),
|
||||
|item, surface| {
|
||||
let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id)
|
||||
let Some(Placement::Visible {
|
||||
representation,
|
||||
width,
|
||||
}) = resolution.items.get(&item.id)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -221,11 +242,17 @@ impl FereseShell {
|
||||
}
|
||||
};
|
||||
cosmic::iced::widget::stack![
|
||||
row![start, cosmic::iced::widget::Space::new().width(Length::Fill), end]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
container(center).center_x(Length::Fill).center_y(Length::Fill),
|
||||
row![
|
||||
start,
|
||||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||||
end
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(cosmic::iced::Alignment::Center),
|
||||
container(center)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
]
|
||||
.into()
|
||||
},
|
||||
@@ -267,9 +294,17 @@ impl FereseShell {
|
||||
self.with_workspace_hover(id, content)
|
||||
}
|
||||
|
||||
fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action<Message>> {
|
||||
fn view_binding_mode<'a>(
|
||||
&'a self,
|
||||
id: window::Id,
|
||||
name: &'a str,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let panel = self.panel_for_bar(id);
|
||||
let output = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output");
|
||||
let output = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.bar == id)
|
||||
.expect("panel output");
|
||||
let effects = output.effects.as_ref();
|
||||
let width = output
|
||||
.runtime
|
||||
@@ -297,7 +332,9 @@ impl FereseShell {
|
||||
.width(width as f32)
|
||||
.height(panel.resolved_geometry().height)
|
||||
.align_y(alignment::Vertical::Center);
|
||||
let content = container(badge).center_x(Length::Fill).center_y(Length::Fill);
|
||||
let content = container(badge)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill);
|
||||
let palette = self.config.theme.for_bar();
|
||||
let mut background = color(palette.bar_background);
|
||||
background.a = palette.panel_opacity(panel.background_opacity);
|
||||
@@ -345,7 +382,12 @@ impl FereseShell {
|
||||
.map(|(workspace, target)| presentation::HoverTarget {
|
||||
id: target.clone(),
|
||||
exited: Box::new(move || {
|
||||
cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false))
|
||||
cosmic::Action::App(Message::HoverWorkspace(
|
||||
id,
|
||||
workspace,
|
||||
target.clone(),
|
||||
false,
|
||||
))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -377,7 +419,10 @@ impl FereseShell {
|
||||
ItemKind::Overview => motion::button(
|
||||
self.render,
|
||||
button::custom(overview_control(
|
||||
BarMetrics { control_height, ..bar },
|
||||
BarMetrics {
|
||||
control_height,
|
||||
..bar
|
||||
},
|
||||
color(shell_theme.accent),
|
||||
))
|
||||
.height(control_height)
|
||||
@@ -425,15 +470,30 @@ impl FereseShell {
|
||||
return (container(element).max_width(360).into(), !title.is_empty());
|
||||
}
|
||||
ItemKind::Activities => {
|
||||
return self.view_activity_item(id, item, representation, selected, width, control_height);
|
||||
return self.view_activity_item(
|
||||
id,
|
||||
item,
|
||||
representation,
|
||||
selected,
|
||||
width,
|
||||
control_height,
|
||||
);
|
||||
}
|
||||
ItemKind::Media => self.view_media_item(representation, selected, width),
|
||||
ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected),
|
||||
ItemKind::NightLight => self.view_status_item(status_ui::Menu::NightLight, false, selected),
|
||||
ItemKind::QuickSettings => {
|
||||
self.view_status_item(status_ui::Menu::System, false, selected)
|
||||
}
|
||||
ItemKind::NightLight => {
|
||||
self.view_status_item(status_ui::Menu::NightLight, false, selected)
|
||||
}
|
||||
ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
|
||||
ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected),
|
||||
ItemKind::Recording => self.view_status_item(status_ui::Menu::Recording, false, selected),
|
||||
ItemKind::Notifications => self.view_status_item(status_ui::Menu::Notifications, false, selected),
|
||||
ItemKind::Recording => {
|
||||
self.view_status_item(status_ui::Menu::Recording, false, selected)
|
||||
}
|
||||
ItemKind::Notifications => {
|
||||
self.view_status_item(status_ui::Menu::Notifications, false, selected)
|
||||
}
|
||||
ItemKind::Battery { percentage } => self.view_status_item(
|
||||
status_ui::Menu::Battery,
|
||||
percentage && representation != Representation::Icon,
|
||||
@@ -453,7 +513,9 @@ impl FereseShell {
|
||||
self.render
|
||||
.text(date)
|
||||
.size(bar.text_size)
|
||||
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(20.into()))
|
||||
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(
|
||||
20.into(),
|
||||
))
|
||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
);
|
||||
@@ -496,7 +558,10 @@ impl FereseShell {
|
||||
))
|
||||
.name("Display mode")
|
||||
.height(control_height)
|
||||
.padding([0.0, ((bar.height - f32::from(bar.icon_size)) * 0.5).max(4.0)])
|
||||
.padding([
|
||||
0.0,
|
||||
((bar.height - f32::from(bar.icon_size)) * 0.5).max(4.0),
|
||||
])
|
||||
.on_press(cosmic::Action::App(Message::OpenDisplays(
|
||||
self.outputs
|
||||
.iter()
|
||||
@@ -515,15 +580,17 @@ impl FereseShell {
|
||||
.into();
|
||||
(
|
||||
element.map(move |action| match action {
|
||||
cosmic::Action::App(Message::OpenMenu(kind, anchor)) => cosmic::Action::App(Message::OpenPopover(
|
||||
kind,
|
||||
status_ui::PopoverAnchor {
|
||||
parent: id,
|
||||
panel: panel.clone(),
|
||||
item: Some(item.clone()),
|
||||
rectangle: anchor,
|
||||
},
|
||||
)),
|
||||
cosmic::Action::App(Message::OpenMenu(kind, anchor)) => {
|
||||
cosmic::Action::App(Message::OpenPopover(
|
||||
kind,
|
||||
status_ui::PopoverAnchor {
|
||||
parent: id,
|
||||
panel: panel.clone(),
|
||||
item: Some(item.clone()),
|
||||
rectangle: anchor,
|
||||
},
|
||||
))
|
||||
}
|
||||
other => other,
|
||||
}),
|
||||
true,
|
||||
@@ -532,10 +599,19 @@ impl FereseShell {
|
||||
|
||||
pub(crate) fn view_panel_overflow<'a>(
|
||||
&'a self,
|
||||
mut rows: cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>,
|
||||
mut rows: cosmic::widget::Column<
|
||||
'a,
|
||||
cosmic::Action<Message>,
|
||||
cosmic::Theme,
|
||||
cosmic::Renderer,
|
||||
>,
|
||||
) -> cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer> {
|
||||
let Some(menu) = &self.menu else { return rows };
|
||||
let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else {
|
||||
let Some(output) = self
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|output| output.bar == menu.anchor.parent)
|
||||
else {
|
||||
return rows;
|
||||
};
|
||||
let panel = self.panel_for_bar(output.bar);
|
||||
@@ -569,7 +645,10 @@ impl FereseShell {
|
||||
) => cosmic::Action::App(Message::PanelAction(Box::new(message))),
|
||||
other => other,
|
||||
});
|
||||
rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4));
|
||||
rows = rows.push(
|
||||
cosmic::widget::column![self.render.text(item.kind.label()).size(12), element]
|
||||
.spacing(4),
|
||||
);
|
||||
}
|
||||
rows
|
||||
}
|
||||
@@ -645,7 +724,9 @@ impl FereseShell {
|
||||
))
|
||||
.height(bar.group_item_height)
|
||||
.padding(0)
|
||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
|
||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
|
||||
workspace.id,
|
||||
)));
|
||||
|
||||
let selector = motion::button(
|
||||
self.render,
|
||||
@@ -658,8 +739,10 @@ impl FereseShell {
|
||||
false,
|
||||
opacity,
|
||||
);
|
||||
let target =
|
||||
cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id));
|
||||
let target = cosmic::iced::advanced::widget::Id::new(format!(
|
||||
"workspace:{id:?}:{instance}:{}",
|
||||
workspace.id
|
||||
));
|
||||
let selector = cosmic::widget::mouse_area(container(selector).id(target.clone()))
|
||||
.on_enter(cosmic::Action::App(Message::HoverWorkspace(
|
||||
id,
|
||||
@@ -679,7 +762,12 @@ impl FereseShell {
|
||||
selector
|
||||
} else {
|
||||
selector.on_move(move |_| {
|
||||
cosmic::Action::App(Message::HoverWorkspace(id, workspace.id, target.clone(), true))
|
||||
cosmic::Action::App(Message::HoverWorkspace(
|
||||
id,
|
||||
workspace.id,
|
||||
target.clone(),
|
||||
true,
|
||||
))
|
||||
})
|
||||
};
|
||||
workspace_buttons = workspace_buttons.push(selector);
|
||||
@@ -688,8 +776,14 @@ impl FereseShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn group_material_id(panel: &Panel, group: &crate::panel::Group) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!("panel:{}:group:{}:material", panel.id.0, group.id.0))
|
||||
fn group_material_id(
|
||||
panel: &Panel,
|
||||
group: &crate::panel::Group,
|
||||
) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!(
|
||||
"panel:{}:group:{}:material",
|
||||
panel.id.0, group.id.0
|
||||
))
|
||||
}
|
||||
|
||||
/// Render ordered instances; item rendering does not choose its neighbors or surface.
|
||||
@@ -706,7 +800,8 @@ fn view_zone<'a>(
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
for group in &zone.groups {
|
||||
let surface = panel.group_surface();
|
||||
let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
|
||||
let mut controls =
|
||||
row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
|
||||
let mut count = 0;
|
||||
for item in group
|
||||
.items
|
||||
@@ -714,11 +809,14 @@ fn view_zone<'a>(
|
||||
.filter(|item| item.available(availability) && visible(item))
|
||||
{
|
||||
if count > 0 {
|
||||
controls =
|
||||
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
||||
controls = controls.push(
|
||||
cosmic::iced::widget::Space::new()
|
||||
.width(item.gap_before.unwrap_or(group.spacing)),
|
||||
);
|
||||
}
|
||||
let (element, _) = render(item, surface);
|
||||
controls = controls.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0)));
|
||||
controls = controls
|
||||
.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0)));
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -756,9 +854,16 @@ pub(super) fn bar_content<'a>(
|
||||
container(content).center_y(height)
|
||||
}
|
||||
|
||||
pub(super) fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action<Message>> {
|
||||
pub(super) fn overview_control(
|
||||
bar: BarMetrics,
|
||||
foreground: Color,
|
||||
) -> Element<'static, cosmic::Action<Message>> {
|
||||
bar_content(
|
||||
bar_icon(ferese_theme::icons::FERESE, bar.overview_icon_size, foreground),
|
||||
bar_icon(
|
||||
ferese_theme::icons::FERESE,
|
||||
bar.overview_icon_size,
|
||||
foreground,
|
||||
),
|
||||
bar.control_height,
|
||||
)
|
||||
.width(bar.overview_icon_size)
|
||||
@@ -795,7 +900,10 @@ pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str {
|
||||
pub(super) fn focused_bar_title<'a>(
|
||||
snapshot: &'a ShellSnapshot,
|
||||
output: Option<&control::OutputSnapshot>,
|
||||
) -> &'a str {
|
||||
let Some(output) = output else {
|
||||
return "";
|
||||
};
|
||||
@@ -812,7 +920,10 @@ pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool {
|
||||
pub(super) fn workspace_active_on_bar(
|
||||
workspace: u64,
|
||||
output: Option<&control::OutputSnapshot>,
|
||||
) -> bool {
|
||||
output.is_some_and(|output| output.active_workspace == workspace)
|
||||
}
|
||||
|
||||
@@ -934,7 +1045,13 @@ mod island_tests {
|
||||
let mut previous_label = 0.;
|
||||
for width in [120., 200., 320.] {
|
||||
let mut view = shell
|
||||
.view_panel_item(shell.outputs[0].bar, &item, Some(width), true, Representation::Wide)
|
||||
.view_panel_item(
|
||||
shell.outputs[0].bar,
|
||||
&item,
|
||||
Some(width),
|
||||
true,
|
||||
Representation::Wide,
|
||||
)
|
||||
.0;
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node = view.as_widget_mut().layout(
|
||||
@@ -1004,12 +1121,20 @@ mod island_tests {
|
||||
let last = labels.0.last().unwrap();
|
||||
let right = node.size().width - last.x - last.width;
|
||||
assert!((left - right).abs() < 0.01);
|
||||
assert!(right <= 4.01, "panel height must not add horizontal space to the clock");
|
||||
assert!(
|
||||
right <= 4.01,
|
||||
"panel height must not add horizontal space to the clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_zone(panel: &Panel, zone: &Zone, availability: Availability, title_content: bool) -> Bounds {
|
||||
fn measure_zone(
|
||||
panel: &Panel,
|
||||
zone: &Zone,
|
||||
availability: Availability,
|
||||
title_content: bool,
|
||||
) -> Bounds {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -1040,7 +1165,12 @@ mod island_tests {
|
||||
20.
|
||||
};
|
||||
(
|
||||
container(cosmic::iced::widget::Space::new().width(width).height(height)).into(),
|
||||
container(
|
||||
cosmic::iced::widget::Space::new()
|
||||
.width(width)
|
||||
.height(height),
|
||||
)
|
||||
.into(),
|
||||
!matches!(item.kind, ItemKind::FocusedWindow) || title_content,
|
||||
)
|
||||
},
|
||||
@@ -1053,7 +1183,10 @@ mod island_tests {
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)),
|
||||
&layout::Limits::new(
|
||||
Size::ZERO,
|
||||
Size::new(1200., panel.resolved_geometry().height),
|
||||
),
|
||||
);
|
||||
let mut bounds = Bounds {
|
||||
group_ids: zone
|
||||
@@ -1116,12 +1249,19 @@ mod island_tests {
|
||||
}
|
||||
let start = measure_zone(&panel, &panel.start, Availability::default(), true);
|
||||
assert_eq!(
|
||||
start.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
start
|
||||
.items
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["overview", "workspaces"]
|
||||
);
|
||||
let islands = background == PanelStyle::Floating;
|
||||
assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. });
|
||||
assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. });
|
||||
assert_eq!(
|
||||
start.items[1].1.x - start.items[0].1.x,
|
||||
if islands { 29. } else { 34. }
|
||||
);
|
||||
assert_eq!(start.islands.len(), panel.start.groups.len());
|
||||
if islands {
|
||||
assert_eq!(
|
||||
@@ -1134,12 +1274,18 @@ mod island_tests {
|
||||
}
|
||||
let end = measure_zone(&panel, &panel.end, Availability::default(), true);
|
||||
assert_eq!(
|
||||
end.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
end.items
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["quick-settings", "night-light", "recording", "clock"]
|
||||
);
|
||||
assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.);
|
||||
assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.);
|
||||
assert_eq!(end.items[3].1.x - end.items[2].1.x, if islands { 21. } else { 34. });
|
||||
assert_eq!(
|
||||
end.items[3].1.x - end.items[2].1.x,
|
||||
if islands { 21. } else { 34. }
|
||||
);
|
||||
if islands {
|
||||
assert_eq!(
|
||||
end.islands[0],
|
||||
@@ -1154,7 +1300,8 @@ mod island_tests {
|
||||
if islands {
|
||||
assert_eq!(center.islands[0].width, 80. + 2. * padding);
|
||||
}
|
||||
let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false);
|
||||
let empty_title =
|
||||
measure_zone(&panel, &panel.center, Availability::default(), false);
|
||||
assert!(empty_title.islands.is_empty());
|
||||
assert!(
|
||||
empty_title.items.is_empty(),
|
||||
@@ -1190,16 +1337,27 @@ mod island_tests {
|
||||
panel.end.groups.insert(0, unavailable);
|
||||
let result = measure_zone(&panel, &panel.end, Availability::default(), true);
|
||||
assert_eq!(
|
||||
result.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["clock", "other-clock"]
|
||||
);
|
||||
assert_eq!(result.items[0].1.x, 7., "an unavailable group adds no space");
|
||||
assert_eq!(
|
||||
result.items[0].1.x, 7.,
|
||||
"an unavailable group adds no space"
|
||||
);
|
||||
assert_eq!(result.items[1].1.x, 68.);
|
||||
assert_eq!(result.islands.len(), 1);
|
||||
panel.end.groups[1].items.reverse();
|
||||
let reordered = measure_zone(&panel, &panel.end, Availability::default(), true);
|
||||
assert_eq!(
|
||||
reordered.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||
reordered
|
||||
.items
|
||||
.iter()
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["other-clock", "clock"]
|
||||
);
|
||||
}
|
||||
@@ -1213,10 +1371,20 @@ mod island_tests {
|
||||
input[0],
|
||||
cosmic::iced::Rectangle::new((12., 2.).into(), (81., 25.).into())
|
||||
);
|
||||
assert!(!input.iter().any(|region| region.contains((240., 14.).into())));
|
||||
assert_eq!(input_region(PanelSurface::None, true, ®ions), Some(Vec::new()));
|
||||
assert!(
|
||||
!input
|
||||
.iter()
|
||||
.any(|region| region.contains((240., 14.).into()))
|
||||
);
|
||||
assert_eq!(
|
||||
input_region(PanelSurface::None, true, ®ions),
|
||||
Some(Vec::new())
|
||||
);
|
||||
assert_eq!(input_region(PanelSurface::Solid, false, ®ions), None);
|
||||
assert_eq!(input_region(PanelSurface::Solid, true, ®ions), Some(Vec::new()));
|
||||
assert_eq!(
|
||||
input_region(PanelSurface::Solid, true, ®ions),
|
||||
Some(Vec::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,27 +23,46 @@ fn a_binding_mode_owns_the_panel_instead_of_hidden_morph_hover_or_menus() {
|
||||
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
||||
let id = shell.outputs[0].bar;
|
||||
shell.snapshot.binding_mode = Some("resize".into());
|
||||
let allocation = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation;
|
||||
let allocation = shell.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.borrow()
|
||||
.allocation;
|
||||
drop(shell.hub_hover(id, true));
|
||||
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
|
||||
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
||||
assert_eq!(shell.hub_mode(id), Some(ferese_panel::morph::Mode::Idle));
|
||||
assert_eq!(
|
||||
shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().allocation,
|
||||
shell.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.borrow()
|
||||
.allocation,
|
||||
allocation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_entry_dismisses_existing_popovers_and_collapses_expanded_morph_content() {
|
||||
for style in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Morph] {
|
||||
for style in [
|
||||
ferese_panel::PanelStyle::Flat,
|
||||
ferese_panel::PanelStyle::Morph,
|
||||
] {
|
||||
let mut shell = crate::tests::shell_with_measured_panel();
|
||||
shell.config.panels[0].style = style;
|
||||
shell.outputs[0]
|
||||
.runtime
|
||||
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
||||
let id = shell.outputs[0].bar;
|
||||
let collapsed = shell.outputs[0].runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
|
||||
let collapsed = shell.outputs[0]
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.map(|hub| hub.borrow().allocation);
|
||||
if collapsed.is_some() {
|
||||
drop(shell.open_hub(id, status_ui::Menu::Calendar));
|
||||
assert!(shell.hub_mode(id).unwrap().expanded());
|
||||
@@ -117,13 +136,19 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
|
||||
.hub
|
||||
.as_ref()
|
||||
.map(|hub| hub.borrow().allocation)
|
||||
.unwrap_or([1200, shell.config.panels[0].resolved_geometry().height as u32]);
|
||||
let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
|
||||
.unwrap_or([
|
||||
1200,
|
||||
shell.config.panels[0].resolved_geometry().height as u32,
|
||||
]);
|
||||
let viewport =
|
||||
Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32));
|
||||
let mut view = shell.view_layer(id);
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
@@ -141,7 +166,11 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
|
||||
cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{style}: mode must replace the previous native clipping and input shape"));
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{style}: mode must replace the previous native clipping and input shape"
|
||||
)
|
||||
});
|
||||
let mut labels = TextBounds::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||
@@ -150,7 +179,10 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
|
||||
"{style}: mode name missing"
|
||||
);
|
||||
assert!(
|
||||
labels.0.iter().any(|(text, _)| text.contains("Esc to exit")),
|
||||
labels
|
||||
.0
|
||||
.iter()
|
||||
.any(|(text, _)| text.contains("Esc to exit")),
|
||||
"{style}: exit hint missing"
|
||||
);
|
||||
for (text, bounds) in &labels.0 {
|
||||
@@ -186,7 +218,11 @@ fn mode_name_and_escape_hint_fit_the_published_shape_in_every_panel_style() {
|
||||
mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let pixels = renderer.screenshot(Size::new(allocation[0], allocation[1]), 1., Color::TRANSPARENT);
|
||||
let pixels = renderer.screenshot(
|
||||
Size::new(allocation[0], allocation[1]),
|
||||
1.,
|
||||
Color::TRANSPARENT,
|
||||
);
|
||||
assert!(
|
||||
pixels.chunks_exact(4).any(|pixel| pixel[3] > 0),
|
||||
"{style}: no visible pixels"
|
||||
|
||||
@@ -2,7 +2,10 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, re
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||
use cosmic::{Element, Theme};
|
||||
|
||||
pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option<HoverTarget<'a, M>>) -> Element<'a, M> {
|
||||
pub(super) fn hover<'a, M: 'a>(
|
||||
content: Element<'a, M>,
|
||||
hover: Option<HoverTarget<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
Element::new(Hover { content, hover })
|
||||
}
|
||||
|
||||
@@ -63,7 +66,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
|
||||
fn update(
|
||||
@@ -88,9 +93,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
target: &hover.id,
|
||||
bounds: None,
|
||||
};
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, &mut target);
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut target,
|
||||
);
|
||||
if matches!(event, Event::Mouse(mouse::Event::CursorLeft))
|
||||
|| !cursor.is_over(*viewport)
|
||||
|| !target.bounds.is_some_and(|bounds| cursor.is_over(bounds))
|
||||
@@ -125,9 +133,15 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
@@ -138,9 +152,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
@@ -163,8 +181,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,15 @@ impl FereseShell {
|
||||
let foreground = color_with_opacity(theme.text_primary, opacity);
|
||||
let outline = if battery.is_some_and(|battery| {
|
||||
battery.percent < self.config.status.low_battery_threshold
|
||||
&& matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty")
|
||||
&& matches!(
|
||||
battery.status.as_str(),
|
||||
"Discharging" | "Pending discharge" | "Empty"
|
||||
)
|
||||
}) {
|
||||
palette.error.scale_alpha(opacity)
|
||||
} else if battery.is_some_and(|battery| matches!(battery.status.as_str(), "Charging" | "Pending charge")) {
|
||||
} else if battery
|
||||
.is_some_and(|battery| matches!(battery.status.as_str(), "Charging" | "Pending charge"))
|
||||
{
|
||||
palette.accent.scale_alpha(opacity)
|
||||
} else {
|
||||
foreground
|
||||
@@ -66,15 +71,19 @@ impl FereseShell {
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
let terminal =
|
||||
container(Space::new().width(1.5).height(4)).class(theme::Container::custom(move |_| container::Style {
|
||||
let terminal = container(Space::new().width(1.5).height(4)).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(outline)),
|
||||
border: Border {
|
||||
radius: 1.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}));
|
||||
row![body, terminal].spacing(1).align_y(Alignment::Center).into()
|
||||
}),
|
||||
);
|
||||
row![body, terminal]
|
||||
.spacing(1)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,13 @@ fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
|
||||
let (send, receive) = tokio::sync::mpsc::channel(1);
|
||||
let stop = match UnixStream::pair() {
|
||||
Ok((stop, cancel)) => {
|
||||
match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || {
|
||||
if let Err(error) = watch(send, cancel) {
|
||||
eprintln!("ferese-shell: clock subscription: {error}");
|
||||
}
|
||||
}) {
|
||||
match std::thread::Builder::new()
|
||||
.name("ferese-clock".into())
|
||||
.spawn(move || {
|
||||
if let Err(error) = watch(send, cancel) {
|
||||
eprintln!("ferese-shell: clock subscription: {error}");
|
||||
}
|
||||
}) {
|
||||
Ok(_) => Some(stop),
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: clock worker: {error}");
|
||||
@@ -52,8 +54,9 @@ fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
|
||||
}
|
||||
|
||||
fn watch(send: tokio::sync::mpsc::Sender<String>, cancel: UnixStream) -> io::Result<()> {
|
||||
let timer =
|
||||
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?;
|
||||
let timer = owned_fd(unsafe {
|
||||
libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
|
||||
})?;
|
||||
let (mut system, mut paths) = zones();
|
||||
let mut timezone = ZoneWatch::new(&paths)?;
|
||||
let mut previous = None;
|
||||
@@ -119,7 +122,10 @@ fn owned_fd(fd: libc::c_int) -> io::Result<OwnedFd> {
|
||||
|
||||
fn arm(timer: &OwnedFd, deadline: i128) -> io::Result<bool> {
|
||||
let spec = libc::itimerspec {
|
||||
it_interval: libc::timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
it_interval: libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
it_value: libc::timespec {
|
||||
tv_sec: deadline.div_euclid(SECOND) as _,
|
||||
tv_nsec: deadline.rem_euclid(SECOND) as _,
|
||||
@@ -146,7 +152,10 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
|
||||
let error = io::Error::last_os_error();
|
||||
// A wall-clock adjustment cancels the timer. Recompute labels/deadline
|
||||
// just as after expiration; CLOCK_REALTIME also advances during suspend.
|
||||
if !matches!(error.raw_os_error(), Some(libc::ECANCELED | libc::EAGAIN | libc::EINTR)) {
|
||||
if !matches!(
|
||||
error.raw_os_error(),
|
||||
Some(libc::ECANCELED | libc::EAGAIN | libc::EINTR)
|
||||
) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
@@ -154,12 +163,19 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
|
||||
}
|
||||
|
||||
fn zones() -> (TimeZone, Vec<PathBuf>) {
|
||||
let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")];
|
||||
let mut paths = vec![
|
||||
PathBuf::from("/etc/localtime"),
|
||||
PathBuf::from("/etc/timezone"),
|
||||
];
|
||||
// Load the file directly: Jiff's system timezone cache lasts five minutes.
|
||||
let system = match std::env::var("TZ") {
|
||||
Ok(zone) if zone.is_empty() => TimeZone::UTC,
|
||||
Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system),
|
||||
Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system),
|
||||
Ok(zone) => {
|
||||
named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system)
|
||||
}
|
||||
Err(_) => {
|
||||
file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system)
|
||||
}
|
||||
};
|
||||
(system, paths)
|
||||
}
|
||||
@@ -169,7 +185,11 @@ fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
|
||||
if name.eq_ignore_ascii_case("UTC") {
|
||||
return Some(TimeZone::UTC);
|
||||
}
|
||||
for root in ["/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo"] {
|
||||
for root in [
|
||||
"/usr/share/zoneinfo",
|
||||
"/usr/share/lib/zoneinfo",
|
||||
"/etc/zoneinfo",
|
||||
] {
|
||||
let path = Path::new(root).join(name);
|
||||
if let Some(zone) = file_zone(&path, paths) {
|
||||
return Some(zone);
|
||||
@@ -208,7 +228,9 @@ fn zone_deadline(now: &Zoned) -> i128 {
|
||||
now.time_zone()
|
||||
.following(now.timestamp())
|
||||
.next()
|
||||
.map_or(next, |transition| next.min(transition.timestamp().as_nanosecond()))
|
||||
.map_or(next, |transition| {
|
||||
next.min(transition.timestamp().as_nanosecond())
|
||||
})
|
||||
}
|
||||
|
||||
struct ZoneWatch {
|
||||
@@ -246,7 +268,13 @@ impl ZoneWatch {
|
||||
let mut buffer = [0u8; 8192];
|
||||
let mut changed = false;
|
||||
loop {
|
||||
let size = unsafe { libc::read(self.fd.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||
let size = unsafe {
|
||||
libc::read(
|
||||
self.fd.as_raw_fd(),
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
)
|
||||
};
|
||||
if size < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() == io::ErrorKind::WouldBlock {
|
||||
@@ -262,16 +290,24 @@ impl ZoneWatch {
|
||||
}
|
||||
let mut offset = 0;
|
||||
while offset + std::mem::size_of::<libc::inotify_event>() <= size as usize {
|
||||
let event = unsafe { (buffer.as_ptr().add(offset) as *const libc::inotify_event).read_unaligned() };
|
||||
let event = unsafe {
|
||||
(buffer.as_ptr().add(offset) as *const libc::inotify_event).read_unaligned()
|
||||
};
|
||||
offset += std::mem::size_of::<libc::inotify_event>();
|
||||
let end = offset + event.len as usize;
|
||||
if end > size as usize {
|
||||
break;
|
||||
}
|
||||
let name = &buffer[offset..end];
|
||||
let name = &name[..name.iter().position(|byte| *byte == 0).unwrap_or(name.len())];
|
||||
let name = &name[..name
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.unwrap_or(name.len())];
|
||||
changed |= event.mask
|
||||
& (libc::IN_Q_OVERFLOW | libc::IN_IGNORED | libc::IN_MOVE_SELF | libc::IN_DELETE_SELF)
|
||||
& (libc::IN_Q_OVERFLOW
|
||||
| libc::IN_IGNORED
|
||||
| libc::IN_MOVE_SELF
|
||||
| libc::IN_DELETE_SELF)
|
||||
!= 0
|
||||
|| self
|
||||
.names
|
||||
@@ -354,19 +390,33 @@ mod tests {
|
||||
}
|
||||
let zone = TimeZone::get("America/New_York").unwrap();
|
||||
for (stamp, expected, label) in [
|
||||
("2026-03-08T06:59:59Z", "2026-03-08T07:00:00Z", "8 mar, 3:00 am"),
|
||||
("2026-11-01T05:59:59Z", "2026-11-01T06:00:00Z", "1 nov, 1:00 am"),
|
||||
(
|
||||
"2026-03-08T06:59:59Z",
|
||||
"2026-03-08T07:00:00Z",
|
||||
"8 mar, 3:00 am",
|
||||
),
|
||||
(
|
||||
"2026-11-01T05:59:59Z",
|
||||
"2026-11-01T06:00:00Z",
|
||||
"1 nov, 1:00 am",
|
||||
),
|
||||
] {
|
||||
let now: Timestamp = stamp.parse().unwrap();
|
||||
let expected: Timestamp = expected.parse().unwrap();
|
||||
assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond());
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(zone.clone())),
|
||||
expected.as_nanosecond()
|
||||
);
|
||||
assert_eq!(labels(expected, &zone), label);
|
||||
}
|
||||
let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap());
|
||||
let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap();
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(zone)),
|
||||
"2026-10-04T12:34:30Z".parse::<Timestamp>().unwrap().as_nanosecond()
|
||||
"2026-10-04T12:34:30Z"
|
||||
.parse::<Timestamp>()
|
||||
.unwrap()
|
||||
.as_nanosecond()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,9 +439,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn absolute_timer_and_subscription_drop_wake_without_recurring_poll() {
|
||||
let timer =
|
||||
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })
|
||||
.unwrap();
|
||||
let timer = owned_fd(unsafe {
|
||||
libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK)
|
||||
})
|
||||
.unwrap();
|
||||
arm(&timer, Timestamp::now().as_nanosecond() - SECOND).unwrap();
|
||||
let mut poll = libc::pollfd {
|
||||
fd: timer.as_raw_fd(),
|
||||
@@ -411,7 +462,10 @@ mod tests {
|
||||
});
|
||||
assert!(receive.blocking_recv().is_some());
|
||||
drop(stop);
|
||||
done_receive.recv_timeout(Duration::from_secs(2)).unwrap().unwrap();
|
||||
done_receive
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(receive.blocking_recv().is_none());
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ impl SessionConnection {
|
||||
}
|
||||
|
||||
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
|
||||
let mut stream = self.stream.lock().map_err(|_| "Compositor connection failed")?;
|
||||
let mut stream = self
|
||||
.stream
|
||||
.lock()
|
||||
.map_err(|_| "Compositor connection failed")?;
|
||||
let request = ferese_ipc::Request {
|
||||
version: ferese_ipc::VERSION,
|
||||
id: 1,
|
||||
@@ -37,7 +40,8 @@ impl SessionConnection {
|
||||
args,
|
||||
};
|
||||
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
|
||||
let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
|
||||
let response: ferese_ipc::Response =
|
||||
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(error.message);
|
||||
}
|
||||
@@ -80,8 +84,12 @@ impl Approval {
|
||||
}
|
||||
|
||||
pub fn prepare(&self) -> Result<(), String> {
|
||||
if matches!(self.mode, Mode::Shutdown | Mode::Suspend) && login_inhibitors(self.mode)? != self.external {
|
||||
return Err("Applications changed their inhibitors; review the confirmation again".into());
|
||||
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
|
||||
&& login_inhibitors(self.mode)? != self.external
|
||||
{
|
||||
return Err(
|
||||
"Applications changed their inhibitors; review the confirmation again".into(),
|
||||
);
|
||||
}
|
||||
if self.mode == Mode::Shutdown {
|
||||
self.connection
|
||||
@@ -94,7 +102,9 @@ impl Approval {
|
||||
} else if self.mode == Mode::Suspend {
|
||||
let state = call("get-session-state")?;
|
||||
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
|
||||
return Err("Applications changed their inhibitors; review the confirmation again".into());
|
||||
return Err(
|
||||
"Applications changed their inhibitors; review the confirmation again".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -117,12 +127,19 @@ fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
|
||||
"org.freedesktop.login1.Manager",
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let inhibitors: Vec<(String, String, String, String, u32, u32)> =
|
||||
proxy.call("ListInhibitors", &()).map_err(|error| error.to_string())?;
|
||||
let what = if mode == Mode::Suspend { "sleep" } else { "shutdown" };
|
||||
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
|
||||
.call("ListInhibitors", &())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let what = if mode == Mode::Suspend {
|
||||
"sleep"
|
||||
} else {
|
||||
"shutdown"
|
||||
};
|
||||
let mut reasons = inhibitors
|
||||
.into_iter()
|
||||
.filter(|(flags, _, _, inhibition, _, _)| inhibition == "block" && flags.split(':').any(|flag| flag == what))
|
||||
.filter(|(flags, _, _, inhibition, _, _)| {
|
||||
inhibition == "block" && flags.split(':').any(|flag| flag == what)
|
||||
})
|
||||
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
|
||||
.collect::<Vec<_>>();
|
||||
reasons.sort();
|
||||
@@ -162,7 +179,9 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
|
||||
}
|
||||
}
|
||||
let flags = if mode == Mode::Suspend { 4 } else { 1 };
|
||||
let entries = state["inhibitors"].as_array().ok_or("Invalid inhibitor list")?;
|
||||
let entries = state["inhibitors"]
|
||||
.as_array()
|
||||
.ok_or("Invalid inhibitor list")?;
|
||||
let mut reasons = entries
|
||||
.iter()
|
||||
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
|
||||
@@ -171,7 +190,9 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
|
||||
.as_str()
|
||||
.filter(|app| !app.is_empty())
|
||||
.unwrap_or("Application");
|
||||
let reason = entry["reason"].as_str().unwrap_or("Requested session inhibition");
|
||||
let reason = entry["reason"]
|
||||
.as_str()
|
||||
.unwrap_or("Requested session inhibition");
|
||||
format!("{app}: {reason}")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -92,8 +92,10 @@ impl Default for ShellTheme {
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
surface_base: [17, 24, 33, 255],
|
||||
surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised)
|
||||
.expect("valid theme surface color"),
|
||||
surface_raised: parse_color(
|
||||
&ferese_config::theme::default_tokens().colors.surface_raised,
|
||||
)
|
||||
.expect("valid theme surface color"),
|
||||
surface_popover: [17, 24, 33, 255],
|
||||
text_primary: [244, 247, 251, 255],
|
||||
text_muted: [135, 147, 162, 255],
|
||||
@@ -130,7 +132,8 @@ impl ShellTheme {
|
||||
}
|
||||
|
||||
pub(crate) fn palette(self) -> ferese_theme::Palette {
|
||||
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
|
||||
let color =
|
||||
|[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
|
||||
let surface = color(self.surface_base);
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
@@ -352,10 +355,15 @@ pub(crate) fn load(snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig
|
||||
load_path(&path, snapshot)
|
||||
}
|
||||
|
||||
fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig, String> {
|
||||
fn load_path(
|
||||
path: &std::path::Path,
|
||||
snapshot: &ferese_ipc::theme::Snapshot,
|
||||
) -> Result<ShellConfig, String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ShellConfig::default());
|
||||
}
|
||||
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
|
||||
}
|
||||
let mut source = String::new();
|
||||
@@ -368,7 +376,8 @@ fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) ->
|
||||
if source.len() > ferese_config::file::LIMIT {
|
||||
return Err(format!("{} exceeds 60 KiB", path.display()));
|
||||
}
|
||||
parse_source(&source, snapshot).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
|
||||
parse_source(&source, snapshot)
|
||||
.map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(
|
||||
@@ -400,11 +409,18 @@ fn parse_document(
|
||||
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
|
||||
}
|
||||
|
||||
config.animations.validate().map_err(ferese_config::Error::from)?;
|
||||
config.notifications.validate().map_err(ferese_config::Error::from)?;
|
||||
config
|
||||
.animations
|
||||
.validate()
|
||||
.map_err(ferese_config::Error::from)?;
|
||||
config
|
||||
.notifications
|
||||
.validate()
|
||||
.map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.appearance = appearance;
|
||||
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
|
||||
theme.material_radius =
|
||||
nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
|
||||
theme.bar_radius = theme.material_radius;
|
||||
|
||||
Ok(ShellConfig {
|
||||
@@ -420,7 +436,9 @@ fn parse_document(
|
||||
wallpaper: config.theme.background,
|
||||
theme,
|
||||
theme_mode: mode,
|
||||
panels: config.panels.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
|
||||
panels: config
|
||||
.panels
|
||||
.unwrap_or_else(|| vec![crate::panel::Panel::default()]),
|
||||
status: StatusConfig {
|
||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||
..config.status
|
||||
@@ -433,7 +451,8 @@ fn parse_document(
|
||||
|
||||
impl ShellConfig {
|
||||
pub(crate) fn apply_theme(&mut self, theme: &ferese_config::theme::ResolvedTheme) {
|
||||
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
|
||||
let config: ThemeConfig =
|
||||
serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
|
||||
self.theme = shell_theme(&config);
|
||||
self.theme.appearance = theme.appearance;
|
||||
self.theme.high_contrast = theme.accessibility.increase_contrast;
|
||||
@@ -472,16 +491,21 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
reduce_transparency: defaults.reduce_transparency,
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
let mut background =
|
||||
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
background[3] = alpha;
|
||||
background
|
||||
},
|
||||
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
|
||||
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
|
||||
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
|
||||
.unwrap_or(defaults.bar_text_primary),
|
||||
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
|
||||
.unwrap_or(defaults.bar_text_muted),
|
||||
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
|
||||
surface_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised),
|
||||
surface_raised: parse_color(&theme.colors.surface_raised)
|
||||
.unwrap_or(defaults.surface_raised),
|
||||
surface_popover: {
|
||||
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
|
||||
let mut color =
|
||||
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
|
||||
color[3] = alpha;
|
||||
color
|
||||
},
|
||||
@@ -492,11 +516,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
||||
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
||||
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
||||
bar_radius: nonnegative_or(theme.geometry.shell_radius.unwrap_or(14.0), defaults.bar_radius),
|
||||
bar_radius: nonnegative_or(
|
||||
theme.geometry.shell_radius.unwrap_or(14.0),
|
||||
defaults.bar_radius,
|
||||
),
|
||||
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
|
||||
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
|
||||
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
|
||||
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity).clamp(0.0, 1.0),
|
||||
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
|
||||
.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,13 +622,21 @@ mod tests {
|
||||
assert_eq!(config.panels[0].morph.media_height, 184.);
|
||||
assert!(!config.panels[0].morph.peek_on_hover);
|
||||
assert!(
|
||||
parse_test_source("panel main { style morph; }\n").unwrap().panels[0]
|
||||
parse_test_source("panel main { style morph; }\n")
|
||||
.unwrap()
|
||||
.panels[0]
|
||||
.morph
|
||||
.peek_on_hover
|
||||
);
|
||||
assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err());
|
||||
assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err());
|
||||
assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err());
|
||||
assert!(
|
||||
parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err()
|
||||
);
|
||||
assert!(
|
||||
parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err()
|
||||
);
|
||||
assert!(
|
||||
parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -659,9 +695,14 @@ mod tests {
|
||||
.theme;
|
||||
let mut config = parse_test_source(&source).unwrap();
|
||||
config.apply_theme(&resolved);
|
||||
let effective = config.theme.panel_opacity(config.panels[0].background_opacity);
|
||||
let effective = config
|
||||
.theme
|
||||
.panel_opacity(config.panels[0].background_opacity);
|
||||
assert_eq!(effective, if reduce { 1. } else { opacity });
|
||||
assert_eq!(effective, resolved.panel_opacity(config.panels[0].background_opacity));
|
||||
assert_eq!(
|
||||
effective,
|
||||
resolved.panel_opacity(config.panels[0].background_opacity)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,7 +712,8 @@ mod tests {
|
||||
fn rejects_removed_physics_keys_before_publication() {
|
||||
for property in ["spring", "viewport-spring"] {
|
||||
for coefficient in ["mass", "stiffness", "damping", "damping-ratio"] {
|
||||
let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
|
||||
let source =
|
||||
format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}");
|
||||
let error = parse_test_source(&source).unwrap_err().to_string();
|
||||
assert!(error.contains("unknown field"), "{error}");
|
||||
}
|
||||
@@ -738,7 +780,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn group_surfaces_do_not_change_modal_opacity() {
|
||||
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
|
||||
let normal =
|
||||
parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
|
||||
let islands = parse_test_source(
|
||||
"panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }",
|
||||
)
|
||||
@@ -793,7 +836,10 @@ theme {
|
||||
config.theme.typography.font_family.as_deref(),
|
||||
Some("JetBrainsMono Nerd Font")
|
||||
);
|
||||
assert_eq!(config.theme.background.path, Some(PathBuf::from("/tmp/wallpaper.png")));
|
||||
assert_eq!(
|
||||
config.theme.background.path,
|
||||
Some(PathBuf::from("/tmp/wallpaper.png"))
|
||||
);
|
||||
assert_eq!(config.theme.background.mode, WallpaperMode::Fit);
|
||||
}
|
||||
|
||||
@@ -813,8 +859,7 @@ theme {
|
||||
fn loader_reports_invalid_configuration_without_replacing_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let source =
|
||||
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
|
||||
let source = "notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let error = super::load_path(&path, &ferese_theme_client::service::fallback()).unwrap_err();
|
||||
assert!(error.contains("bar_layout"), "{error}");
|
||||
@@ -842,7 +887,8 @@ theme {
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
let error = super::load_path(directory.path(), &ferese_theme_client::service::fallback()).unwrap_err();
|
||||
let error = super::load_path(directory.path(), &ferese_theme_client::service::fallback())
|
||||
.unwrap_err();
|
||||
assert!(error.contains("cannot read"), "{error}");
|
||||
let path = directory.path().join("config.kdl");
|
||||
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
|
||||
|
||||
@@ -21,7 +21,10 @@ pub(crate) struct ShellSnapshot {
|
||||
}
|
||||
|
||||
impl ShellSnapshot {
|
||||
pub(crate) fn workspaces_for_output(&self, output: Option<u64>) -> impl Iterator<Item = &WorkspaceSnapshot> {
|
||||
pub(crate) fn workspaces_for_output(
|
||||
&self,
|
||||
output: Option<u64>,
|
||||
) -> impl Iterator<Item = &WorkspaceSnapshot> {
|
||||
self.workspaces
|
||||
.iter()
|
||||
.filter(move |workspace| output.is_some() && workspace.output == output)
|
||||
@@ -60,7 +63,10 @@ pub(crate) struct WindowSnapshot {
|
||||
pub(crate) maximized: bool,
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "The current bar does not display window placement.")
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "The current bar does not display window placement."
|
||||
)
|
||||
)]
|
||||
pub(crate) floating: bool,
|
||||
}
|
||||
@@ -184,16 +190,30 @@ impl ShellControl {
|
||||
|
||||
loop {
|
||||
match self.updates.try_recv() {
|
||||
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::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::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),
|
||||
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
|
||||
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
|
||||
@@ -221,7 +241,8 @@ impl ShellControl {
|
||||
}
|
||||
let (hi, lo) = split_id(window);
|
||||
let (workspace_hi, workspace_lo) = split_id(workspace);
|
||||
self.shell.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
|
||||
self.shell
|
||||
.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
|
||||
@@ -240,7 +261,10 @@ impl ShellControl {
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "Window-targeting API; the current UI targets workspaces."
|
||||
)
|
||||
)]
|
||||
pub(crate) fn close_window(&self, id: u64) {
|
||||
let (hi, lo) = split_id(id);
|
||||
@@ -250,7 +274,10 @@ impl ShellControl {
|
||||
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "Window-targeting API; the current UI targets workspaces.")
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "Window-targeting API; the current UI targets workspaces."
|
||||
)
|
||||
)]
|
||||
pub(crate) fn select_overview_window(&self, id: u64) {
|
||||
let (hi, lo) = split_id(id);
|
||||
@@ -282,8 +309,8 @@ impl ShellControl {
|
||||
}
|
||||
|
||||
fn control_connection() -> Result<Connection, Box<dyn Error>> {
|
||||
let raw_fd =
|
||||
std::env::var_os("FERESE_SHELL_CONTROL_SOCKET").ok_or("Ferese did not provide a shell-control connection")?;
|
||||
let raw_fd = std::env::var_os("FERESE_SHELL_CONTROL_SOCKET")
|
||||
.ok_or("Ferese did not provide a shell-control connection")?;
|
||||
let fd = raw_fd
|
||||
.to_str()
|
||||
.ok_or("FERESE_SHELL_CONTROL_SOCKET is not valid UTF-8")?
|
||||
@@ -346,9 +373,11 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
window_hi,
|
||||
window_lo,
|
||||
} => {
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), None));
|
||||
let _ = state.sender.send(ControlUpdate::Thumbnail(
|
||||
token,
|
||||
join_id(window_hi, window_lo),
|
||||
None,
|
||||
));
|
||||
}
|
||||
ferese_shell_v1::Event::Thumbnail {
|
||||
token,
|
||||
@@ -370,14 +399,20 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
}
|
||||
let mut bytes = vec![0; size];
|
||||
file.read_exact(&mut bytes).ok()?;
|
||||
Some(cosmic::widget::image::Handle::from_rgba(width, height, bytes))
|
||||
Some(cosmic::widget::image::Handle::from_rgba(
|
||||
width, height, bytes,
|
||||
))
|
||||
})();
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Thumbnail(token, join_id(window_hi, window_lo), image));
|
||||
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));
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::ToggleLauncher(output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
|
||||
ferese_shell_v1::Event::ConfigChunk { source } => {
|
||||
@@ -441,7 +476,8 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
state: window_state,
|
||||
} => {
|
||||
// WEnum::Unknown holds the entire bitfield, not just new bits.
|
||||
let flags = ferese_shell_v1::WindowState::from_bits_truncate(u32::from(window_state));
|
||||
let flags =
|
||||
ferese_shell_v1::WindowState::from_bits_truncate(u32::from(window_state));
|
||||
|
||||
state.pending.windows.push(WindowSnapshot {
|
||||
id: join_id(window_hi, window_lo),
|
||||
@@ -479,14 +515,25 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
ferese_shell_v1::Event::Capabilities { .. } => {
|
||||
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
||||
}
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
|
||||
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial }
|
||||
if state.serial.take() == Some(serial) =>
|
||||
{
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Snapshot(state.pending.clone()));
|
||||
}
|
||||
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::Logout(serial, output_name));
|
||||
ferese_shell_v1::Event::LogoutRequested {
|
||||
serial,
|
||||
output_name,
|
||||
} => {
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::Logout(serial, output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::ToggleDisplayMode { output_name } => {
|
||||
let _ = state.sender.send(ControlUpdate::ToggleDisplays(output_name));
|
||||
let _ = state
|
||||
.sender
|
||||
.send(ControlUpdate::ToggleDisplays(output_name));
|
||||
}
|
||||
ferese_shell_v1::Event::MonitorStateChanged => {
|
||||
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
||||
@@ -636,7 +683,9 @@ mod tests {
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::BindingMode { name: "resize".into() },
|
||||
ferese_shell_v1::Event::BindingMode {
|
||||
name: "resize".into(),
|
||||
},
|
||||
);
|
||||
assert!(control.poll().snapshot.is_none());
|
||||
dispatch_event(
|
||||
@@ -645,7 +694,10 @@ mod tests {
|
||||
&queue,
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial: 1 },
|
||||
);
|
||||
assert_eq!(control.poll().snapshot.unwrap().binding_mode.as_deref(), Some("resize"));
|
||||
assert_eq!(
|
||||
control.poll().snapshot.unwrap().binding_mode.as_deref(),
|
||||
Some("resize")
|
||||
);
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
@@ -656,7 +708,9 @@ mod tests {
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
ferese_shell_v1::Event::BindingMode { name: String::new() },
|
||||
ferese_shell_v1::Event::BindingMode {
|
||||
name: String::new(),
|
||||
},
|
||||
);
|
||||
dispatch_event(
|
||||
&control,
|
||||
@@ -677,7 +731,10 @@ mod tests {
|
||||
ferese_shell_v1::Event::SnapshotBegin { serial: 7 },
|
||||
);
|
||||
let ids = [0x1234_5678_9abc_def0, 0xfedc_ba98_7654_3210];
|
||||
for (id, flags) in [(ids[0], 0), (ids[1], ferese_shell_v1::WindowState::Floating.bits())] {
|
||||
for (id, flags) in [
|
||||
(ids[0], 0),
|
||||
(ids[1], ferese_shell_v1::WindowState::Floating.bits()),
|
||||
] {
|
||||
dispatch_event(&control, &mut state, &queue, window_event(id, flags));
|
||||
}
|
||||
assert!(control.poll().snapshot.is_none());
|
||||
@@ -688,7 +745,14 @@ mod tests {
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial: 7 },
|
||||
);
|
||||
let snapshot = control.poll().snapshot.unwrap().clone();
|
||||
assert_eq!(snapshot.windows.iter().map(|window| window.id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.windows
|
||||
.iter()
|
||||
.map(|window| window.id)
|
||||
.collect::<Vec<_>>(),
|
||||
ids
|
||||
);
|
||||
let [tiled, floating] = snapshot.windows.as_slice() else {
|
||||
panic!("expected two windows")
|
||||
};
|
||||
@@ -818,11 +882,26 @@ mod tests {
|
||||
let cases = [
|
||||
(known.bits(), [true, true, true, true, false]),
|
||||
(known.bits() | future, [true, true, true, true, false]),
|
||||
(Flags::Focused.bits() | future, [true, false, false, false, false]),
|
||||
(Flags::Urgent.bits() | future, [false, true, false, false, false]),
|
||||
(Flags::Fullscreen.bits() | future, [false, false, true, false, false]),
|
||||
(Flags::Floating.bits() | future, [false, false, false, true, false]),
|
||||
(Flags::Maximized.bits() | future, [false, false, false, false, true]),
|
||||
(
|
||||
Flags::Focused.bits() | future,
|
||||
[true, false, false, false, false],
|
||||
),
|
||||
(
|
||||
Flags::Urgent.bits() | future,
|
||||
[false, true, false, false, false],
|
||||
),
|
||||
(
|
||||
Flags::Fullscreen.bits() | future,
|
||||
[false, false, true, false, false],
|
||||
),
|
||||
(
|
||||
Flags::Floating.bits() | future,
|
||||
[false, false, false, true, false],
|
||||
),
|
||||
(
|
||||
Flags::Maximized.bits() | future,
|
||||
[false, false, false, false, true],
|
||||
),
|
||||
(future, [false; 5]),
|
||||
(0, [false; 5]),
|
||||
];
|
||||
@@ -833,7 +912,12 @@ mod tests {
|
||||
ferese_shell_v1::Event::SnapshotBegin { serial: 1 },
|
||||
);
|
||||
for (index, (bits, _)) in cases.iter().enumerate() {
|
||||
dispatch_event(&control, &mut state, &queue, window_event(index as u64 + 1, *bits));
|
||||
dispatch_event(
|
||||
&control,
|
||||
&mut state,
|
||||
&queue,
|
||||
window_event(index as u64 + 1, *bits),
|
||||
);
|
||||
}
|
||||
dispatch_event(
|
||||
&control,
|
||||
@@ -925,13 +1009,19 @@ mod workspace_tests {
|
||||
focused: output == 1 && index == 1,
|
||||
};
|
||||
let snapshot = ShellSnapshot {
|
||||
workspaces: vec![workspace(7, 1, 1, 2), workspace(9, 1, 2, 0), workspace(3, 2, 1, 0)],
|
||||
workspaces: vec![
|
||||
workspace(7, 1, 1, 2),
|
||||
workspace(9, 1, 2, 0),
|
||||
workspace(3, 2, 1, 0),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let left = snapshot.workspaces_for_output(Some(1)).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
left.iter().map(|workspace| workspace.id).collect::<Vec<_>>(),
|
||||
left.iter()
|
||||
.map(|workspace| workspace.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![7, 9]
|
||||
);
|
||||
assert_eq!(left[0].window_count, 2);
|
||||
@@ -961,20 +1051,38 @@ mod wake_tests {
|
||||
sender,
|
||||
wake: wake.clone(),
|
||||
};
|
||||
sender.send(ControlUpdate::OverviewState(true)).ok().unwrap();
|
||||
sender
|
||||
.send(ControlUpdate::OverviewState(true))
|
||||
.ok()
|
||||
.unwrap();
|
||||
let stream = wake.stream();
|
||||
cosmic::iced::futures::pin_mut!(stream);
|
||||
|
||||
assert_eq!(stream.next().now_or_never(), Some(Some(())));
|
||||
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::OverviewState(true))));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(ControlUpdate::OverviewState(true))
|
||||
));
|
||||
assert_eq!(stream.next().now_or_never(), None);
|
||||
|
||||
sender.send(ControlUpdate::Logout(7, "display".into())).ok().unwrap();
|
||||
sender
|
||||
.send(ControlUpdate::Logout(7, "display".into()))
|
||||
.ok()
|
||||
.unwrap();
|
||||
sender.send(ControlUpdate::LogoutCancelled(7)).ok().unwrap();
|
||||
sender.send(ControlUpdate::Disconnected).ok().unwrap();
|
||||
assert_eq!(stream.next().now_or_never(), Some(Some(())));
|
||||
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::Logout(7, _))));
|
||||
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::LogoutCancelled(7))));
|
||||
assert!(matches!(receiver.try_recv(), Ok(ControlUpdate::Disconnected)));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(ControlUpdate::Logout(7, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(ControlUpdate::LogoutCancelled(7))
|
||||
));
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(ControlUpdate::Disconnected)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -190,11 +190,15 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn external_connected(&self) -> bool {
|
||||
self.inventory.as_ref().is_some_and(Inventory::external_connected)
|
||||
self.inventory
|
||||
.as_ref()
|
||||
.is_some_and(Inventory::external_connected)
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> bool {
|
||||
self.inventory.as_ref().is_some_and(|inventory| inventory.pending)
|
||||
self.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.pending)
|
||||
}
|
||||
|
||||
fn update(&mut self, inventory: Inventory) {
|
||||
@@ -249,7 +253,11 @@ impl FereseShell {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn select_display_mode(&mut self, mode: Mode, preserve_focus: bool) -> Task<Message> {
|
||||
pub(super) fn select_display_mode(
|
||||
&mut self,
|
||||
mode: Mode,
|
||||
preserve_focus: bool,
|
||||
) -> Task<Message> {
|
||||
if !self.display_mode.busy
|
||||
&& !self.display_mode.pending()
|
||||
&& self
|
||||
@@ -268,10 +276,12 @@ impl FereseShell {
|
||||
|
||||
// Arrow/number selection leaves Enter for the default action,
|
||||
// rather than activating a previously clicked or tabbed mode.
|
||||
return cosmic::iced::advanced::widget::operate(cosmic::iced::advanced::widget::operation::scoped(
|
||||
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
|
||||
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
|
||||
));
|
||||
return cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::scoped(
|
||||
cosmic::iced::advanced::widget::Id::new("ferese-system-modal-controls"),
|
||||
cosmic::iced::advanced::widget::operation::focusable::unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Task::none()
|
||||
@@ -289,7 +299,9 @@ impl FereseShell {
|
||||
.ok()
|
||||
.filter(|number| (1..=4).contains(number))
|
||||
.map(|number| Mode::ALL[number - 1]),
|
||||
Key::Named(Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown) => {
|
||||
Key::Named(
|
||||
Named::ArrowLeft | Named::ArrowUp | Named::ArrowRight | Named::ArrowDown,
|
||||
) => {
|
||||
let backwards = matches!(key, Key::Named(Named::ArrowLeft | Named::ArrowUp));
|
||||
(1..=4)
|
||||
.map(|step| Mode::ALL[(current + if backwards { 4 - step } else { step }) % 4])
|
||||
@@ -329,7 +341,12 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
self.display_operation(true, move || {
|
||||
compositor_ipc::call(if keep { "output-confirm" } else { "output-revert" }).map(|_| ())
|
||||
compositor_ipc::call(if keep {
|
||||
"output-confirm"
|
||||
} else {
|
||||
"output-revert"
|
||||
})
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,11 +400,16 @@ impl FereseShell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_mode_rows(&self) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
|
||||
pub(super) fn display_mode_rows(
|
||||
&self,
|
||||
) -> cosmic::widget::Column<'_, cosmic::Action<Message>, cosmic::Theme> {
|
||||
let palette = self.config.theme.palette();
|
||||
let material_opacity = self.config.theme.material_opacity();
|
||||
let model = &self.display_mode;
|
||||
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
|
||||
let focus_visible = self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.focus_visible);
|
||||
let pending = model.pending();
|
||||
let mut header = row![
|
||||
self.render.text("Display mode").size(17),
|
||||
@@ -441,17 +463,24 @@ impl FereseShell {
|
||||
material_opacity,
|
||||
focus_visible,
|
||||
))
|
||||
.on_press_maybe(available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode)))),
|
||||
.on_press_maybe(
|
||||
available.then_some(cosmic::Action::App(Message::SelectDisplayMode(mode))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let mut rows = cosmic::widget::column![header, cards].spacing(14);
|
||||
if let Some(inventory) = &model.inventory {
|
||||
let mut displays = cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
|
||||
let mut displays =
|
||||
cosmic::widget::column::with_capacity(inventory.displays.len()).spacing(6);
|
||||
for display in &inventory.displays {
|
||||
displays = displays.push(
|
||||
row![
|
||||
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
|
||||
ferese_theme::icons::tinted(
|
||||
ferese_theme::icons::DISPLAY,
|
||||
16,
|
||||
palette.muted
|
||||
),
|
||||
self.render
|
||||
.text(&display.label)
|
||||
.size(12)
|
||||
@@ -476,7 +505,11 @@ impl FereseShell {
|
||||
.inventory
|
||||
.as_ref()
|
||||
.map_or(15, |inventory| inventory.timeout)
|
||||
.saturating_sub(model.confirmation_since.map_or(0, |since| since.elapsed().as_secs()));
|
||||
.saturating_sub(
|
||||
model
|
||||
.confirmation_since
|
||||
.map_or(0, |since| since.elapsed().as_secs()),
|
||||
);
|
||||
format!("Keep this configuration? Reverts in {remaining}s.")
|
||||
} else {
|
||||
"Arrow keys or 1–4 to choose. Enter to apply.".to_owned()
|
||||
@@ -497,43 +530,48 @@ impl FereseShell {
|
||||
.inventory
|
||||
.as_ref()
|
||||
.is_some_and(|inventory| inventory.available(model.selected)));
|
||||
rows.push(self.render.text(hint).size(12).class(theme::Text::Color(palette.muted)))
|
||||
.push(
|
||||
container(
|
||||
row![
|
||||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Revert" } else { "Cancel" },
|
||||
self.render.font,
|
||||
palette,
|
||||
false
|
||||
)
|
||||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||||
palette,
|
||||
false,
|
||||
material_opacity,
|
||||
focus_visible
|
||||
))
|
||||
.id("ferese-modal-cancel".into())
|
||||
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Keep" } else { "Apply" },
|
||||
self.render.font,
|
||||
palette,
|
||||
true
|
||||
)
|
||||
.class(ferese_theme::controls::button_style_with_focus(
|
||||
palette,
|
||||
true,
|
||||
focus_visible
|
||||
))
|
||||
.id("ferese-display-apply".into())
|
||||
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.id("ferese-display-modal-actions"),
|
||||
rows.push(
|
||||
self.render
|
||||
.text(hint)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(palette.muted)),
|
||||
)
|
||||
.push(
|
||||
container(
|
||||
row![
|
||||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Revert" } else { "Cancel" },
|
||||
self.render.font,
|
||||
palette,
|
||||
false
|
||||
)
|
||||
.class(ferese_theme::controls::material_button_style_with_focus(
|
||||
palette,
|
||||
false,
|
||||
material_opacity,
|
||||
focus_visible
|
||||
))
|
||||
.id("ferese-modal-cancel".into())
|
||||
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
||||
ferese_theme::controls::text_button(
|
||||
if pending { "Keep" } else { "Apply" },
|
||||
self.render.font,
|
||||
palette,
|
||||
true
|
||||
)
|
||||
.class(ferese_theme::controls::button_style_with_focus(
|
||||
palette,
|
||||
true,
|
||||
focus_visible
|
||||
))
|
||||
.id("ferese-display-apply".into())
|
||||
.on_press_maybe(can_apply.then_some(cosmic::Action::App(apply))),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.id("ferese-display-modal-actions"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,17 +593,26 @@ mod tests {
|
||||
fn disabled_and_mirrored_external_monitors_keep_the_bar_trigger() {
|
||||
for mirror in [false, true] {
|
||||
let inventory = Inventory::parse(
|
||||
json!([output(true, true, true, false), output(false, mirror, true, mirror)]),
|
||||
json!([
|
||||
output(true, true, true, false),
|
||||
output(false, mirror, true, mirror)
|
||||
]),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inventory.external_connected());
|
||||
assert_eq!(inventory.current(), if mirror { Mode::Mirror } else { Mode::Internal });
|
||||
assert_eq!(
|
||||
inventory.current(),
|
||||
if mirror { Mode::Mirror } else { Mode::Internal }
|
||||
);
|
||||
assert!(Mode::ALL.into_iter().all(|mode| inventory.available(mode)));
|
||||
}
|
||||
|
||||
let removed = Inventory::parse(
|
||||
json!([output(true, true, true, false), output(false, false, false, false)]),
|
||||
json!([
|
||||
output(true, true, true, false),
|
||||
output(false, false, false, false)
|
||||
]),
|
||||
json!({}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -579,7 +626,10 @@ mod tests {
|
||||
#[test]
|
||||
fn confirmation_tracks_backend_state_and_profile_timeout() {
|
||||
let mut model = Model::default();
|
||||
let outputs = json!([output(true, true, true, false), output(false, true, true, false)]);
|
||||
let outputs = json!([
|
||||
output(true, true, true, false),
|
||||
output(false, true, true, false)
|
||||
]);
|
||||
model.update(
|
||||
Inventory::parse(
|
||||
outputs.clone(),
|
||||
@@ -591,7 +641,9 @@ mod tests {
|
||||
assert_eq!(model.selected, Mode::Extend);
|
||||
assert_eq!(model.inventory.as_ref().unwrap().timeout, 7);
|
||||
let since = model.confirmation_since;
|
||||
model.update(Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap());
|
||||
model.update(
|
||||
Inventory::parse(outputs.clone(), json!({"confirmation_pending": true})).unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
model.confirmation_since, since,
|
||||
"inventory refresh must not restart the countdown"
|
||||
|
||||
@@ -6,7 +6,11 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
/// Follow a versioned snapshot, clearing stale content when its connection ends.
|
||||
pub(crate) fn stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64) -> impl Stream<Item = Arc<S>>
|
||||
pub(crate) fn stream<S>(
|
||||
get: &'static str,
|
||||
watch: &'static str,
|
||||
revision: fn(&S) -> u64,
|
||||
) -> impl Stream<Item = Arc<S>>
|
||||
where
|
||||
S: DeserializeOwned + Default + Send + Sync + 'static,
|
||||
{
|
||||
@@ -99,10 +103,14 @@ mod tests {
|
||||
);
|
||||
return;
|
||||
}
|
||||
check_stream::<ferese_ipc::media::Snapshot>("media-get", "media-watch", |snapshot| snapshot.revision);
|
||||
check_stream::<ferese_ipc::activities::Snapshot>("activity-get", "activity-watch", |snapshot| {
|
||||
check_stream::<ferese_ipc::media::Snapshot>("media-get", "media-watch", |snapshot| {
|
||||
snapshot.revision
|
||||
});
|
||||
check_stream::<ferese_ipc::activities::Snapshot>(
|
||||
"activity-get",
|
||||
"activity-watch",
|
||||
|snapshot| snapshot.revision,
|
||||
);
|
||||
}
|
||||
|
||||
fn check_stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64)
|
||||
@@ -122,32 +130,53 @@ mod tests {
|
||||
match listener.accept() {
|
||||
Ok((connection, _)) => break connection,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(std::time::Instant::now() < deadline, "subscription did not reconnect");
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"subscription did not reconnect"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => panic!("accept: {error}"),
|
||||
}
|
||||
};
|
||||
connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap();
|
||||
connection.set_write_timeout(Some(Duration::from_secs(8))).unwrap();
|
||||
connection
|
||||
.set_read_timeout(Some(Duration::from_secs(8)))
|
||||
.unwrap();
|
||||
connection
|
||||
.set_write_timeout(Some(Duration::from_secs(8)))
|
||||
.unwrap();
|
||||
let request: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(request.command, get);
|
||||
let mut snapshot = serde_json::to_value(S::default()).unwrap();
|
||||
snapshot["revision"] = json!(initial);
|
||||
write_frame(&mut connection, &Response::success(request.id, snapshot.clone())).unwrap();
|
||||
write_frame(
|
||||
&mut connection,
|
||||
&Response::success(request.id, snapshot.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let request: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(request.command, watch);
|
||||
assert_eq!(request.args["since"], initial);
|
||||
if initial == 1 {
|
||||
// Wait for delivery so watch-channel coalescing cannot skip this snapshot.
|
||||
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 1);
|
||||
assert_eq!(
|
||||
consumed_receive
|
||||
.recv_timeout(Duration::from_secs(8))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
snapshot["revision"] = json!(2);
|
||||
write_frame(&mut connection, &Response::success(request.id, snapshot)).unwrap();
|
||||
let next: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(next.command, watch);
|
||||
assert_eq!(next.args["since"], 2);
|
||||
assert_ne!(request.id, next.id);
|
||||
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 2);
|
||||
assert_eq!(
|
||||
consumed_receive
|
||||
.recv_timeout(Duration::from_secs(8))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
// Dropping this connection must clear the old snapshot before retrying.
|
||||
} else {
|
||||
watch_send.send(()).unwrap();
|
||||
@@ -166,18 +195,21 @@ mod tests {
|
||||
.block_on(async {
|
||||
let mut subscription = Box::pin(stream::<S>(get, watch, revision));
|
||||
for expected in [1, 2, 0, 3] {
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot =
|
||||
tokio::time::timeout(Duration::from_secs(8), subscription.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(revision(&snapshot), expected);
|
||||
if expected == 1 || expected == 2 {
|
||||
consumed.send(expected).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
watch_receive.recv_timeout(Duration::from_secs(2)).unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(subscription);
|
||||
});
|
||||
server.join().unwrap();
|
||||
|
||||
@@ -6,7 +6,8 @@ pub(super) struct Entry {
|
||||
|
||||
pub(super) fn load() -> Result<Vec<Entry>, String> {
|
||||
let entries: Vec<Entry> =
|
||||
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?).map_err(|error| error.to_string())?;
|
||||
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if entries.len() > 256
|
||||
|| entries
|
||||
.iter()
|
||||
@@ -18,7 +19,11 @@ pub(super) fn load() -> Result<Vec<Entry>, String> {
|
||||
}
|
||||
|
||||
impl crate::FereseShell {
|
||||
pub(super) fn load_guide(&mut self, manual: bool, output: Option<String>) -> cosmic::app::Task<crate::Message> {
|
||||
pub(super) fn load_guide(
|
||||
&mut self,
|
||||
manual: bool,
|
||||
output: Option<String>,
|
||||
) -> cosmic::app::Task<crate::Message> {
|
||||
let generation = self.guide_load.begin(manual);
|
||||
cosmic::app::Task::perform(
|
||||
async {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::sources::{Matcher, ResultId, Row, Source, Target};
|
||||
use fff_search::{
|
||||
FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser, SharedFilePicker, SharedFrecency,
|
||||
FilePicker, FilePickerOptions, FuzzySearchOptions, PaginationArgs, QueryParser,
|
||||
SharedFilePicker, SharedFrecency,
|
||||
};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -27,7 +28,8 @@ 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::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());
|
||||
@@ -55,7 +57,9 @@ impl Files {
|
||||
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,
|
||||
Some(Ok(picker)) if !picker.wait_for_scan(std::time::Duration::ZERO) => {
|
||||
Status::Indexing
|
||||
}
|
||||
_ => Status::Ready,
|
||||
}
|
||||
}
|
||||
@@ -94,7 +98,10 @@ impl Files {
|
||||
None,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 2,
|
||||
pagination: PaginationArgs { offset: 0, limit: 100 },
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -111,7 +118,11 @@ impl Files {
|
||||
.into_iter()
|
||||
.take_while(|_| !cancel.cancelled())
|
||||
.map(|path| {
|
||||
let title = path.file_name().unwrap_or_default().to_string_lossy().into_owned();
|
||||
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();
|
||||
|
||||
@@ -5,8 +5,9 @@ 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 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)?;
|
||||
@@ -16,7 +17,10 @@ pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
|
||||
}
|
||||
spawn(&mut command)
|
||||
} else {
|
||||
open("gio", &[std::ffi::OsStr::new("launch"), desktop.as_os_str()])
|
||||
open(
|
||||
"gio",
|
||||
&[std::ffi::OsStr::new("launch"), desktop.as_os_str()],
|
||||
)
|
||||
};
|
||||
if result.is_ok()
|
||||
&& let Err(error) = apps::record(&id)
|
||||
@@ -25,7 +29,9 @@ pub(super) fn run(target: Target, terminal: &[String]) -> Result<(), String> {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Target::File(path) => return open("gio", &[std::ffi::OsStr::new("open"), path.as_os_str()]),
|
||||
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(|_|());
|
||||
}
|
||||
|
||||
@@ -112,7 +112,11 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
impl FereseShell {
|
||||
pub(super) fn attach_launcher_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) {
|
||||
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;
|
||||
};
|
||||
@@ -218,7 +222,9 @@ impl FereseShell {
|
||||
.map(Arc::new)
|
||||
.unwrap_or_else(|_| Arc::new(apps::Apps::empty()))
|
||||
},
|
||||
move |apps| cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps))),
|
||||
move |apps| {
|
||||
cosmic::Action::App(super::Message::Launcher(Message::Loaded(id, apps)))
|
||||
},
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -266,7 +272,9 @@ impl FereseShell {
|
||||
let Some(launcher) = &self.launcher else {
|
||||
return Subscription::none();
|
||||
};
|
||||
let interval = if launcher.progress.is_animating() || launcher.height.needs_update(launcher.target_height()) {
|
||||
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
|
||||
@@ -275,7 +283,8 @@ impl FereseShell {
|
||||
} else {
|
||||
return Subscription::none();
|
||||
};
|
||||
cosmic::iced::time::every(Duration::from_millis(interval)).map(|_| super::Message::Launcher(Message::Tick))
|
||||
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)
|
||||
@@ -286,18 +295,26 @@ impl FereseShell {
|
||||
};
|
||||
if launcher.closing
|
||||
|| (launcher.query.is_empty() && launcher.category.is_none())
|
||||
|| !launcher.category.is_none_or(|category| category == Category::Windows)
|
||||
|| !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);
|
||||
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(_)));
|
||||
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() {
|
||||
@@ -312,7 +329,10 @@ impl FereseShell {
|
||||
let Some(launcher) = &mut self.launcher else {
|
||||
return Task::none();
|
||||
};
|
||||
if launcher.running || launcher.closing || (launcher.query.is_empty() && launcher.category.is_none()) {
|
||||
if launcher.running
|
||||
|| launcher.closing
|
||||
|| (launcher.query.is_empty() && launcher.category.is_none())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(apps) = launcher.apps.clone() else {
|
||||
@@ -362,14 +382,17 @@ impl FereseShell {
|
||||
.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)))
|
||||
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);
|
||||
self.notifications
|
||||
.service_error("Launcher action failed", &error);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
@@ -379,11 +402,13 @@ impl FereseShell {
|
||||
match message {
|
||||
Message::Dismiss(id) if id == launcher.id => self.close_launcher(),
|
||||
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));
|
||||
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.ghost_visible =
|
||||
launcher.query.starts_with(&query) || query.starts_with(&launcher.query);
|
||||
launcher.query = query;
|
||||
launcher.alternatives = false;
|
||||
launcher.changed();
|
||||
@@ -399,7 +424,11 @@ impl FereseShell {
|
||||
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 {
|
||||
let Some(index) = launcher
|
||||
.rows
|
||||
.iter()
|
||||
.position(|candidate| candidate.id == row)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
launcher.selected = index;
|
||||
@@ -415,10 +444,12 @@ impl FereseShell {
|
||||
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()));
|
||||
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);
|
||||
@@ -485,13 +516,18 @@ impl FereseShell {
|
||||
_ => Task::none(),
|
||||
}
|
||||
}
|
||||
pub(super) fn launcher_event(&mut self, event: &Event, id: window::Id) -> Option<Task<super::Message>> {
|
||||
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))),
|
||||
window::run(id, native_wayland_surface).map(move |surface| {
|
||||
cosmic::Action::App(super::Message::NativeSurface(id, surface))
|
||||
}),
|
||||
]));
|
||||
}
|
||||
if matches!(
|
||||
@@ -509,11 +545,16 @@ impl FereseShell {
|
||||
if launcher.closing {
|
||||
return Some(Task::none());
|
||||
}
|
||||
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_))) {
|
||||
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 {
|
||||
let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key, modifiers, .. }) =
|
||||
event
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if launcher.query.is_empty() && launcher.category.is_none() {
|
||||
@@ -571,7 +612,8 @@ impl FereseShell {
|
||||
}
|
||||
Key::Named(Named::ArrowDown) => {
|
||||
if launcher.alternatives || launcher.query.is_empty() {
|
||||
launcher.selected = (launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
|
||||
launcher.selected =
|
||||
(launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
|
||||
}
|
||||
launcher.alternatives = true;
|
||||
Task::none()
|
||||
@@ -583,7 +625,8 @@ impl FereseShell {
|
||||
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));
|
||||
launcher.selected =
|
||||
(launcher.selected + 1).min(launcher.rows.len().saturating_sub(1));
|
||||
Task::none()
|
||||
}
|
||||
Key::Named(Named::ArrowLeft)
|
||||
@@ -624,7 +667,8 @@ impl FereseShell {
|
||||
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 category =
|
||||
[Category::Apps, Category::Files, Category::Windows][launcher.selected.min(2)];
|
||||
let id = launcher.id;
|
||||
return self.update_launcher(Message::Browse(id, category));
|
||||
}
|
||||
@@ -681,7 +725,9 @@ impl FereseShell {
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()))
|
||||
},
|
||||
move |result| cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result))),
|
||||
move |result| {
|
||||
cosmic::Action::App(super::Message::Launcher(Message::Launched(id, result)))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,12 @@ struct WindowEntry {
|
||||
seen: bool,
|
||||
}
|
||||
impl Windows {
|
||||
pub(super) fn sync(&mut self, snapshot: &ShellSnapshot, apps: Option<&super::apps::Apps>, query: &str) -> bool {
|
||||
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();
|
||||
@@ -171,13 +176,17 @@ impl Windows {
|
||||
.or_default()
|
||||
.clone_from(&workspace.name);
|
||||
}
|
||||
self.workspaces.retain(|id, _| self.workspace_ids.contains(id));
|
||||
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);
|
||||
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
|
||||
@@ -231,7 +240,9 @@ impl Windows {
|
||||
changed
|
||||
}
|
||||
pub(super) fn rows(&self) -> impl Iterator<Item = &Row> {
|
||||
self.entries.values().filter_map(|entry| entry.result.as_ref())
|
||||
self.entries
|
||||
.values()
|
||||
.filter_map(|entry| entry.result.as_ref())
|
||||
}
|
||||
}
|
||||
impl Source for Windows {
|
||||
|
||||
@@ -42,7 +42,10 @@ fn escape_pops_one_layer_in_every_category() {
|
||||
#[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 _ = 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.);
|
||||
@@ -57,13 +60,28 @@ 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));
|
||||
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));
|
||||
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));
|
||||
let _ = shell.update_launcher(Message::Results(
|
||||
id,
|
||||
0,
|
||||
vec![row("old")],
|
||||
files::Status::Ready,
|
||||
));
|
||||
assert!(shell.launcher.as_ref().unwrap().rows.is_empty());
|
||||
}
|
||||
#[test]
|
||||
@@ -134,7 +152,12 @@ fn queries_are_coalesced_and_only_the_latest_result_is_applied() {
|
||||
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));
|
||||
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(
|
||||
|
||||
@@ -35,7 +35,8 @@ impl Thumbnails {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
self.images.retain(|id, _| self.visible.contains(&Some(*id)));
|
||||
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);
|
||||
@@ -45,11 +46,19 @@ impl Thumbnails {
|
||||
}
|
||||
}
|
||||
impl FereseShell {
|
||||
pub(crate) fn launcher_thumbnail(&mut self, token: u32, window: u64, image: Option<image::Handle>) {
|
||||
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) {
|
||||
if launcher.closing
|
||||
|| launcher.thumbnails.token != token
|
||||
|| !launcher.thumbnails.pending.remove(&window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(image) = image {
|
||||
@@ -72,12 +81,28 @@ mod tests {
|
||||
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());
|
||||
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());
|
||||
assert!(
|
||||
shell
|
||||
.launcher
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.thumbnails
|
||||
.images
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,11 @@ impl FereseShell {
|
||||
let completion = launcher
|
||||
.rows
|
||||
.get(launcher.selected)
|
||||
.filter(|_| !launcher.query.is_empty() && launcher.ghost_visible && launcher.query.chars().count() <= 28)
|
||||
.filter(|_| {
|
||||
!launcher.query.is_empty()
|
||||
&& launcher.ghost_visible
|
||||
&& launcher.query.chars().count() <= 28
|
||||
})
|
||||
.map(|row| {
|
||||
if row
|
||||
.title
|
||||
@@ -168,7 +172,8 @@ impl FereseShell {
|
||||
.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));
|
||||
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;
|
||||
@@ -225,7 +230,10 @@ impl FereseShell {
|
||||
let input_content = if let Some(error) = launcher.status_message() {
|
||||
column![
|
||||
input_row,
|
||||
self.render.text(error).size(12).class(theme::Text::Color(muted))
|
||||
self.render
|
||||
.text(error)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted))
|
||||
]
|
||||
.spacing(2)
|
||||
} else {
|
||||
@@ -243,13 +251,22 @@ impl FereseShell {
|
||||
];
|
||||
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 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) {
|
||||
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 {
|
||||
@@ -278,11 +295,13 @@ impl FereseShell {
|
||||
.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
|
||||
}))
|
||||
.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()))),
|
||||
);
|
||||
}
|
||||
@@ -295,14 +314,21 @@ impl FereseShell {
|
||||
);
|
||||
}
|
||||
if end < total {
|
||||
grid = grid.push(usize::MAX - 1, Space::new().height((total - end) as f32 * 108. - 8.));
|
||||
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" })
|
||||
.text(if launcher.running {
|
||||
"Loading apps…"
|
||||
} else {
|
||||
"No apps"
|
||||
})
|
||||
.size(14)
|
||||
.class(theme::Text::Color(muted)),
|
||||
)
|
||||
@@ -337,7 +363,12 @@ impl FereseShell {
|
||||
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 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)
|
||||
@@ -368,7 +399,11 @@ impl FereseShell {
|
||||
.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 })
|
||||
.unwrap_or(if w.app_id.is_empty() {
|
||||
&w.title
|
||||
} else {
|
||||
&w.app_id
|
||||
})
|
||||
}))
|
||||
.size(14)
|
||||
.class(theme::Text::Color(text));
|
||||
@@ -390,7 +425,10 @@ impl FereseShell {
|
||||
.width(Length::Fill)
|
||||
.padding(0)
|
||||
.class(button_style(Color::TRANSPARENT))
|
||||
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
|
||||
.on_press(message(Message::ActivateRow(
|
||||
launcher.id,
|
||||
result.id.clone(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
if launcher.rows.is_empty() {
|
||||
@@ -402,7 +440,9 @@ impl FereseShell {
|
||||
);
|
||||
}
|
||||
body = body.push(container(cards).padding([0, 16]));
|
||||
} else if launcher.alternatives || (launcher.query.is_empty() && launcher.category.is_some()) {
|
||||
} 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) {
|
||||
@@ -437,11 +477,17 @@ impl FereseShell {
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Length::Fill);
|
||||
let mut content = row![glyph, labels].spacing(12).align_y(cosmic::iced::Alignment::Center);
|
||||
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" })
|
||||
.text(if result.requires_alt() {
|
||||
"Alt+Enter"
|
||||
} else {
|
||||
"Enter"
|
||||
})
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
@@ -463,7 +509,10 @@ impl FereseShell {
|
||||
},
|
||||
18.,
|
||||
))
|
||||
.on_press(message(Message::ActivateRow(launcher.id, result.id.clone()))),
|
||||
.on_press(message(Message::ActivateRow(
|
||||
launcher.id,
|
||||
result.id.clone(),
|
||||
))),
|
||||
);
|
||||
}
|
||||
if launcher.rows.is_empty() {
|
||||
|
||||
+271
-94
@@ -39,11 +39,12 @@ use cosmic::iced::platform_specific::runtime::wayland::layer_surface::{
|
||||
IcedMargin, IcedOutput, SctkLayerSurfaceSettings,
|
||||
};
|
||||
use cosmic::iced::platform_specific::shell::commands::layer_surface::{
|
||||
Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, set_anchor, set_exclusive_zone, set_input_zone,
|
||||
set_margin, set_size,
|
||||
Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, set_anchor, set_exclusive_zone,
|
||||
set_input_zone, set_margin, set_size,
|
||||
};
|
||||
use cosmic::iced::{
|
||||
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, alignment, event, window,
|
||||
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, alignment, event,
|
||||
window,
|
||||
};
|
||||
use cosmic::widget::{button, container, icon, image, row};
|
||||
use cosmic::{Element, theme};
|
||||
@@ -124,9 +125,12 @@ fn main() -> cosmic::iced::Result {
|
||||
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
||||
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path))
|
||||
.map(|pixels| image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw()))
|
||||
.map_err(|error| format!("{error:?}"));
|
||||
let result =
|
||||
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path))
|
||||
.map(|pixels| {
|
||||
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
|
||||
})
|
||||
.map_err(|error| format!("{error:?}"));
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
receiver
|
||||
@@ -143,7 +147,8 @@ fn main() -> cosmic::iced::Result {
|
||||
cosmic::app::run::<FereseShell>(settings, (config, wallpaper, startup_error, theme_snapshot))
|
||||
}
|
||||
|
||||
type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
|
||||
type WallpaperLoad =
|
||||
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
|
||||
|
||||
struct FereseShell {
|
||||
core: Core,
|
||||
@@ -210,7 +215,11 @@ enum Message {
|
||||
HubWorkspaceHover(window::Id, Option<u64>),
|
||||
HubWorkspaceSelect(window::Id, u64),
|
||||
HubWorkspaceMove(window::Id, u64, u64),
|
||||
HubWorkspaceFocus(window::Id, Option<u64>, Option<cosmic::iced::keyboard::key::Named>),
|
||||
HubWorkspaceFocus(
|
||||
window::Id,
|
||||
Option<u64>,
|
||||
Option<cosmic::iced::keyboard::key::Named>,
|
||||
),
|
||||
HubToastExpired(window::Id, Instant),
|
||||
HubWorkspaceFeedbackExpired(window::Id, Instant),
|
||||
HubIgnore,
|
||||
@@ -223,7 +232,10 @@ enum Message {
|
||||
ThemeModeSet(Result<(), String>),
|
||||
WallpaperLoaded(Result<image::Handle, String>),
|
||||
Event(Event, window::Id),
|
||||
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
|
||||
NativeSurface(
|
||||
window::Id,
|
||||
Result<(Connection, wl_surface::WlSurface), String>,
|
||||
),
|
||||
ClockChanged(String),
|
||||
RecorderEvent(recording::Event),
|
||||
RecorderElapsed,
|
||||
@@ -233,7 +245,10 @@ enum Message {
|
||||
MediaTick,
|
||||
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
|
||||
ActivityTick,
|
||||
Pomodoro(ferese_ipc::activities::PomodoroAction, Option<ferese_activities::Id>),
|
||||
Pomodoro(
|
||||
ferese_ipc::activities::PomodoroAction,
|
||||
Option<ferese_activities::Id>,
|
||||
),
|
||||
PomodoroResult(Result<(), String>),
|
||||
ActivityHover(window::Id, bool),
|
||||
ActivityActivate(window::Id, ferese_activities::Id),
|
||||
@@ -324,8 +339,13 @@ impl cosmic::Application for FereseShell {
|
||||
.ok();
|
||||
// The compositor reports the complete configuration error through its
|
||||
// accepted-theme snapshot. Use the local warning only when standalone.
|
||||
let pending_config_error = if control.is_none() { startup_error } else { None };
|
||||
let mut notification_center = notifications::Center::new(config.notifications.clone(), config.animations);
|
||||
let pending_config_error = if control.is_none() {
|
||||
startup_error
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut notification_center =
|
||||
notifications::Center::new(config.notifications.clone(), config.animations);
|
||||
notification_center.activity_bridge = config.activities.sources.notification_bridge;
|
||||
let app = Self {
|
||||
core,
|
||||
@@ -366,7 +386,9 @@ impl cosmic::Application for FereseShell {
|
||||
let wallpaper_task = match wallpaper {
|
||||
Some(receiver) => cosmic::task::future(async move {
|
||||
cosmic::Action::App(Message::WallpaperLoaded(
|
||||
receiver.await.unwrap_or_else(|error| Err(error.to_string())),
|
||||
receiver
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(error.to_string())),
|
||||
))
|
||||
}),
|
||||
None => Task::none(),
|
||||
@@ -378,46 +400,66 @@ 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)
|
||||
}),
|
||||
ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
self.notifications.subscription().map(Message::NotificationEvent),
|
||||
self.control
|
||||
.as_ref()
|
||||
.map_or_else(Subscription::none, |control| {
|
||||
control.subscription().map(|_| Message::ControlReady)
|
||||
}),
|
||||
ferese_theme_client::service::subscription()
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
self.notifications
|
||||
.subscription()
|
||||
.map(Message::NotificationEvent),
|
||||
self.notifications
|
||||
.tick_subscription()
|
||||
.map(|_| Message::NotificationTick),
|
||||
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),
|
||||
key:
|
||||
cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
|
||||
modifiers,
|
||||
..
|
||||
}) 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)),
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => {
|
||||
EFFECT_FRAME_PENDING
|
||||
.load(Ordering::Relaxed)
|
||||
.then_some(Message::Event(event, id))
|
||||
}
|
||||
Event::Keyboard(_)
|
||||
| Event::Mouse(
|
||||
cosmic::iced::mouse::Event::ButtonPressed(_)
|
||||
| cosmic::iced::mouse::Event::CursorLeft
|
||||
| cosmic::iced::mouse::Event::CursorEntered,
|
||||
)
|
||||
| Event::Window(window::Event::Opened { .. } | window::Event::Closed | window::Event::Rescaled(_))
|
||||
| Event::Window(
|
||||
window::Event::Opened { .. }
|
||||
| window::Event::Closed
|
||||
| window::Event::Rescaled(_),
|
||||
)
|
||||
| Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
|
||||
wayland::Event::Popup(..)
|
||||
| wayland::Event::Layer(..)
|
||||
| wayland::Event::Output(..),
|
||||
)) => Some(Message::Event(event, id)),
|
||||
_ => None,
|
||||
}),
|
||||
clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
|
||||
clock::subscription(self.outputs.iter().any(|output| !output.hidden))
|
||||
.map(Message::ClockChanged),
|
||||
activities::subscription::subscription().map(Message::ActivitiesUpdated),
|
||||
self.activity_tick_subscription().map(|_| Message::ActivityTick),
|
||||
self.activity_tick_subscription()
|
||||
.map(|_| Message::ActivityTick),
|
||||
self.media.subscription().map(Message::MediaUpdated),
|
||||
self.media_tick_subscription().map(|_| Message::MediaTick),
|
||||
self.media.art.subscription().map(Message::MediaArt),
|
||||
self.recorder.subscription().map(Message::RecorderEvent),
|
||||
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
|
||||
self.status_service.subscription().map(Message::StatusUpdated),
|
||||
self.recorder
|
||||
.elapsed_subscription()
|
||||
.map(|_| Message::RecorderElapsed),
|
||||
self.status_service
|
||||
.subscription()
|
||||
.map(Message::StatusUpdated),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -437,10 +479,16 @@ impl cosmic::Application for FereseShell {
|
||||
Message::HubOverview(id) => self.open_hub_overview(id),
|
||||
Message::HubWorkspaceHover(id, workspace) => self.hub_workspace_hover(id, workspace),
|
||||
Message::HubWorkspaceSelect(id, workspace) => self.hub_workspace_select(id, workspace),
|
||||
Message::HubWorkspaceMove(id, window, workspace) => self.hub_workspace_move(id, window, workspace),
|
||||
Message::HubWorkspaceFocus(id, focused, key) => self.hub_workspace_focus(id, focused, key),
|
||||
Message::HubWorkspaceMove(id, window, workspace) => {
|
||||
self.hub_workspace_move(id, window, workspace)
|
||||
}
|
||||
Message::HubWorkspaceFocus(id, focused, key) => {
|
||||
self.hub_workspace_focus(id, focused, key)
|
||||
}
|
||||
Message::HubToastExpired(id, revision) => self.hub_toast_expired(id, revision),
|
||||
Message::HubWorkspaceFeedbackExpired(id, revision) => self.hub_workspace_feedback_expired(id, revision),
|
||||
Message::HubWorkspaceFeedbackExpired(id, revision) => {
|
||||
self.hub_workspace_feedback_expired(id, revision)
|
||||
}
|
||||
Message::HubIgnore => Task::none(),
|
||||
Message::PanelResolved(id, resolution) => {
|
||||
let allocation_changed = self
|
||||
@@ -456,7 +504,8 @@ impl cosmic::Application for FereseShell {
|
||||
let close_menu = self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.anchor.parent == id
|
||||
&& menu.anchor.item.is_some()
|
||||
&& ((menu.kind == status_ui::Menu::Overflow && resolution.overflow.is_empty())
|
||||
&& ((menu.kind == status_ui::Menu::Overflow
|
||||
&& resolution.overflow.is_empty())
|
||||
|| allocation_changed)
|
||||
});
|
||||
if let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) {
|
||||
@@ -487,7 +536,11 @@ impl cosmic::Application for FereseShell {
|
||||
output.bar_regions = regions;
|
||||
set_input_zone(
|
||||
id,
|
||||
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
|
||||
bar::input_region(
|
||||
ferese_panel::PanelSurface::None,
|
||||
output.hidden,
|
||||
&output.bar_regions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -522,16 +575,14 @@ impl cosmic::Application for FereseShell {
|
||||
self.sync_hub_activity()
|
||||
}
|
||||
Message::ActivityActivate(id, activity) => self.activate_activity(id, activity),
|
||||
Message::ActivityNavigation(id, focused, key) => {
|
||||
self.activity_navigation(id, focused, key).unwrap_or_else(Task::none)
|
||||
}
|
||||
Message::ActivityNavigation(id, focused, key) => self
|
||||
.activity_navigation(id, focused, key)
|
||||
.unwrap_or_else(Task::none),
|
||||
Message::MediaUpdated(snapshot) => {
|
||||
self.media.receive(snapshot);
|
||||
self.refresh_media_art(
|
||||
self.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()),
|
||||
);
|
||||
self.refresh_media_art(self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == status_ui::Menu::Media && !menu.motion.closing()
|
||||
}));
|
||||
Task::none()
|
||||
}
|
||||
Message::MediaAction(args) => self.media.command(args),
|
||||
@@ -554,8 +605,13 @@ impl cosmic::Application for FereseShell {
|
||||
};
|
||||
let mut args = self.media.action("seek");
|
||||
args["position_us"] = serde_json::json!(position);
|
||||
args["track_id"] =
|
||||
serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone()));
|
||||
args["track_id"] = serde_json::json!(
|
||||
self.media
|
||||
.snapshot
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|p| p.track_id.clone())
|
||||
);
|
||||
self.media.command(args)
|
||||
}
|
||||
Message::MediaArt(art) => {
|
||||
@@ -577,7 +633,8 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::ThemeMode(mode) => cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let mut connection =
|
||||
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.call("theme-set-mode", serde_json::json!({"mode": mode}))
|
||||
.map(|_| ())
|
||||
@@ -602,7 +659,9 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::NativeSurface(id, result) => {
|
||||
match result {
|
||||
Ok((_connection, surface)) if self.outputs.iter().any(|output| output.bar == id) => {
|
||||
Ok((_connection, surface))
|
||||
if self.outputs.iter().any(|output| output.bar == id) =>
|
||||
{
|
||||
self.attach_effects(id, &surface)
|
||||
}
|
||||
Ok((_connection, surface)) => {
|
||||
@@ -612,9 +671,16 @@ impl cosmic::Application for FereseShell {
|
||||
&& entry.id == id
|
||||
&& entry.effects.is_none()
|
||||
{
|
||||
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||
match EffectsBinding::attach_role(
|
||||
&mut self.effects_context,
|
||||
&surface,
|
||||
None,
|
||||
1.0,
|
||||
) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"),
|
||||
Err(error) => eprintln!(
|
||||
"ferese-shell: notification effects unavailable: {error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,10 +852,14 @@ impl cosmic::Application for FereseShell {
|
||||
Task::none()
|
||||
}
|
||||
Message::OpenDisplays(output) => self.open_display_modal(output.as_deref()),
|
||||
Message::DisplaysLoaded(generation, result) => self.finish_display_inventory(generation, result),
|
||||
Message::DisplaysLoaded(generation, result) => {
|
||||
self.finish_display_inventory(generation, result)
|
||||
}
|
||||
Message::SelectDisplayMode(mode) => self.select_display_mode(mode, true),
|
||||
Message::ApplyDisplayMode => self.apply_display_mode(),
|
||||
Message::DisplayModeCompleted(serial, close, result) => self.finish_display_mode(serial, close, result),
|
||||
Message::DisplayModeCompleted(serial, close, result) => {
|
||||
self.finish_display_mode(serial, close, result)
|
||||
}
|
||||
Message::KeepDisplayMode => self.confirm_display_mode(true),
|
||||
Message::RevertDisplayMode => self.confirm_display_mode(false),
|
||||
Message::ExecutePower => self.execute_system_modal(),
|
||||
@@ -810,7 +880,9 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::CommitNightLightTemperature => {
|
||||
if let Some(value) = self.night_light_temperature.take() {
|
||||
return self.update(Message::Control(status::Action::NightLightTemperature(value)));
|
||||
return self.update(Message::Control(status::Action::NightLightTemperature(
|
||||
value,
|
||||
)));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -820,7 +892,10 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
|
||||
if let status::Action::PowerProfile(profile) = action {
|
||||
self.status_error = self.status_service.send(status::Action::PowerProfile(profile)).err();
|
||||
self.status_error = self
|
||||
.status_service
|
||||
.send(status::Action::PowerProfile(profile))
|
||||
.err();
|
||||
|
||||
if self.status_error.is_none()
|
||||
&& let Some(profiles) = &mut self.status.power_profiles
|
||||
@@ -840,7 +915,10 @@ impl cosmic::Application for FereseShell {
|
||||
return self.sync_hub_activity();
|
||||
}
|
||||
status::Action::Notifications => {
|
||||
return Task::batch([self.close_menu(), self.toggle_notification_history()]);
|
||||
return Task::batch([
|
||||
self.close_menu(),
|
||||
self.toggle_notification_history(),
|
||||
]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -852,7 +930,10 @@ impl cosmic::Application for FereseShell {
|
||||
self.optimistic_status(&action);
|
||||
}
|
||||
|
||||
if matches!(action, status::Action::Notifications | status::Action::Settings) {
|
||||
if matches!(
|
||||
action,
|
||||
status::Action::Notifications | status::Action::Settings
|
||||
) {
|
||||
return self.close_menu();
|
||||
}
|
||||
|
||||
@@ -905,7 +986,9 @@ impl cosmic::Application for FereseShell {
|
||||
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 feedback = self.sync_hub_workspace_feedback(
|
||||
overview_was_active || self.overview_active,
|
||||
);
|
||||
let mut tasks = vec![
|
||||
reload_task,
|
||||
mode,
|
||||
@@ -930,7 +1013,10 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
tasks.push(set_exclusive_zone(
|
||||
entry.bar,
|
||||
entry.runtime.panel(&self.config.panels[0]).exclusive_zone(hidden),
|
||||
entry
|
||||
.runtime
|
||||
.panel(&self.config.panels[0])
|
||||
.exclusive_zone(hidden),
|
||||
));
|
||||
entry.hidden = hidden;
|
||||
if hidden && entry.runtime.hub.is_some() {
|
||||
@@ -944,7 +1030,11 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
tasks.push(set_input_zone(
|
||||
entry.bar,
|
||||
bar::input_region(ferese_panel::PanelSurface::None, hidden, &entry.bar_regions),
|
||||
bar::input_region(
|
||||
ferese_panel::PanelSurface::None,
|
||||
hidden,
|
||||
&entry.bar_regions,
|
||||
),
|
||||
));
|
||||
}
|
||||
for id in suspended_hubs {
|
||||
@@ -971,14 +1061,26 @@ impl cosmic::Application for FereseShell {
|
||||
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),
|
||||
control::ControlCommand::CancelLogout(serial) => self.cancel_logout_modal(serial),
|
||||
control::ControlCommand::Logout(serial, output) => {
|
||||
self.open_system_modal(system_modal::PowerAction::Logout(serial), Some(&output))
|
||||
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)
|
||||
}
|
||||
control::ControlCommand::CancelLogout(serial) => {
|
||||
self.cancel_logout_modal(serial)
|
||||
}
|
||||
control::ControlCommand::Logout(serial, output) => self
|
||||
.open_system_modal(
|
||||
system_modal::PowerAction::Logout(serial),
|
||||
Some(&output),
|
||||
),
|
||||
};
|
||||
reload_task = Task::batch([reload_task, task]);
|
||||
}
|
||||
@@ -995,7 +1097,9 @@ impl cosmic::Application for FereseShell {
|
||||
self.hover_workspace(bar, workspace, target, entered)
|
||||
}
|
||||
Message::WorkspaceTooltipDelay(serial) => self.locate_workspace_tooltip(serial),
|
||||
Message::WorkspaceTooltipBounds(serial, bounds) => self.show_workspace_tooltip(serial, bounds),
|
||||
Message::WorkspaceTooltipBounds(serial, bounds) => {
|
||||
self.show_workspace_tooltip(serial, bounds)
|
||||
}
|
||||
Message::WorkspaceIconsLoaded(result) => self.workspace_icons_loaded(result),
|
||||
Message::ToggleOverview => {
|
||||
self.overview_active = !self.overview_active;
|
||||
@@ -1082,9 +1186,11 @@ impl FereseShell {
|
||||
self.config = config;
|
||||
if composition_changed || geometry_changed {
|
||||
for entry in &mut self.outputs {
|
||||
entry
|
||||
.runtime
|
||||
.reset(&self.config.panels[0], &self.snapshot, entry.name.as_deref());
|
||||
entry.runtime.reset(
|
||||
&self.config.panels[0],
|
||||
&self.snapshot,
|
||||
entry.name.as_deref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for entry in &mut self.outputs {
|
||||
@@ -1101,14 +1207,21 @@ impl FereseShell {
|
||||
// publishes its replacement through PanelResolved.
|
||||
tasks.push(set_input_zone(
|
||||
output.bar,
|
||||
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
|
||||
bar::input_region(
|
||||
ferese_panel::PanelSurface::None,
|
||||
output.hidden,
|
||||
&output.bar_regions,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.guide_load.manual
|
||||
&& !self.config.status.keybinding_guide
|
||||
&& self.system_modal.as_ref().is_some_and(|modal| modal.is_guide())
|
||||
&& self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.is_guide())
|
||||
{
|
||||
tasks.push(self.destroy_system_modal(false));
|
||||
}
|
||||
@@ -1117,11 +1230,20 @@ impl FereseShell {
|
||||
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
||||
}
|
||||
|
||||
for entry in self.outputs.iter().filter(|_| geometry_changed || composition_changed) {
|
||||
for entry in self
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|_| geometry_changed || composition_changed)
|
||||
{
|
||||
let panel = entry.runtime.panel(&self.config.panels[0]);
|
||||
let geometry = panel.resolved_geometry();
|
||||
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
let hub_size = entry.runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
|
||||
let (anchor, _) =
|
||||
surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
let hub_size = entry
|
||||
.runtime
|
||||
.hub
|
||||
.as_ref()
|
||||
.map(|hub| hub.borrow().allocation);
|
||||
tasks.push(set_anchor(
|
||||
entry.bar,
|
||||
if hub_size.is_some() {
|
||||
@@ -1130,9 +1252,10 @@ impl FereseShell {
|
||||
anchor | Anchor::LEFT | Anchor::RIGHT
|
||||
},
|
||||
));
|
||||
let (width, height) = hub_size.map_or((None, Some(geometry.height.round() as u32)), |size| {
|
||||
(Some(size[0]), Some(size[1]))
|
||||
});
|
||||
let (width, height) = hub_size
|
||||
.map_or((None, Some(geometry.height.round() as u32)), |size| {
|
||||
(Some(size[0]), Some(size[1]))
|
||||
});
|
||||
tasks.push(set_size(entry.bar, width, height));
|
||||
tasks.push(
|
||||
cosmic::iced::platform_specific::shell::commands::layer_surface::set_keyboard_interactivity(
|
||||
@@ -1140,14 +1263,19 @@ impl FereseShell {
|
||||
KeyboardInteractivity::None,
|
||||
),
|
||||
);
|
||||
tasks.push(set_exclusive_zone(entry.bar, panel.exclusive_zone(entry.hidden)));
|
||||
tasks.push(set_exclusive_zone(
|
||||
entry.bar,
|
||||
panel.exclusive_zone(entry.hidden),
|
||||
));
|
||||
}
|
||||
|
||||
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
|
||||
for bar in bars {
|
||||
tasks.push(self.update_panel_margin(
|
||||
bar,
|
||||
composition_changed || edge_changed || old_geometry.edge_margin != geometry.edge_margin,
|
||||
composition_changed
|
||||
|| edge_changed
|
||||
|| old_geometry.edge_margin != geometry.edge_margin,
|
||||
));
|
||||
}
|
||||
if geometry_changed || composition_changed {
|
||||
@@ -1185,7 +1313,8 @@ impl FereseShell {
|
||||
return Task::none();
|
||||
}
|
||||
entry.bar_margin_horizontal = margin;
|
||||
let (_, margin) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
|
||||
let (_, margin) =
|
||||
surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin);
|
||||
set_margin(bar, margin.top, margin.right, margin.bottom, margin.left)
|
||||
}
|
||||
}
|
||||
@@ -1216,7 +1345,10 @@ mod tests {
|
||||
FereseShell {
|
||||
core: Core::default(),
|
||||
theme_snapshot: ferese_theme_client::service::fallback(),
|
||||
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
|
||||
notifications: notifications::Center::new(
|
||||
config.notifications.clone(),
|
||||
config.animations,
|
||||
),
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(None),
|
||||
status: Default::default(),
|
||||
@@ -1247,7 +1379,11 @@ mod tests {
|
||||
overview_active: false,
|
||||
clock: String::new(),
|
||||
outputs: vec![OutputSurfaces {
|
||||
runtime: panel_runtime::Runtime::new(&panel::Panel::default(), &ShellSnapshot::default(), None),
|
||||
runtime: panel_runtime::Runtime::new(
|
||||
&panel::Panel::default(),
|
||||
&ShellSnapshot::default(),
|
||||
None,
|
||||
),
|
||||
scale: 1.,
|
||||
output,
|
||||
name: Some("test".into()),
|
||||
@@ -1273,10 +1409,16 @@ mod tests {
|
||||
for gap in [0, 12] {
|
||||
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
|
||||
assert_eq!(anchor, Anchor::TOP);
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (gap, 32, 0, 32));
|
||||
assert_eq!(
|
||||
(margin.top, margin.right, margin.bottom, margin.left),
|
||||
(gap, 32, 0, 32)
|
||||
);
|
||||
let (anchor, margin) = surfaces::panel_placement(Edge::Bottom, gap, 32);
|
||||
assert_eq!(anchor, Anchor::BOTTOM);
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 32, gap, 32));
|
||||
assert_eq!(
|
||||
(margin.top, margin.right, margin.bottom, margin.left),
|
||||
(0, 32, gap, 32)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1369,7 +1511,10 @@ mod tests {
|
||||
assert_eq!(shell.config.theme_mode, snapshot.mode);
|
||||
assert_eq!(shell.config.theme.appearance, snapshot.presented.appearance);
|
||||
assert_eq!(shell.config.theme.material_radius, 23.);
|
||||
assert_eq!(shell.config.font_family.as_deref(), Some("Reload test font"));
|
||||
assert_eq!(
|
||||
shell.config.font_family.as_deref(),
|
||||
Some("Reload test font")
|
||||
);
|
||||
assert_eq!(shell.theme_snapshot, snapshot);
|
||||
}
|
||||
|
||||
@@ -1403,7 +1548,10 @@ mod tests {
|
||||
let edit = field;
|
||||
let mut document = ferese_config::Document::parse("").unwrap();
|
||||
document
|
||||
.set("panels", serde_json::to_value(&shell.config.panels).unwrap())
|
||||
.set(
|
||||
"panels",
|
||||
serde_json::to_value(&shell.config.panels).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
document.set(&format!("panels.0.{field}"), value).unwrap();
|
||||
let source = document.to_string();
|
||||
@@ -1427,12 +1575,21 @@ mod tests {
|
||||
minimum_width: 900.,
|
||||
..Default::default()
|
||||
};
|
||||
drop(shell.update(Message::PanelResolved(shell.outputs[0].bar, replacement.clone())));
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
|
||||
drop(shell.update(Message::PanelResolved(
|
||||
shell.outputs[0].bar,
|
||||
replacement.clone(),
|
||||
)));
|
||||
assert_eq!(
|
||||
shell.outputs[0].panel_resolution.as_ref(),
|
||||
Some(&replacement)
|
||||
);
|
||||
assert!(shell.outputs[0].bar_margin_horizontal < 240);
|
||||
let before = shell.outputs[0].bar_margin_horizontal;
|
||||
drop(shell.reload_config("panel broken {".into()));
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement));
|
||||
assert_eq!(
|
||||
shell.outputs[0].panel_resolution.as_ref(),
|
||||
Some(&replacement)
|
||||
);
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, before);
|
||||
}
|
||||
|
||||
@@ -1507,7 +1664,10 @@ mod tests {
|
||||
"unchanged allocation must preserve the popup"
|
||||
);
|
||||
drop(shell.update(Message::PanelResolved(bar, after.clone())));
|
||||
assert!(shell.menu.is_none(), "stale {kind:?} popup must be destroyed");
|
||||
assert!(
|
||||
shell.menu.is_none(),
|
||||
"stale {kind:?} popup must be destroyed"
|
||||
);
|
||||
assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(after));
|
||||
}
|
||||
}
|
||||
@@ -1569,9 +1729,16 @@ mod tests {
|
||||
text_muted: [255, 255, 255, 255],
|
||||
..theme
|
||||
};
|
||||
let before = ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true);
|
||||
let after =
|
||||
ferese_theme::panel::group_border(color(contrasted.border), contrasted.material_radius.min(16.), true);
|
||||
let before = ferese_theme::panel::group_border(
|
||||
color(theme.border),
|
||||
theme.material_radius.min(16.),
|
||||
true,
|
||||
);
|
||||
let after = ferese_theme::panel::group_border(
|
||||
color(contrasted.border),
|
||||
contrasted.material_radius.min(16.),
|
||||
true,
|
||||
);
|
||||
assert_eq!(before.border, after.border);
|
||||
assert_eq!(before.background, after.background);
|
||||
assert_eq!(before.border.color, color(theme.border));
|
||||
@@ -1593,11 +1760,18 @@ mod tests {
|
||||
#[test]
|
||||
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
|
||||
let theme = ShellTheme::default();
|
||||
assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT);
|
||||
assert_eq!(
|
||||
shell_surface_style(theme).background_color,
|
||||
Color::TRANSPARENT
|
||||
);
|
||||
let background = color(theme.for_bar().bar_background);
|
||||
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
|
||||
assert!(background.a > 0.9);
|
||||
assert!(theme.bar_text_primary[..3].iter().all(|channel| *channel > 220));
|
||||
assert!(
|
||||
theme.bar_text_primary[..3]
|
||||
.iter()
|
||||
.all(|channel| *channel > 220)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1650,7 +1824,10 @@ mod tests {
|
||||
#[test]
|
||||
fn bar_title_tracks_focus_and_the_outputs_active_workspace() {
|
||||
let mut snapshot = snapshot_with_fullscreen_window(7);
|
||||
assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), "Test");
|
||||
assert_eq!(
|
||||
focused_bar_title(&snapshot, snapshot.outputs.first()),
|
||||
"Test"
|
||||
);
|
||||
snapshot.windows[0].focused = false;
|
||||
assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), "");
|
||||
snapshot.windows[0].focused = true;
|
||||
|
||||
@@ -56,7 +56,8 @@ impl Model {
|
||||
|
||||
pub fn tick_subscription(&self, visible: bool, on_battery: bool) -> Subscription<()> {
|
||||
if visible && needs_progress(&self.snapshot) {
|
||||
cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 })).map(|_| ())
|
||||
cosmic::iced::time::every(Duration::from_secs(if on_battery { 3 } else { 1 }))
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
@@ -75,9 +76,18 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn refresh_art(&mut self, visible: bool) {
|
||||
let player = if visible { self.snapshot.selected.as_ref() } else { None };
|
||||
let player = if visible {
|
||||
self.snapshot.selected.as_ref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.art.request(player);
|
||||
if visible && self.artwork.as_ref().is_some_and(|art| !self.art.accepts(art)) {
|
||||
if visible
|
||||
&& self
|
||||
.artwork
|
||||
.as_ref()
|
||||
.is_some_and(|art| !self.art.accepts(art))
|
||||
{
|
||||
self.artwork = None;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +101,9 @@ impl Model {
|
||||
&& pending["owner"] == args["owner"]
|
||||
{
|
||||
pending["delta"] = json!(
|
||||
(pending["delta"].as_f64().unwrap_or(0.0) + args["delta"].as_f64().unwrap_or(0.0)).clamp(-1.0, 1.0)
|
||||
(pending["delta"].as_f64().unwrap_or(0.0)
|
||||
+ args["delta"].as_f64().unwrap_or(0.0))
|
||||
.clamp(-1.0, 1.0)
|
||||
);
|
||||
} else if self.pending.len() < 8 {
|
||||
self.pending.push_back(args);
|
||||
@@ -106,7 +118,8 @@ impl Model {
|
||||
self.error = None;
|
||||
cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut connection = ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let mut connection =
|
||||
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
|
||||
connection.call("media-action", args).map(|_| ())
|
||||
})
|
||||
.await
|
||||
@@ -127,7 +140,9 @@ impl Model {
|
||||
|
||||
fn needs_progress(snapshot: &Snapshot) -> bool {
|
||||
snapshot.selected.as_ref().is_some_and(|player| {
|
||||
player.status == Playback::Playing && player.position_us.is_some() && player.length_us.is_some()
|
||||
player.status == Playback::Playing
|
||||
&& player.position_us.is_some()
|
||||
&& player.length_us.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,14 +156,14 @@ impl FereseShell {
|
||||
let hub_visible = self.outputs.iter().any(|entry| {
|
||||
!entry.hidden
|
||||
&& entry.runtime.hub.as_ref().is_some_and(|hub| {
|
||||
hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
|
||||
hub.borrow().hub.mode()
|
||||
== ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
|
||||
})
|
||||
});
|
||||
let visible = (hub_visible
|
||||
|| self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()))
|
||||
|| self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()
|
||||
}))
|
||||
&& self.outputs.iter().any(|output| !output.hidden);
|
||||
let on_battery = self
|
||||
.status
|
||||
|
||||
@@ -304,11 +304,17 @@ mod tests {
|
||||
None
|
||||
})
|
||||
});
|
||||
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "first");
|
||||
assert_eq!(
|
||||
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
|
||||
"first"
|
||||
);
|
||||
queue.0.lock().unwrap().pending = Some(key("second"));
|
||||
queue.0.lock().unwrap().pending = Some(key("latest"));
|
||||
resume.send(()).unwrap();
|
||||
assert_eq!(starts.recv_timeout(Duration::from_secs(2)).unwrap(), "latest");
|
||||
assert_eq!(
|
||||
starts.recv_timeout(Duration::from_secs(2)).unwrap(),
|
||||
"latest"
|
||||
);
|
||||
queue.0.lock().unwrap().stopped = true;
|
||||
queue.1.notify_one();
|
||||
worker.join().unwrap();
|
||||
|
||||
@@ -8,5 +8,7 @@ pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
|
||||
}
|
||||
|
||||
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
|
||||
crate::ipc_subscription::stream("media-get", "media-watch", |snapshot: &Snapshot| snapshot.revision)
|
||||
crate::ipc_subscription::stream("media-get", "media-watch", |snapshot: &Snapshot| {
|
||||
snapshot.revision
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod frames;
|
||||
pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_driven};
|
||||
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
|
||||
};
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
|
||||
use cosmic::{Element, Theme};
|
||||
|
||||
@@ -59,9 +61,13 @@ impl PopupMotion {
|
||||
value.snap();
|
||||
return value;
|
||||
}
|
||||
let spring = self.settings.spring_config().expect("validated motion settings");
|
||||
let spring = self
|
||||
.settings
|
||||
.spring_config()
|
||||
.expect("validated motion settings");
|
||||
value.advance(
|
||||
now.saturating_duration_since(started).mul_f64(self.settings.speed),
|
||||
now.saturating_duration_since(started)
|
||||
.mul_f64(self.settings.speed),
|
||||
SpringConfig {
|
||||
position_tolerance: 0.00001,
|
||||
velocity_tolerance: 0.00001,
|
||||
@@ -113,7 +119,14 @@ pub(crate) fn button<'a, M: Clone + 'a>(
|
||||
opacity: f32,
|
||||
) -> Element<'a, M> {
|
||||
let paint = move |progress: f32, pressed: bool| {
|
||||
ferese_theme::controls::shell_button(foreground, selected, opacity, render.radius(14.), progress, pressed)
|
||||
ferese_theme::controls::shell_button(
|
||||
foreground,
|
||||
selected,
|
||||
opacity,
|
||||
render.radius(14.),
|
||||
progress,
|
||||
pressed,
|
||||
)
|
||||
};
|
||||
painted_button(render, button, paint)
|
||||
}
|
||||
@@ -127,8 +140,14 @@ pub(crate) fn circular_button<'a, M: Clone + 'a>(
|
||||
diameter: f32,
|
||||
) -> Element<'a, M> {
|
||||
let paint = move |progress: f32, pressed: bool| {
|
||||
let mut style =
|
||||
ferese_theme::controls::shell_button(foreground, false, opacity, diameter / 2.0, progress, pressed);
|
||||
let mut style = ferese_theme::controls::shell_button(
|
||||
foreground,
|
||||
false,
|
||||
opacity,
|
||||
diameter / 2.0,
|
||||
progress,
|
||||
pressed,
|
||||
);
|
||||
style.shape = Some(cosmic::iced::border::Shape::Circular);
|
||||
if let Some(fill) = fill {
|
||||
style.background = Some(
|
||||
@@ -177,7 +196,12 @@ struct HoverState {
|
||||
}
|
||||
|
||||
impl HoverState {
|
||||
fn advance(&mut self, target: f32, now: std::time::Instant, duration: std::time::Duration) -> bool {
|
||||
fn advance(
|
||||
&mut self,
|
||||
target: f32,
|
||||
now: std::time::Instant,
|
||||
duration: std::time::Duration,
|
||||
) -> bool {
|
||||
if self.target != target {
|
||||
self.start = self.current;
|
||||
self.target = target;
|
||||
@@ -187,7 +211,8 @@ impl HoverState {
|
||||
1.0
|
||||
} else {
|
||||
self.started.map_or(1.0, |start| {
|
||||
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32()).min(1.0)
|
||||
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
|
||||
.min(1.0)
|
||||
})
|
||||
};
|
||||
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
|
||||
@@ -212,7 +237,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
@@ -289,10 +316,17 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.progress.set(tree.state.downcast_ref::<HoverState>().current);
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.progress
|
||||
.set(tree.state.downcast_ref::<HoverState>().current);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
@@ -303,9 +337,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
@@ -328,13 +366,22 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
|
||||
pub fn animated<'a, M: 'a>(
|
||||
content: Element<'a, M>,
|
||||
progress: f32,
|
||||
regions: Regions,
|
||||
radius: f32,
|
||||
) -> Element<'a, M> {
|
||||
animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top)
|
||||
}
|
||||
|
||||
@@ -351,7 +398,11 @@ pub fn animated_from_edge<'a, M: 'a>(
|
||||
regions: Some(regions),
|
||||
radius,
|
||||
scale: 1.,
|
||||
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
|
||||
direction: if edge == ferese_panel::Edge::Top {
|
||||
-1.
|
||||
} else {
|
||||
1.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,7 +434,8 @@ impl<M> Motion<'_, M> {
|
||||
fn cursor(&self, layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Cursor {
|
||||
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
|
||||
mouse::Cursor::Available(
|
||||
layout.bounds().center() + (p - layout.bounds().center() - self.translation()) / self.scale,
|
||||
layout.bounds().center()
|
||||
+ (p - layout.bounds().center() - self.translation()) / self.scale,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -396,10 +448,10 @@ 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(layout, 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() {
|
||||
transform_accessible_node(node, offset, layout.bounds().center(), self.scale);
|
||||
@@ -453,9 +505,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
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));
|
||||
self.content.as_widget_mut().operate(
|
||||
tree,
|
||||
layout,
|
||||
renderer,
|
||||
&mut ReuseRegions(&mut collected),
|
||||
);
|
||||
if collected.is_empty()
|
||||
&& let Some(content) = layout.children().next()
|
||||
{
|
||||
@@ -471,9 +526,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
);
|
||||
}
|
||||
let cursor = self.cursor(layout, cursor);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
@@ -517,9 +572,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, self.cursor(layout, cursor), viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
tree,
|
||||
layout,
|
||||
self.cursor(layout, cursor),
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
@@ -529,7 +588,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
renderer: &cosmic::Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
@@ -564,7 +625,13 @@ fn transform_accessible_node(
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_regions(regions: &mut [[f32; 5]], center: cosmic::iced::Point, scale: f32, offset: Vector, radius: f32) {
|
||||
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;
|
||||
@@ -580,7 +647,10 @@ impl widget::Operation for ReuseRegions<'_> {
|
||||
children(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id != Some(&widget::Id::new("ferese-blur-card")) || bounds.width <= 0. || bounds.height <= 0. {
|
||||
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.];
|
||||
@@ -639,21 +709,30 @@ mod tests {
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let plain_a11y =
|
||||
plain
|
||||
.as_widget()
|
||||
.a11y_nodes(Layout::new(&plain_node), &plain_tree, mouse::Cursor::Unavailable);
|
||||
let plain_a11y = plain.as_widget().a11y_nodes(
|
||||
Layout::new(&plain_node),
|
||||
&plain_tree,
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert!(!plain_a11y.root().is_empty());
|
||||
let mut wrapped = animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
|
||||
let mut wrapped =
|
||||
animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
|
||||
let mut tree = widget::Tree::new(&wrapped);
|
||||
let node =
|
||||
wrapped
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||
let offset = if edge == ferese_panel::Edge::Top { -2. } else { 2. };
|
||||
let accessible = wrapped
|
||||
.as_widget()
|
||||
.a11y_nodes(Layout::new(&node), &tree, mouse::Cursor::Unavailable);
|
||||
let node = wrapped.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
let offset = if edge == ferese_panel::Edge::Top {
|
||||
-2.
|
||||
} else {
|
||||
2.
|
||||
};
|
||||
let accessible = wrapped.as_widget().a11y_nodes(
|
||||
Layout::new(&node),
|
||||
&tree,
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(accessible.root().len(), plain_a11y.root().len());
|
||||
for (actual, expected) in accessible
|
||||
.root()
|
||||
@@ -713,7 +792,9 @@ mod tests {
|
||||
Vector::new(40., 80.),
|
||||
)
|
||||
.expect("motion forwards tooltip");
|
||||
let plain_layout = plain_overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||
let plain_layout = plain_overlay
|
||||
.as_overlay_mut()
|
||||
.layout(&renderer, viewport.size());
|
||||
let overlay_layout = overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||
fn tooltip_bounds(node: &layout::Node) -> Rectangle {
|
||||
// Tooltip and row each return an overlay group; descend past
|
||||
@@ -778,7 +859,11 @@ mod tests {
|
||||
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);
|
||||
widget::Operation::container(
|
||||
&mut collector,
|
||||
Some(&widget::Id::new("ferese-blur-card")),
|
||||
card,
|
||||
);
|
||||
transform_regions(
|
||||
&mut regions,
|
||||
cosmic::iced::Point::ORIGIN,
|
||||
@@ -793,7 +878,10 @@ mod tests {
|
||||
fn popup_waits_for_configuration_and_preserves_reversal_velocity() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut motion = PopupMotion::new(Settings::default());
|
||||
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
|
||||
assert_eq!(
|
||||
motion.progress_at(now + std::time::Duration::from_secs(30)),
|
||||
0.0
|
||||
);
|
||||
motion.begin(now);
|
||||
let reverse = now + std::time::Duration::from_millis(90);
|
||||
let before = motion.sample(reverse);
|
||||
@@ -804,7 +892,10 @@ mod tests {
|
||||
assert_eq!(after.current, before.current);
|
||||
assert_eq!(after.velocity, before.velocity);
|
||||
assert!(motion.closing());
|
||||
assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 0.0);
|
||||
assert_eq!(
|
||||
motion.progress_at(reverse + std::time::Duration::from_secs(2)),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -850,7 +941,10 @@ mod tests {
|
||||
let before = motion.progress_at(reopen);
|
||||
motion.retarget(1.0, reopen);
|
||||
assert_eq!(motion.progress_at(reopen), before);
|
||||
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(2)), 1.0);
|
||||
assert_eq!(
|
||||
motion.progress_at(reopen + std::time::Duration::from_secs(2)),
|
||||
1.0
|
||||
);
|
||||
for settings in [
|
||||
Settings {
|
||||
reduced_motion: true,
|
||||
|
||||
@@ -17,7 +17,15 @@ pub(crate) fn geometry_driven<'a, M: 'a>(
|
||||
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||
measurement: Option<ContentMeasurement<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
driven(revision, build, active, after_frame, completion, true, measurement)
|
||||
driven(
|
||||
revision,
|
||||
build,
|
||||
active,
|
||||
after_frame,
|
||||
completion,
|
||||
true,
|
||||
measurement,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn frame_driven<'a, M: 'a>(
|
||||
@@ -27,7 +35,15 @@ pub(crate) fn frame_driven<'a, M: 'a>(
|
||||
after_frame: impl Fn(Instant) + 'a,
|
||||
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||
) -> Element<'a, M> {
|
||||
driven(revision, build, active, after_frame, completion, false, None)
|
||||
driven(
|
||||
revision,
|
||||
build,
|
||||
active,
|
||||
after_frame,
|
||||
completion,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn driven<'a, M: 'a>(
|
||||
@@ -111,11 +127,16 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
std::iter::once(widget::Tree::new(&self.content))
|
||||
.chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content)))
|
||||
.chain(
|
||||
self.measurement
|
||||
.iter()
|
||||
.map(|m| widget::Tree::new(&m.content)),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||
tree.children.truncate(1 + usize::from(self.measurement.is_some()));
|
||||
tree.children
|
||||
.truncate(1 + usize::from(self.measurement.is_some()));
|
||||
tree.children[0].diff(&mut self.content);
|
||||
if let Some(measurement) = &mut self.measurement {
|
||||
if tree.children.len() == 1 {
|
||||
@@ -138,11 +159,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
if !self.measured {
|
||||
if let Some(measurement) = &mut self.measurement {
|
||||
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
|
||||
let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
|
||||
let node = measurement
|
||||
.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[1], renderer, &probe_limits);
|
||||
let probe_limits =
|
||||
layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
|
||||
let node = measurement.content.as_widget_mut().layout(
|
||||
&mut tree.children[1],
|
||||
renderer,
|
||||
&probe_limits,
|
||||
);
|
||||
(measurement.measured)(node.size());
|
||||
let now = Instant::now();
|
||||
self.content = (self.build)(now);
|
||||
@@ -161,7 +184,9 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
@@ -174,7 +199,8 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
||||
let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event
|
||||
{
|
||||
tree.state
|
||||
.downcast_mut::<FrameState>()
|
||||
.begin(*at, self.revision)
|
||||
@@ -234,7 +260,10 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
|
||||
(self.after_frame)(*at);
|
||||
let completion = (self.completion)(*at);
|
||||
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
|
||||
if tree
|
||||
.state
|
||||
.downcast_mut::<FrameState>()
|
||||
.complete(completion.is_none())
|
||||
&& let Some(message) = completion
|
||||
{
|
||||
shell.publish(message);
|
||||
@@ -252,9 +281,15 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
@@ -264,9 +299,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
@@ -287,9 +326,13 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'b, M, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,11 +429,18 @@ mod tests {
|
||||
);
|
||||
assert_eq!(shell.redraw_request(), RedrawRequest::Wait);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content");
|
||||
assert_eq!(
|
||||
samples.borrow().len(),
|
||||
settled_builds,
|
||||
"settled redraw rebuilt content"
|
||||
);
|
||||
let values = samples.borrow();
|
||||
assert!(values.windows(2).all(|pair| pair[1] >= pair[0]));
|
||||
assert_eq!(values.last(), Some(&1.0));
|
||||
assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped");
|
||||
assert!(
|
||||
values.len() > (hz / 4) as usize,
|
||||
"high refresh callbacks were skipped"
|
||||
);
|
||||
}
|
||||
// Keep this mutable: the real render path shares the same renderer.
|
||||
renderer.reset(Rectangle::default());
|
||||
|
||||
@@ -8,7 +8,11 @@ const POPUP_WIDTH: u32 = 368;
|
||||
const HISTORY_MAX_HEIGHT: f32 = 620.0;
|
||||
|
||||
fn popup_height(notice: ¬ifications::Notice, count: usize) -> u32 {
|
||||
let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 };
|
||||
let body = if notice.body.is_empty() && count == 1 {
|
||||
0
|
||||
} else {
|
||||
22
|
||||
};
|
||||
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
|
||||
39
|
||||
} else {
|
||||
@@ -111,7 +115,10 @@ fn icon_control<'a>(
|
||||
);
|
||||
cosmic::widget::tooltip(
|
||||
control,
|
||||
render.text(label).size(11).class(theme::Text::Color(foreground)),
|
||||
render
|
||||
.text(label)
|
||||
.size(11)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
cosmic::widget::tooltip::Position::Left,
|
||||
)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
@@ -155,7 +162,8 @@ impl FereseShell {
|
||||
.map(|menu| self.panel_for_bar(menu.anchor.parent))
|
||||
.unwrap_or(&self.config.panels[0])
|
||||
.resolved_geometry();
|
||||
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
|
||||
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0)
|
||||
.max(1.0)
|
||||
})
|
||||
.min(HISTORY_MAX_HEIGHT)
|
||||
}
|
||||
@@ -224,10 +232,9 @@ impl FereseShell {
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
self.snapshot
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
|
||||
self.snapshot.outputs.iter().any(|output| {
|
||||
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
|
||||
})
|
||||
})
|
||||
.or_else(|| self.outputs.first())
|
||||
.map(|entry| entry.output.clone());
|
||||
@@ -250,7 +257,9 @@ impl FereseShell {
|
||||
.notifications
|
||||
.popup_groups()
|
||||
.iter()
|
||||
.map(|(notice, _, count)| popup_height(notice, *count) + if *count > 1 { 12 } else { 0 })
|
||||
.map(|(notice, _, count)| {
|
||||
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
|
||||
})
|
||||
.sum::<u32>()
|
||||
+ count.saturating_sub(1) as u32 * 8
|
||||
+ 8;
|
||||
@@ -264,7 +273,12 @@ impl FereseShell {
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|entry| entry.output == output)
|
||||
.map(|entry| entry.runtime.panel(&self.config.panels[0]).resolved_geometry())
|
||||
.map(|entry| {
|
||||
entry
|
||||
.runtime
|
||||
.panel(&self.config.panels[0])
|
||||
.resolved_geometry()
|
||||
})
|
||||
.unwrap_or_else(|| self.config.panels[0].resolved_geometry());
|
||||
let top = geometry.height + geometry.edge_margin as f32 + 12.0;
|
||||
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
|
||||
@@ -338,7 +352,8 @@ impl FereseShell {
|
||||
let id = notice.id;
|
||||
let mut bold = self.render.font;
|
||||
bold.weight = cosmic::iced::font::Weight::Bold;
|
||||
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
|
||||
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
|
||||
{
|
||||
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
|
||||
} else if notice.icon.starts_with('/') {
|
||||
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
|
||||
@@ -356,7 +371,8 @@ impl FereseShell {
|
||||
palette.surface_popover
|
||||
}),
|
||||
);
|
||||
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
|
||||
let (badge_fill, badge_text) =
|
||||
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
|
||||
let badge = container(
|
||||
self.render
|
||||
.text(count.to_string())
|
||||
@@ -457,13 +473,18 @@ impl FereseShell {
|
||||
};
|
||||
if !body.is_empty() {
|
||||
content = content.push(
|
||||
container(self.render.text(body).size(13).class(theme::Text::Color(muted)))
|
||||
.height(if history && count == 1 {
|
||||
Length::Shrink
|
||||
} else {
|
||||
Length::Fixed(18.0)
|
||||
})
|
||||
.clip(true),
|
||||
container(
|
||||
self.render
|
||||
.text(body)
|
||||
.size(13)
|
||||
.class(theme::Text::Color(muted)),
|
||||
)
|
||||
.height(if history && count == 1 {
|
||||
Length::Shrink
|
||||
} else {
|
||||
Length::Fixed(18.0)
|
||||
})
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill));
|
||||
@@ -478,13 +499,13 @@ impl FereseShell {
|
||||
let mut actions = row([]);
|
||||
for (index, (key, label)) in available.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
actions =
|
||||
actions.push(container(cosmic::iced::widget::Space::new().width(1).height(38)).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
actions = actions.push(
|
||||
container(cosmic::iced::widget::Space::new().width(1).height(38))
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(divider)),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
})),
|
||||
);
|
||||
}
|
||||
actions = actions.push(card_button(
|
||||
self.render,
|
||||
@@ -505,12 +526,17 @@ impl FereseShell {
|
||||
}
|
||||
face = face
|
||||
.push(
|
||||
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(1)).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
container(
|
||||
cosmic::iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(1),
|
||||
)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
container::Style {
|
||||
background: Some(Background::Color(divider)),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.push(actions);
|
||||
}
|
||||
@@ -544,20 +570,25 @@ impl FereseShell {
|
||||
// bottom edges, after the card's measured height.
|
||||
let back = move |inset: f32| {
|
||||
container(
|
||||
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(6))
|
||||
.width(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
a: 0.045 * opacity,
|
||||
..foreground
|
||||
})),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)].into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
container(
|
||||
cosmic::iced::widget::Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(6),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
a: 0.045 * opacity,
|
||||
..foreground
|
||||
})),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)]
|
||||
.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.padding([0.0, inset])
|
||||
};
|
||||
@@ -575,7 +606,10 @@ impl FereseShell {
|
||||
Message::ToggleNotificationHistory
|
||||
}));
|
||||
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
|
||||
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(id, "default".into())));
|
||||
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
|
||||
id,
|
||||
"default".into(),
|
||||
)));
|
||||
}
|
||||
area.into()
|
||||
}
|
||||
@@ -608,7 +642,10 @@ impl FereseShell {
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn view_notifications_at(&self, now: std::time::Instant) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(super) fn view_notifications_at(
|
||||
&self,
|
||||
now: std::time::Instant,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
if self.notifications.history_open {
|
||||
let palette = self.config.theme;
|
||||
let surface = self.menu.as_ref();
|
||||
@@ -630,7 +667,11 @@ impl FereseShell {
|
||||
} else {
|
||||
format!(
|
||||
"{count} {} · {} {}",
|
||||
if count == 1 { "notification" } else { "notifications" },
|
||||
if count == 1 {
|
||||
"notification"
|
||||
} else {
|
||||
"notifications"
|
||||
},
|
||||
groups.len(),
|
||||
if groups.len() == 1 { "app" } else { "apps" }
|
||||
)
|
||||
@@ -644,7 +685,12 @@ impl FereseShell {
|
||||
.font(bold)
|
||||
.class(theme::Text::Color(primary)),
|
||||
)
|
||||
.push(self.render.text(subtitle).size(11).class(theme::Text::Color(muted)));
|
||||
.push(
|
||||
self.render
|
||||
.text(subtitle)
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
let header = row([])
|
||||
.spacing(10)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
@@ -658,7 +704,11 @@ impl FereseShell {
|
||||
well,
|
||||
28,
|
||||
));
|
||||
let dnd_color = if self.notifications.dnd { accent } else { muted };
|
||||
let dnd_color = if self.notifications.dnd {
|
||||
accent
|
||||
} else {
|
||||
muted
|
||||
};
|
||||
let dnd_icon = if self.notifications.dnd {
|
||||
ferese_theme::icons::NOTIFICATIONS_OFF
|
||||
} else {
|
||||
@@ -713,7 +763,8 @@ impl FereseShell {
|
||||
self.notifications.dnd,
|
||||
1.0,
|
||||
));
|
||||
let controls = ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
|
||||
let controls =
|
||||
ferese_theme::menus::section(dnd.into(), primary, opacity, palette.material_radius);
|
||||
let mut content = column([]).spacing(10).push(header).push(controls);
|
||||
if count == 0 {
|
||||
let empty = column([])
|
||||
@@ -738,7 +789,11 @@ impl FereseShell {
|
||||
.size(11)
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
content = content.push(container(empty).center_x(Length::Fill).center_y(Length::Fill));
|
||||
content = content.push(
|
||||
container(empty)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
);
|
||||
} else {
|
||||
content = content.push(
|
||||
row([])
|
||||
@@ -805,7 +860,12 @@ impl FereseShell {
|
||||
}
|
||||
entries = entries.push(cards);
|
||||
} else {
|
||||
entries = entries.push(self.notification_card(latest, opacity, true, group.len()));
|
||||
entries = entries.push(self.notification_card(
|
||||
latest,
|
||||
opacity,
|
||||
true,
|
||||
group.len(),
|
||||
));
|
||||
}
|
||||
}
|
||||
content = content.push(
|
||||
@@ -825,7 +885,8 @@ impl FereseShell {
|
||||
.max_height(self.notification_history_height_limit())
|
||||
.width(Length::Fill)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))),
|
||||
background: (!compositor_material)
|
||||
.then_some(Background::Color(color(palette.surface_popover))),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
|
||||
@@ -840,7 +901,9 @@ impl FereseShell {
|
||||
motion::animated_from_edge(
|
||||
panel,
|
||||
progress,
|
||||
surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
|
||||
surface
|
||||
.map(|surface| surface.regions.clone())
|
||||
.unwrap_or_default(),
|
||||
palette.material_radius,
|
||||
self.config.panels[0].edge,
|
||||
)
|
||||
@@ -920,7 +983,12 @@ mod tests {
|
||||
mouse::Cursor::Unavailable,
|
||||
&bounds,
|
||||
);
|
||||
let pixels = Headless::screenshot(&mut renderer, Size::new(120, 60), 1.0, Color::TRANSPARENT);
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
Size::new(120, 60),
|
||||
1.0,
|
||||
Color::TRANSPARENT,
|
||||
);
|
||||
let center = node.bounds().center();
|
||||
let alpha = pixels[((center.y as usize * 120 + center.x as usize) * 4) + 3];
|
||||
assert!(
|
||||
@@ -983,9 +1051,11 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let mut tree = Tree::new(card.as_widget());
|
||||
let node =
|
||||
card.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()));
|
||||
let node = card.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, bounds.size()),
|
||||
);
|
||||
renderer.reset(bounds);
|
||||
card.as_widget().draw(
|
||||
&tree,
|
||||
@@ -996,7 +1066,10 @@ mod tests {
|
||||
mouse::Cursor::Available((80.0, 100.0).into()),
|
||||
&bounds,
|
||||
);
|
||||
let size = Size::new((bounds.width * scale) as u32, (bounds.height * scale) as u32);
|
||||
let size = Size::new(
|
||||
(bounds.width * scale) as u32,
|
||||
(bounds.height * scale) as u32,
|
||||
);
|
||||
let pixels = Headless::screenshot(&mut renderer, size, scale, Color::TRANSPARENT);
|
||||
let alpha = |x: u32, y: u32| pixels[((y * size.width + x) * 4 + 3) as usize];
|
||||
assert_eq!(
|
||||
|
||||
@@ -94,7 +94,9 @@ impl Server {
|
||||
let mut registry = self.registry.lock().unwrap();
|
||||
let replacing = registry.live.contains_key(&replaces_id);
|
||||
if !replacing && registry.live.len() >= HISTORY_LIMIT {
|
||||
return Err(zbus::fdo::Error::LimitsExceeded("Too many active notifications".into()));
|
||||
return Err(zbus::fdo::Error::LimitsExceeded(
|
||||
"Too many active notifications".into(),
|
||||
));
|
||||
}
|
||||
let id = if replacing {
|
||||
replaces_id
|
||||
@@ -196,7 +198,10 @@ impl Service {
|
||||
Self::connect(config, None)
|
||||
}
|
||||
|
||||
fn connect(config: &ferese_config::notifications::NotificationConfig, address: Option<String>) -> Self {
|
||||
fn connect(
|
||||
config: &ferese_config::notifications::NotificationConfig,
|
||||
address: Option<String>,
|
||||
) -> Self {
|
||||
let timeout = Arc::new(std::sync::atomic::AtomicU32::new(config.timeout_ms));
|
||||
let worker_timeout = timeout.clone();
|
||||
let (sender, events) = tokio::sync::mpsc::channel(256);
|
||||
@@ -231,20 +236,41 @@ impl Service {
|
||||
registry: registry.clone(),
|
||||
timeout: worker_timeout.clone(),
|
||||
};
|
||||
let _ = server.notify("Ferese", 0, "", &title, &body, vec![], HashMap::new(), -1);
|
||||
let _ = server.notify(
|
||||
"Ferese",
|
||||
0,
|
||||
"",
|
||||
&title,
|
||||
&body,
|
||||
vec![],
|
||||
HashMap::new(),
|
||||
-1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Command::Close(id, revision, reason) => {
|
||||
if !registry.lock().unwrap().close(id, revision, reason) {
|
||||
continue;
|
||||
}
|
||||
connection.emit_signal(None::<&str>, PATH, INTERFACE, "NotificationClosed", &(id, reason))
|
||||
connection.emit_signal(
|
||||
None::<&str>,
|
||||
PATH,
|
||||
INTERFACE,
|
||||
"NotificationClosed",
|
||||
&(id, reason),
|
||||
)
|
||||
}
|
||||
Command::Action(id, revision, action) => {
|
||||
if registry.lock().unwrap().live.get(&id) != Some(&revision) {
|
||||
continue;
|
||||
}
|
||||
connection.emit_signal(None::<&str>, PATH, INTERFACE, "ActionInvoked", &(id, action))
|
||||
connection.emit_signal(
|
||||
None::<&str>,
|
||||
PATH,
|
||||
INTERFACE,
|
||||
"ActionInvoked",
|
||||
&(id, action),
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Err(error) = result {
|
||||
@@ -284,7 +310,10 @@ pub struct Center {
|
||||
}
|
||||
|
||||
impl Center {
|
||||
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
|
||||
pub fn new(
|
||||
config: ferese_config::notifications::NotificationConfig,
|
||||
motion: super::motion::Settings,
|
||||
) -> Self {
|
||||
Self {
|
||||
activity_bridge: false,
|
||||
service: Service::start(&config),
|
||||
@@ -354,7 +383,9 @@ impl Center {
|
||||
}
|
||||
|
||||
pub fn frame_active(&self, now: Instant) -> bool {
|
||||
self.toasts.iter().any(|toast| toast.motion.frame_active(now))
|
||||
self.toasts
|
||||
.iter()
|
||||
.any(|toast| toast.motion.frame_active(now))
|
||||
}
|
||||
|
||||
pub fn has_finished_closes(&self, now: Instant) -> bool {
|
||||
@@ -364,7 +395,10 @@ impl Center {
|
||||
}
|
||||
|
||||
pub fn motion_revision(&self) -> Option<Instant> {
|
||||
self.toasts.iter().filter_map(|toast| toast.motion.revision()).max()
|
||||
self.toasts
|
||||
.iter()
|
||||
.filter_map(|toast| toast.motion.revision())
|
||||
.max()
|
||||
}
|
||||
|
||||
pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> {
|
||||
@@ -374,7 +408,10 @@ impl Center {
|
||||
pub fn popup_groups_at(&self, now: Instant) -> Vec<(&Notice, f32, usize)> {
|
||||
let mut groups: Vec<(&Notice, f32, usize)> = Vec::new();
|
||||
for (notice, opacity) in self.visible_at(now) {
|
||||
if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) {
|
||||
if let Some(group) = groups
|
||||
.iter_mut()
|
||||
.find(|(head, _, _)| head.app == notice.app)
|
||||
{
|
||||
group.2 += 1;
|
||||
} else {
|
||||
groups.push((notice, opacity, 1));
|
||||
@@ -473,7 +510,10 @@ impl Center {
|
||||
}
|
||||
|
||||
pub fn invoke(&mut self, id: u32, action: String) {
|
||||
if let Some(notice) = self.entries.iter().find(|notice| notice.id == id && notice.live)
|
||||
if let Some(notice) = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|notice| notice.id == id && notice.live)
|
||||
&& notice.actions.iter().any(|(key, _)| key == &action)
|
||||
{
|
||||
let resident = notice.resident;
|
||||
@@ -524,7 +564,8 @@ impl Center {
|
||||
}
|
||||
}
|
||||
notice.unread = !self.history_open;
|
||||
let show = !self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
|
||||
let show =
|
||||
!self.activity_bridge && self.config.show_popups && (!self.dnd || notice.critical);
|
||||
if show {
|
||||
self.toasts.push_back(Toast {
|
||||
id: notice.id,
|
||||
@@ -574,7 +615,11 @@ impl Center {
|
||||
fn hidden_deadlines(&self) -> impl Iterator<Item = (u32, Instant)> + '_ {
|
||||
self.entries.iter().filter_map(|notice| {
|
||||
(notice.live && !self.toasts.iter().any(|toast| toast.id == notice.id))
|
||||
.then(|| notice.timeout.map(|timeout| (notice.id, notice.received_at + timeout)))
|
||||
.then(|| {
|
||||
notice
|
||||
.timeout
|
||||
.map(|timeout| (notice.id, notice.received_at + timeout))
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
@@ -589,7 +634,9 @@ impl Center {
|
||||
}
|
||||
|
||||
pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> {
|
||||
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||
fn deadline_stream(
|
||||
deadline: &Instant,
|
||||
) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||
let deadline = *deadline;
|
||||
cosmic::iced::futures::stream::once(async move {
|
||||
tokio::time::sleep(deadline.saturating_duration_since(Instant::now())).await;
|
||||
@@ -646,8 +693,9 @@ impl Center {
|
||||
for id in expired {
|
||||
self.close(id, 1);
|
||||
}
|
||||
self.toasts
|
||||
.retain(|toast| toast.closing.is_none() || (!self.history_open && toast.motion.animating()));
|
||||
self.toasts.retain(|toast| {
|
||||
toast.closing.is_none() || (!self.history_open && toast.motion.animating())
|
||||
});
|
||||
let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect();
|
||||
self.entries
|
||||
.retain(|notice| !notice.transient || notice.live || visible.contains(¬ice.id));
|
||||
@@ -724,7 +772,10 @@ mod tests {
|
||||
assert!(app.activity_available(bar));
|
||||
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(true))));
|
||||
assert!(!app.activity_available(bar));
|
||||
assert!(app.notifications.entries[0].live, "DND must not dismiss the notice");
|
||||
assert!(
|
||||
app.notifications.entries[0].live,
|
||||
"DND must not dismiss the notice"
|
||||
);
|
||||
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(false))));
|
||||
assert!(app.activity_available(bar));
|
||||
}
|
||||
@@ -747,7 +798,9 @@ mod tests {
|
||||
let mut multilingual = notice(1);
|
||||
multilingual.title = "通知🛠️".repeat(100);
|
||||
multilingual.app = "アプリ".repeat(100);
|
||||
drop(app.update(crate::Message::NotificationEvent(Event::Notice(multilingual))));
|
||||
drop(app.update(crate::Message::NotificationEvent(Event::Notice(
|
||||
multilingual,
|
||||
))));
|
||||
assert!(app.activity_available(bar));
|
||||
assert!(app.notifications.toasts.is_empty());
|
||||
app.notifications.entries[0].received_at = Instant::now() - Duration::from_secs(7);
|
||||
@@ -784,7 +837,12 @@ mod tests {
|
||||
fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() {
|
||||
let (mut center, _) = fixture();
|
||||
center.receive(notice(1));
|
||||
center.toasts.front_mut().unwrap().motion.begin(Instant::now());
|
||||
center
|
||||
.toasts
|
||||
.front_mut()
|
||||
.unwrap()
|
||||
.motion
|
||||
.begin(Instant::now());
|
||||
center.history_open = true;
|
||||
center.close(1, 2);
|
||||
center.tick();
|
||||
@@ -916,7 +974,10 @@ mod tests {
|
||||
center.last_tick = Instant::now() - Duration::from_secs(10);
|
||||
center.tick();
|
||||
assert!(!center.entries[0].live);
|
||||
assert!(matches!(commands.try_recv().unwrap(), Command::Close(1, 1, 1)));
|
||||
assert!(matches!(
|
||||
commands.try_recv().unwrap(),
|
||||
Command::Close(1, 1, 1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -963,7 +1024,10 @@ mod tests {
|
||||
center.invoke(1, "invalid".into());
|
||||
assert!(commands.try_recv().is_err());
|
||||
center.invoke(1, "open".into());
|
||||
assert!(matches!(commands.try_recv().unwrap(), Command::Action(1, 1, _)));
|
||||
assert!(matches!(
|
||||
commands.try_recv().unwrap(),
|
||||
Command::Action(1, 1, _)
|
||||
));
|
||||
assert!(center.entries[0].live);
|
||||
}
|
||||
|
||||
@@ -985,7 +1049,13 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
assert!(center.dnd);
|
||||
assert_eq!(center.service.timeout.load(std::sync::atomic::Ordering::Relaxed), 10000);
|
||||
assert_eq!(
|
||||
center
|
||||
.service
|
||||
.timeout
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
10000
|
||||
);
|
||||
center.receive(notice(1));
|
||||
center.configure(NotificationConfig {
|
||||
show_popups: false,
|
||||
@@ -1038,7 +1108,8 @@ mod tests {
|
||||
.build()
|
||||
.unwrap();
|
||||
let proxy = zbus::blocking::Proxy::new(&connection, INTERFACE, PATH, INTERFACE).unwrap();
|
||||
let info: (String, String, String, String) = proxy.call("GetServerInformation", &()).unwrap();
|
||||
let info: (String, String, String, String) =
|
||||
proxy.call("GetServerInformation", &()).unwrap();
|
||||
assert_eq!(info.0, "Ferese");
|
||||
let notify = |replace| -> u32 {
|
||||
proxy
|
||||
@@ -1072,14 +1143,22 @@ mod tests {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let signal = signals.next().unwrap();
|
||||
sender.send(signal.body().deserialize::<(u32, u32)>().unwrap()).unwrap();
|
||||
sender
|
||||
.send(signal.body().deserialize::<(u32, u32)>().unwrap())
|
||||
.unwrap();
|
||||
});
|
||||
proxy.call::<_, _, ()>("CloseNotification", &(id,)).unwrap();
|
||||
let Event::Close(closed, revision) = received(&service) else {
|
||||
panic!("Expected close");
|
||||
};
|
||||
service.commands.send(Command::Close(closed, revision, 3)).unwrap();
|
||||
assert_eq!(receiver.recv_timeout(Duration::from_secs(3)).unwrap(), (id, 3));
|
||||
service
|
||||
.commands
|
||||
.send(Command::Close(closed, revision, 3))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
|
||||
(id, 3)
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(proxy.call::<_, _, ()>("CloseNotification", &(id,)).is_err());
|
||||
}
|
||||
|
||||
+825
-291
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,10 @@ impl FereseShell {
|
||||
.as_ref()
|
||||
.filter(|battery| {
|
||||
battery.percent < self.config.status.low_battery_threshold
|
||||
&& matches!(battery.status.as_str(), "Discharging" | "Pending discharge" | "Empty")
|
||||
&& matches!(
|
||||
battery.status.as_str(),
|
||||
"Discharging" | "Pending discharge" | "Empty"
|
||||
)
|
||||
})
|
||||
.map(|battery| battery.percent)
|
||||
}
|
||||
@@ -53,7 +56,15 @@ impl FereseShell {
|
||||
.and_then(|runtime| runtime.borrow().idle_content_width)
|
||||
.unwrap_or(self.panel_for_bar(id).morph.idle_width);
|
||||
let workspaces = self.hub_workspace_width(id);
|
||||
return (Peek::Date, width + if workspaces > 0. { workspaces + 10. } else { 0. });
|
||||
return (
|
||||
Peek::Date,
|
||||
width
|
||||
+ if workspaces > 0. {
|
||||
workspaces + 10.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
}
|
||||
let player = self.media.snapshot.selected.as_ref().filter(|player| {
|
||||
matches!(
|
||||
@@ -122,7 +133,11 @@ impl FereseShell {
|
||||
.size(13)
|
||||
.class(theme::Text::Color(foreground))
|
||||
.width(width)
|
||||
.align_x(if expanded { Alignment::End } else { Alignment::Start })
|
||||
.align_x(if expanded {
|
||||
Alignment::End
|
||||
} else {
|
||||
Alignment::Start
|
||||
})
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
@@ -136,7 +151,11 @@ impl FereseShell {
|
||||
.align_y(Alignment::Center)
|
||||
.width(width)
|
||||
.push(icons::tinted(
|
||||
if saving { icons::RECORD_SAVING } else { icons::RECORD },
|
||||
if saving {
|
||||
icons::RECORD_SAVING
|
||||
} else {
|
||||
icons::RECORD
|
||||
},
|
||||
18,
|
||||
warning,
|
||||
));
|
||||
@@ -153,8 +172,13 @@ impl FereseShell {
|
||||
// Reserve the same width for every second, regardless of digit advances.
|
||||
let timer_width = elapsed.len().max(5) as f32 * 8.;
|
||||
strip = strip.push(
|
||||
container(self.render.text(elapsed).size(13).class(theme::Text::Color(foreground)))
|
||||
.center_x(timer_width),
|
||||
container(
|
||||
self.render
|
||||
.text(elapsed)
|
||||
.size(13)
|
||||
.class(theme::Text::Color(foreground)),
|
||||
)
|
||||
.center_x(timer_width),
|
||||
);
|
||||
} else {
|
||||
strip = strip.push(
|
||||
@@ -181,7 +205,9 @@ impl FereseShell {
|
||||
self.battery_indicator(self.config.theme, opacity, true),
|
||||
28.,
|
||||
))
|
||||
.name(format!("Low battery, {percent} percent. Open battery controls"))
|
||||
.name(format!(
|
||||
"Low battery, {percent} percent. Open battery controls"
|
||||
))
|
||||
.padding(0)
|
||||
.height(28)
|
||||
.class(theme::Button::Custom {
|
||||
@@ -190,7 +216,10 @@ impl FereseShell {
|
||||
pressed: Box::new(move |_, _| style),
|
||||
disabled: Box::new(move |_| style),
|
||||
})
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(
|
||||
id,
|
||||
status_ui::Menu::Battery,
|
||||
))),
|
||||
);
|
||||
}
|
||||
strip.into()
|
||||
|
||||
@@ -26,7 +26,10 @@ impl FereseShell {
|
||||
.padding([0, 3])
|
||||
.width(WIDTH as f32)
|
||||
.height(28)
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(id, status_ui::Menu::Battery))),
|
||||
.on_press(cosmic::Action::App(Message::HubOpen(
|
||||
id,
|
||||
status_ui::Menu::Battery,
|
||||
))),
|
||||
foreground,
|
||||
active,
|
||||
opacity,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::Message;
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
|
||||
};
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||
use cosmic::{Element, Theme};
|
||||
|
||||
@@ -8,7 +10,11 @@ type Action = cosmic::Action<Message>;
|
||||
// Content keeps its destination layout while the shell springs around it.
|
||||
// Centering against the animated width cancels the shell's movement; anchoring
|
||||
// to the screen edge keeps its vertical position stable too.
|
||||
pub(super) fn view(content: Element<'_, Action>, size: [f64; 2], edge: ferese_panel::Edge) -> Element<'_, Action> {
|
||||
pub(super) fn view(
|
||||
content: Element<'_, Action>,
|
||||
size: [f64; 2],
|
||||
edge: ferese_panel::Edge,
|
||||
) -> Element<'_, Action> {
|
||||
Element::new(ContentFrame {
|
||||
content,
|
||||
target: Size::new(size[0] as f32, size[1] as f32),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget,
|
||||
};
|
||||
use cosmic::iced::{Color, Event, Length, Rectangle, Size, window};
|
||||
use cosmic::{Element, Theme};
|
||||
|
||||
use crate::Message;
|
||||
|
||||
pub(super) fn view<'a>(started: Instant, animate: bool, color: Color) -> Element<'a, cosmic::Action<Message>> {
|
||||
pub(super) fn view<'a>(
|
||||
started: Instant,
|
||||
animate: bool,
|
||||
color: Color,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
Element::new(Equalizer {
|
||||
started,
|
||||
animate,
|
||||
@@ -33,7 +39,12 @@ impl Widget<cosmic::Action<Message>, Theme, cosmic::Renderer> for Equalizer {
|
||||
Size::new(Length::Fixed(13.), Length::Fixed(14.))
|
||||
}
|
||||
|
||||
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
|
||||
fn layout(
|
||||
&mut self,
|
||||
_: &mut widget::Tree,
|
||||
_: &cosmic::Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::Node::new(limits.resolve(13., 14., Size::ZERO))
|
||||
}
|
||||
|
||||
@@ -78,12 +89,20 @@ impl Widget<cosmic::Action<Message>, Theme, cosmic::Renderer> for Equalizer {
|
||||
renderer.with_layer(clip, |renderer| {
|
||||
for index in 0..3 {
|
||||
let phase = (elapsed - index as f32 * 0.2) * std::f32::consts::TAU / 0.9;
|
||||
let fraction = if self.animate { 0.65 + 0.35 * phase.sin() } else { 0.45 };
|
||||
let fraction = if self.animate {
|
||||
0.65 + 0.35 * phase.sin()
|
||||
} else {
|
||||
0.45
|
||||
};
|
||||
let height = bounds.height * fraction;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle::new(
|
||||
(bounds.x + index as f32 * 5., bounds.y + bounds.height - height).into(),
|
||||
(
|
||||
bounds.x + index as f32 * 5.,
|
||||
bounds.y + bounds.height - height,
|
||||
)
|
||||
.into(),
|
||||
(3., height).into(),
|
||||
),
|
||||
border: cosmic::iced::Border {
|
||||
|
||||
@@ -62,7 +62,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
|
||||
&& cursor.is_over(*viewport)
|
||||
&& let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event
|
||||
{
|
||||
shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(self.id, *delta)));
|
||||
shell.publish(cosmic::Action::App(Message::HubWorkspaceScroll(
|
||||
self.id, *delta,
|
||||
)));
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
@@ -76,9 +78,15 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn a11y_nodes(
|
||||
&self,
|
||||
@@ -86,7 +94,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
@@ -96,9 +106,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
|
||||
viewport: &Rectangle,
|
||||
renderer: &cosmic::Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
@@ -119,8 +133,12 @@ impl Widget<Action, Theme, cosmic::Renderer> for WheelArea<'_> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,8 @@ impl Runtime {
|
||||
}
|
||||
Event::Activity { priority, peek } => {
|
||||
let changed = self.priority != priority;
|
||||
let resized = priority.is_some() && current == Mode::Peek && self.peek_width != peek.1;
|
||||
let resized =
|
||||
priority.is_some() && current == Mode::Peek && self.peek_width != peek.1;
|
||||
self.priority = priority;
|
||||
if (!changed && !resized) || current.expanded() {
|
||||
return false;
|
||||
@@ -140,7 +141,8 @@ impl Runtime {
|
||||
(Mode::Toast, None)
|
||||
}
|
||||
Event::ToastExpired(token) => {
|
||||
if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token) {
|
||||
if !matches!(self.transient, Transient::Toast { token: live, .. } if live == token)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.restore_transient();
|
||||
|
||||
@@ -55,11 +55,19 @@ struct FocusedWorkspace {
|
||||
}
|
||||
|
||||
impl cosmic::iced::advanced::widget::Operation<Option<u64>> for FocusedWorkspace {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<u64>>)) {
|
||||
fn traverse(
|
||||
&mut self,
|
||||
operate: &mut dyn FnMut(&mut dyn cosmic::iced::advanced::widget::Operation<Option<u64>>),
|
||||
) {
|
||||
operate(self);
|
||||
}
|
||||
|
||||
fn focusable(&mut self, id: Option<&Id>, _: cosmic::iced::Rectangle, state: &mut dyn operation::Focusable) {
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&Id>,
|
||||
_: cosmic::iced::Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if state.is_focused() {
|
||||
self.focused = self
|
||||
.tiles
|
||||
@@ -77,12 +85,21 @@ impl cosmic::iced::advanced::widget::Operation<Option<u64>> for FocusedWorkspace
|
||||
|
||||
// Fit the actual rectangles together, preserving aspect and relative positions.
|
||||
fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)> {
|
||||
let rectangles: Vec<_> = windows.iter().filter_map(|w| w.geometry.map(|r| (w.id, r))).collect();
|
||||
let rectangles: Vec<_> = windows
|
||||
.iter()
|
||||
.filter_map(|w| w.geometry.map(|r| (w.id, r)))
|
||||
.collect();
|
||||
if rectangles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let x = rectangles.iter().map(|(_, r)| r[0]).fold(f64::INFINITY, f64::min);
|
||||
let y = rectangles.iter().map(|(_, r)| r[1]).fold(f64::INFINITY, f64::min);
|
||||
let x = rectangles
|
||||
.iter()
|
||||
.map(|(_, r)| r[0])
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let y = rectangles
|
||||
.iter()
|
||||
.map(|(_, r)| r[1])
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let right = rectangles
|
||||
.iter()
|
||||
.map(|(_, r)| r[0] + r[2])
|
||||
@@ -111,7 +128,12 @@ fn mini_rects(windows: &[&WindowSnapshot]) -> Vec<(u64, cosmic::iced::Rectangle)
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(crate) fn hub_workspace_move(&mut self, id: window::Id, window: u64, destination: u64) -> Task<Message> {
|
||||
pub(crate) fn hub_workspace_move(
|
||||
&mut self,
|
||||
id: window::Id,
|
||||
window: u64,
|
||||
destination: u64,
|
||||
) -> Task<Message> {
|
||||
let output = self.output_for_bar(id).map(|o| o.id);
|
||||
let source = self
|
||||
.snapshot
|
||||
@@ -119,7 +141,11 @@ impl FereseShell {
|
||||
.iter()
|
||||
.find(|w| w.id == window)
|
||||
.map(|w| w.workspace);
|
||||
let valid = |workspace| self.snapshot.workspaces_for_output(output).any(|w| w.id == workspace);
|
||||
let valid = |workspace| {
|
||||
self.snapshot
|
||||
.workspaces_for_output(output)
|
||||
.any(|w| w.id == workspace)
|
||||
};
|
||||
if source.is_some_and(valid)
|
||||
&& valid(destination)
|
||||
&& let Some(control) = &self.control
|
||||
@@ -129,7 +155,11 @@ impl FereseShell {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn hub_workspace_hover(&mut self, id: window::Id, workspace: Option<u64>) -> Task<Message> {
|
||||
pub(crate) fn hub_workspace_hover(
|
||||
&mut self,
|
||||
id: window::Id,
|
||||
workspace: Option<u64>,
|
||||
) -> Task<Message> {
|
||||
if let Some(runtime) = self
|
||||
.outputs
|
||||
.iter()
|
||||
@@ -169,17 +199,26 @@ impl FereseShell {
|
||||
self.query_hub_workspace_focus(id, Some(key))
|
||||
}
|
||||
|
||||
pub(crate) fn query_hub_workspace_focus(&self, id: window::Id, key: Option<Named>) -> Task<Message> {
|
||||
pub(crate) fn query_hub_workspace_focus(
|
||||
&self,
|
||||
id: window::Id,
|
||||
key: Option<Named>,
|
||||
) -> Task<Message> {
|
||||
let output = self.output_for_bar(id);
|
||||
let tiles = tiles(
|
||||
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
|
||||
self.snapshot
|
||||
.workspaces_for_output(output.map(|o| o.id))
|
||||
.collect(),
|
||||
output.map_or(0, |o| o.active_workspace),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|workspace| (tile_id(id, workspace.id), workspace.id))
|
||||
.collect();
|
||||
cosmic::iced::advanced::widget::operate(FocusedWorkspace { tiles, focused: None })
|
||||
.map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key)))
|
||||
cosmic::iced::advanced::widget::operate(FocusedWorkspace {
|
||||
tiles,
|
||||
focused: None,
|
||||
})
|
||||
.map(move |focused| cosmic::Action::App(Message::HubWorkspaceFocus(id, focused, key)))
|
||||
}
|
||||
|
||||
pub(crate) fn hub_workspace_focus(
|
||||
@@ -199,7 +238,9 @@ impl FereseShell {
|
||||
return Task::none();
|
||||
};
|
||||
let workspaces = tiles(
|
||||
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
|
||||
self.snapshot
|
||||
.workspaces_for_output(Some(output.id))
|
||||
.collect(),
|
||||
output.active_workspace,
|
||||
);
|
||||
let Some(runtime) = self
|
||||
@@ -216,7 +257,10 @@ impl FereseShell {
|
||||
return Task::none();
|
||||
};
|
||||
let selected = focused.unwrap_or(output.active_workspace);
|
||||
let current = workspaces.iter().position(|w| w.id == selected).unwrap_or(0);
|
||||
let current = workspaces
|
||||
.iter()
|
||||
.position(|w| w.id == selected)
|
||||
.unwrap_or(0);
|
||||
let cols = columns((runtime.hub.target_size()[0] - 28.) as f32);
|
||||
let next = match key {
|
||||
Named::ArrowLeft => current.saturating_sub(1),
|
||||
@@ -232,7 +276,10 @@ impl FereseShell {
|
||||
runtime.workspace_hover = None;
|
||||
let rows = workspaces.len().div_ceil(cols);
|
||||
Task::batch([
|
||||
cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(id, workspace.id))),
|
||||
cosmic::iced::advanced::widget::operate(operation::focusable::focus(tile_id(
|
||||
id,
|
||||
workspace.id,
|
||||
))),
|
||||
cosmic::iced::widget::scrollable::snap_to(
|
||||
Id::new(format!("morph-workspace-grid-{id:?}")),
|
||||
cosmic::iced::widget::scrollable::RelativeOffset {
|
||||
@@ -247,11 +294,17 @@ impl FereseShell {
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn hub_workspace_grid(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(crate) fn hub_workspace_grid(
|
||||
&self,
|
||||
id: window::Id,
|
||||
opacity: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let output = self.output_for_bar(id);
|
||||
let active = output.map_or(0, |o| o.active_workspace);
|
||||
let workspaces = tiles(
|
||||
self.snapshot.workspaces_for_output(output.map(|o| o.id)).collect(),
|
||||
self.snapshot
|
||||
.workspaces_for_output(output.map(|o| o.id))
|
||||
.collect(),
|
||||
active,
|
||||
);
|
||||
let runtime = self
|
||||
@@ -274,7 +327,11 @@ impl FereseShell {
|
||||
.find(|w| w.id == hovered)
|
||||
.or_else(|| workspaces.iter().find(|w| w.id == active));
|
||||
let mut label = current.map_or_else(|| "No workspaces".to_owned(), |w| caption(w));
|
||||
if let Some(workspace) = current.filter(|_| runtime.as_ref().is_some_and(|r| r.workspace_hover.is_some())) {
|
||||
if let Some(workspace) = current.filter(|_| {
|
||||
runtime
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.workspace_hover.is_some())
|
||||
}) {
|
||||
let titles = self
|
||||
.snapshot
|
||||
.windows
|
||||
@@ -324,16 +381,26 @@ impl FereseShell {
|
||||
let label = format!(
|
||||
"{}{}{}",
|
||||
caption(workspace),
|
||||
if workspace_id == active { ", active" } else { "" },
|
||||
windows.iter().map(|w| format!(", {}", w.title)).collect::<String>()
|
||||
if workspace_id == active {
|
||||
", active"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
windows
|
||||
.iter()
|
||||
.map(|w| format!(", {}", w.title))
|
||||
.collect::<String>()
|
||||
);
|
||||
let transition = self.workspace_ui.transition(workspace_id, workspace_id == active);
|
||||
let transition = self
|
||||
.workspace_ui
|
||||
.transition(workspace_id, workspace_id == active);
|
||||
line = line.push(motion::geometry_driven(
|
||||
transition.revision(),
|
||||
move |now| {
|
||||
let progress = transition.progress(now).clamp(0., 1.);
|
||||
let alpha = opacity * if empty { 0.6 } else { 1. };
|
||||
let foreground = ferese_theme::mix(palette.muted, palette.accent, progress).scale_alpha(alpha);
|
||||
let foreground = ferese_theme::mix(palette.muted, palette.accent, progress)
|
||||
.scale_alpha(alpha);
|
||||
let fill = cosmic::iced::Color {
|
||||
a: (0.055 + 0.105 * progress) * alpha,
|
||||
..ferese_theme::mix(palette.text, palette.accent, progress)
|
||||
@@ -358,23 +425,30 @@ impl FereseShell {
|
||||
let pulse = if self.render.motion.motion_enabled() {
|
||||
0.65 + 0.35
|
||||
* (now
|
||||
.saturating_duration_since(transition.revision().unwrap_or(now))
|
||||
.saturating_duration_since(
|
||||
transition.revision().unwrap_or(now),
|
||||
)
|
||||
.as_secs_f32()
|
||||
* 4.)
|
||||
.cos()
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
heading = heading.push(container(cosmic::widget::Space::new()).width(4).height(4).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(palette.error.scale_alpha(alpha * pulse).into()),
|
||||
border: cosmic::iced::Border {
|
||||
radius: 2.0.into(),
|
||||
heading = heading.push(
|
||||
container(cosmic::widget::Space::new())
|
||||
.width(4)
|
||||
.height(4)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(
|
||||
palette.error.scale_alpha(alpha * pulse).into(),
|
||||
),
|
||||
border: cosmic::iced::Border {
|
||||
radius: 2.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
})),
|
||||
);
|
||||
}
|
||||
let content = column![
|
||||
container(heading).height(14),
|
||||
@@ -395,19 +469,29 @@ impl FereseShell {
|
||||
..appearance
|
||||
}),
|
||||
hovered: Box::new(move |_, _| button::Style {
|
||||
background: Some(palette.accent.scale_alpha(0.12 * alpha).into()),
|
||||
background: Some(
|
||||
palette.accent.scale_alpha(0.12 * alpha).into(),
|
||||
),
|
||||
..appearance
|
||||
}),
|
||||
pressed: Box::new(move |_, _| appearance),
|
||||
disabled: Box::new(move |_| appearance),
|
||||
})
|
||||
.on_press(cosmic::Action::App(Message::HubWorkspaceSelect(id, workspace_id))),
|
||||
.on_press(cosmic::Action::App(Message::HubWorkspaceSelect(
|
||||
id,
|
||||
workspace_id,
|
||||
))),
|
||||
)
|
||||
.on_enter(cosmic::Action::App(Message::HubWorkspaceHover(id, Some(workspace_id))))
|
||||
.on_enter(cosmic::Action::App(Message::HubWorkspaceHover(
|
||||
id,
|
||||
Some(workspace_id),
|
||||
)))
|
||||
.on_exit(cosmic::Action::App(Message::HubWorkspaceHover(id, None)))
|
||||
.into()
|
||||
},
|
||||
move |now| transition.active(now) || (urgent && self.render.motion.motion_enabled()),
|
||||
move |now| {
|
||||
transition.active(now) || (urgent && self.render.motion.motion_enabled())
|
||||
},
|
||||
|_| {},
|
||||
|_| None,
|
||||
None,
|
||||
@@ -455,7 +539,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn grid_keeps_occupied_active_and_one_empty_destination_without_a_ten_tile_cap() {
|
||||
let mut all: Vec<_> = (1..=14).map(|id| workspace(id, u32::from(id < 12))).collect();
|
||||
let mut all: Vec<_> = (1..=14)
|
||||
.map(|id| workspace(id, u32::from(id < 12)))
|
||||
.collect();
|
||||
all.reverse();
|
||||
let shown = tiles(all.iter().collect(), 13);
|
||||
assert_eq!(
|
||||
@@ -527,8 +613,12 @@ mod tests {
|
||||
Box::new(operation::focusable::focus_next())
|
||||
};
|
||||
loop {
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut tab);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut tab,
|
||||
);
|
||||
match tab.finish() {
|
||||
operation::Outcome::Chain(next) => tab = next,
|
||||
_ => break,
|
||||
|
||||
@@ -11,8 +11,16 @@ pub(super) struct Target {
|
||||
pub windows: Vec<(u64, Rectangle)>,
|
||||
}
|
||||
|
||||
pub(super) fn area(content: Element<'_, Action>, id: window::Id, targets: Vec<Target>) -> Element<'_, Action> {
|
||||
Element::new(DragArea { content, id, targets })
|
||||
pub(super) fn area(
|
||||
content: Element<'_, Action>,
|
||||
id: window::Id,
|
||||
targets: Vec<Target>,
|
||||
) -> Element<'_, Action> {
|
||||
Element::new(DragArea {
|
||||
content,
|
||||
id,
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
struct DragArea<'a> {
|
||||
@@ -73,7 +81,11 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let (Some(target), Some(point)) = (target, point)
|
||||
&& let Some((window, _)) = target.windows.iter().rev().find(|(_, rect)| rect.contains(point))
|
||||
&& let Some((window, _)) = target
|
||||
.windows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, rect)| rect.contains(point))
|
||||
{
|
||||
state.pressed = Some((*window, target.workspace, point));
|
||||
state.moved = false;
|
||||
@@ -97,7 +109,8 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
target.workspace,
|
||||
)));
|
||||
} else if !state.moved && target.workspace == source {
|
||||
shell.publish(Action::App(Message::HubWorkspaceSelect(self.id, source)));
|
||||
shell
|
||||
.publish(Action::App(Message::HubWorkspaceSelect(self.id, source)));
|
||||
}
|
||||
}
|
||||
state.moved = false;
|
||||
@@ -133,9 +146,15 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn a11y_nodes(
|
||||
&self,
|
||||
@@ -143,7 +162,9 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
tree: &widget::Tree,
|
||||
cursor: mouse::Cursor,
|
||||
) -> iced_accessibility::A11yTree {
|
||||
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
|
||||
self.content
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
@@ -156,9 +177,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
if tree.state.downcast_ref::<State>().pressed.is_some() {
|
||||
return mouse::Interaction::Grabbing;
|
||||
}
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
@@ -179,9 +204,13 @@ impl Widget<Action, Theme, cosmic::Renderer> for DragArea<'_> {
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<cosmic::iced::advanced::overlay::Element<'a, Action, Theme, cosmic::Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +260,20 @@ mod tests {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
(15., 25.),
|
||||
),
|
||||
(Event::Mouse(mouse::Event::CursorMoved { position: end.into() }), end),
|
||||
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
|
||||
(Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), end),
|
||||
(
|
||||
Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: end.into(),
|
||||
}),
|
||||
end,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
end,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
end,
|
||||
),
|
||||
] {
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
|
||||
@@ -19,7 +19,12 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Thumbnail {
|
||||
Size::new(Length::Fixed(44.), Length::Fixed(17.))
|
||||
}
|
||||
|
||||
fn layout(&mut self, _: &mut widget::Tree, _: &cosmic::Renderer, limits: &layout::Limits) -> layout::Node {
|
||||
fn layout(
|
||||
&mut self,
|
||||
_: &mut widget::Tree,
|
||||
_: &cosmic::Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::Node::new(limits.resolve(44., 17., Size::ZERO))
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,10 @@ fn select(mut workspaces: Vec<&WorkspaceSnapshot>, active: u64, budget: f32) ->
|
||||
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
|
||||
let count = workspaces.len();
|
||||
if count == 0 || (count <= LIMIT && count as f32 * SLOT + ACTIVE_EXTRA - DOT_INSET <= budget) {
|
||||
return Selection { workspaces, hidden: 0 };
|
||||
return Selection {
|
||||
workspaces,
|
||||
hidden: 0,
|
||||
};
|
||||
}
|
||||
let overflow_width = 6. + (count.to_string().len() + 1) as f32 * 7.;
|
||||
let visible = (((budget - overflow_width - ACTIVE_EXTRA + DOT_INSET) / SLOT)
|
||||
@@ -127,7 +130,9 @@ impl FereseShell {
|
||||
.and_then(|entry| entry.size),
|
||||
);
|
||||
select(
|
||||
self.snapshot.workspaces_for_output(Some(output.id)).collect(),
|
||||
self.snapshot
|
||||
.workspaces_for_output(Some(output.id))
|
||||
.collect(),
|
||||
output.active_workspace,
|
||||
// Keep the compact strip within its existing width budget.
|
||||
(panel.morph.expanded_width.min(available) - 28. - 5. * 32. - 4.).max(0.) as f32,
|
||||
@@ -148,7 +153,9 @@ impl FereseShell {
|
||||
let width = if width > 0. { width + 10. } else { 0. };
|
||||
let mut runtime = runtime.borrow_mut();
|
||||
if runtime.observed_workspace.is_none() {
|
||||
runtime.observed_workspace = self.output_for_bar(entry.bar).map(|output| output.active_workspace);
|
||||
runtime.observed_workspace = self
|
||||
.output_for_bar(entry.bar)
|
||||
.map(|output| output.active_workspace);
|
||||
}
|
||||
if runtime.workspace_width == width {
|
||||
return None;
|
||||
@@ -157,7 +164,11 @@ impl FereseShell {
|
||||
matches!(runtime.hub.mode(), Mode::Idle).then_some(entry.bar)
|
||||
})
|
||||
.collect();
|
||||
Task::batch(changes.into_iter().map(|id| self.transition_hub(id, Event::Resize)))
|
||||
Task::batch(
|
||||
changes
|
||||
.into_iter()
|
||||
.map(|id| self.transition_hub(id, Event::Resize)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn open_hub_overview(&mut self, id: window::Id) -> Task<Message> {
|
||||
@@ -183,11 +194,18 @@ impl FereseShell {
|
||||
.then_some(entry.bar)
|
||||
})
|
||||
.collect();
|
||||
Task::batch(changed.into_iter().map(|id| self.show_hub_workspace_feedback(id)))
|
||||
Task::batch(
|
||||
changed
|
||||
.into_iter()
|
||||
.map(|id| self.show_hub_workspace_feedback(id)),
|
||||
)
|
||||
}
|
||||
|
||||
fn show_hub_workspace_feedback(&mut self, id: window::Id) -> Task<Message> {
|
||||
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. || self.hub_priority().is_some() {
|
||||
if self.bar_hidden(id)
|
||||
|| self.hub_workspace_width(id) == 0.
|
||||
|| self.hub_priority().is_some()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(runtime) = self
|
||||
@@ -218,11 +236,19 @@ impl FereseShell {
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn hub_workspace_feedback_expired(&mut self, id: window::Id, revision: Instant) -> Task<Message> {
|
||||
pub(crate) fn hub_workspace_feedback_expired(
|
||||
&mut self,
|
||||
id: window::Id,
|
||||
revision: Instant,
|
||||
) -> Task<Message> {
|
||||
self.transition_hub(id, Event::WorkspacesExpired(revision))
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_hub_workspaces(&mut self, id: window::Id, delta: ScrollDelta) -> Task<Message> {
|
||||
pub(crate) fn scroll_hub_workspaces(
|
||||
&mut self,
|
||||
id: window::Id,
|
||||
delta: ScrollDelta,
|
||||
) -> Task<Message> {
|
||||
if self.bar_hidden(id) || self.hub_workspace_width(id) == 0. {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -234,14 +260,21 @@ impl FereseShell {
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(direction) = runtime.borrow_mut().workspace_scroll.step(delta, Instant::now()) else {
|
||||
let Some(direction) = runtime
|
||||
.borrow_mut()
|
||||
.workspace_scroll
|
||||
.step(delta, Instant::now())
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(output) = self.output_for_bar(id) else {
|
||||
return Task::none();
|
||||
};
|
||||
// Include the trailing empty workspace so scrolling can create a new working space.
|
||||
let mut workspaces: Vec<_> = self.snapshot.workspaces_for_output(Some(output.id)).collect();
|
||||
let mut workspaces: Vec<_> = self
|
||||
.snapshot
|
||||
.workspaces_for_output(Some(output.id))
|
||||
.collect();
|
||||
workspaces.sort_by_key(|workspace| (workspace.index, workspace.id));
|
||||
if let Some(position) = workspaces
|
||||
.iter()
|
||||
@@ -256,7 +289,11 @@ impl FereseShell {
|
||||
self.dismiss_workspace_tooltip_for_bar(id)
|
||||
}
|
||||
|
||||
pub(crate) fn hub_workspaces(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(crate) fn hub_workspaces(
|
||||
&self,
|
||||
id: window::Id,
|
||||
opacity: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let selection = self.hub_workspace_selection(id);
|
||||
let width = selection.width();
|
||||
let output = self.output_for_bar(id);
|
||||
@@ -297,7 +334,9 @@ impl FereseShell {
|
||||
if urgent {
|
||||
label.push_str(&format!(" · Workspace {} needs attention", workspace.index));
|
||||
}
|
||||
let transition = self.workspace_ui.transition(workspace.id, workspace.id == active);
|
||||
let transition = self
|
||||
.workspace_ui
|
||||
.transition(workspace.id, workspace.id == active);
|
||||
dots = dots.push(motion::geometry_driven(
|
||||
transition.revision(),
|
||||
move |now| {
|
||||
@@ -395,7 +434,12 @@ mod tests {
|
||||
.collect();
|
||||
for active in [1, 8, 20] {
|
||||
let selection = select(workspaces.iter().collect(), active, 200.);
|
||||
assert!(selection.workspaces.iter().any(|workspace| workspace.id == active));
|
||||
assert!(
|
||||
selection
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == active)
|
||||
);
|
||||
assert!(
|
||||
selection
|
||||
.workspaces
|
||||
@@ -406,7 +450,12 @@ mod tests {
|
||||
assert_eq!(selection.hidden, if active == 20 { 16 } else { 15 });
|
||||
let narrow = select(workspaces.iter().collect(), active, 148.);
|
||||
assert!(narrow.width() <= 148.);
|
||||
assert!(narrow.workspaces.iter().any(|workspace| workspace.id == active));
|
||||
assert!(
|
||||
narrow
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|workspace| workspace.id == active)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,11 +465,29 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let pixels = |y| ScrollDelta::Pixels { x: 0., y };
|
||||
assert_eq!(scroll.step(pixels(-20.), start), None);
|
||||
assert_eq!(scroll.step(pixels(-20.), start + Duration::from_millis(10)), Some(1));
|
||||
assert_eq!(scroll.step(pixels(-100.), start + Duration::from_millis(20)), None);
|
||||
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(200)), None);
|
||||
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(500)), None);
|
||||
assert_eq!(scroll.step(pixels(20.), start + Duration::from_millis(510)), Some(-1));
|
||||
assert_eq!(scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)), None);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(-20.), start + Duration::from_millis(10)),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(-100.), start + Duration::from_millis(20)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(20.), start + Duration::from_millis(200)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(20.), start + Duration::from_millis(500)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(20.), start + Duration::from_millis(510)),
|
||||
Some(-1)
|
||||
);
|
||||
assert_eq!(
|
||||
scroll.step(pixels(f32::NAN), start + Duration::from_secs(1)),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cosmic::app::Task;
|
||||
use cosmic::iced::platform_specific::shell::commands::layer_surface::{set_exclusive_zone, set_input_zone, set_size};
|
||||
use cosmic::iced::platform_specific::shell::commands::layer_surface::{
|
||||
set_exclusive_zone, set_input_zone, set_size,
|
||||
};
|
||||
use cosmic::iced::window;
|
||||
use ferese_panel::adaptive::{Adaptive, WindowState};
|
||||
use ferese_panel::{Panel, PanelStyle};
|
||||
@@ -135,10 +137,11 @@ impl FereseShell {
|
||||
let mut tasks = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
for entry in &mut self.outputs {
|
||||
if entry
|
||||
.runtime
|
||||
.observe(&self.config.panels[0], &self.snapshot, entry.name.as_deref())
|
||||
{
|
||||
if entry.runtime.observe(
|
||||
&self.config.panels[0],
|
||||
&self.snapshot,
|
||||
entry.name.as_deref(),
|
||||
) {
|
||||
let panel = entry.runtime.panel(&self.config.panels[0]);
|
||||
let geometry = panel.resolved_geometry();
|
||||
let output = self
|
||||
@@ -150,7 +153,11 @@ impl FereseShell {
|
||||
entry.bar_regions.clear();
|
||||
entry.panel_resolution = None;
|
||||
tasks.push(set_input_zone(entry.bar, Some(Vec::new())));
|
||||
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
|
||||
tasks.push(set_size(
|
||||
entry.bar,
|
||||
None,
|
||||
Some(geometry.height.round() as u32),
|
||||
));
|
||||
tasks.push(set_exclusive_zone(
|
||||
entry.bar,
|
||||
panel.exclusive_zone(output_bar_hidden(&self.snapshot, output)),
|
||||
@@ -171,7 +178,11 @@ impl FereseShell {
|
||||
for bar in changed {
|
||||
tasks.push(self.update_panel_margin(bar, true));
|
||||
tasks.push(self.dismiss_workspace_tooltip_for_bar(bar));
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
|
||||
if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.anchor.parent == bar)
|
||||
{
|
||||
tasks.push(self.destroy_menu());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,9 @@ impl Recorder {
|
||||
match event {
|
||||
Event::Update(update) => match update.state.as_str() {
|
||||
"selecting" if self.control.is_some() => self.state = State::Selecting,
|
||||
"recording" if self.control.is_some() && !matches!(self.state, State::Recording(_)) => {
|
||||
"recording"
|
||||
if self.control.is_some() && !matches!(self.state, State::Recording(_)) =>
|
||||
{
|
||||
self.state = State::Recording(Instant::now());
|
||||
}
|
||||
"saving" => self.state = State::Saving,
|
||||
@@ -168,11 +170,17 @@ impl Recorder {
|
||||
.unwrap_or_else(|| State::Error("Recorder returned no saved file".into()))
|
||||
}
|
||||
"cancelled" => self.state = State::Idle,
|
||||
"error" => self.state = State::Error(update.message.unwrap_or_else(|| "Recording failed".into())),
|
||||
"error" => {
|
||||
self.state =
|
||||
State::Error(update.message.unwrap_or_else(|| "Recording failed".into()))
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
Event::Exited => {
|
||||
if matches!(self.state, State::Selecting | State::Recording(_) | State::Saving) {
|
||||
if matches!(
|
||||
self.state,
|
||||
State::Selecting | State::Recording(_) | State::Saving
|
||||
) {
|
||||
self.state = State::Error("The recorder stopped unexpectedly".into());
|
||||
}
|
||||
self.control.take();
|
||||
@@ -287,7 +295,11 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn session() -> (Recorder, std::process::Child, tokio::sync::mpsc::Sender<Event>) {
|
||||
fn session() -> (
|
||||
Recorder,
|
||||
std::process::Child,
|
||||
tokio::sync::mpsc::Sender<Event>,
|
||||
) {
|
||||
// A real control pipe lets stop() exercise EOF without a recorder,
|
||||
// portal, encoder, or D-Bus service.
|
||||
let mut child = Command::new("cat")
|
||||
@@ -330,7 +342,9 @@ mod tests {
|
||||
recorder.apply(update("saving", None));
|
||||
recorder.apply(update("saved", Some("display.webm".into())));
|
||||
assert!(recorder.apply(Event::Exited));
|
||||
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm")));
|
||||
assert!(
|
||||
matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("display.webm"))
|
||||
);
|
||||
assert!(!recorder.busy());
|
||||
assert_eq!(recorder.subscription().units(), 0);
|
||||
assert!(send.try_send(Event::Exited).is_err());
|
||||
@@ -394,10 +408,14 @@ mod tests {
|
||||
assert!(!recorder.busy());
|
||||
match terminal {
|
||||
"saved" => {
|
||||
assert!(matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm")))
|
||||
assert!(
|
||||
matches!(recorder.state, State::Saved(ref path) if path == &PathBuf::from("video.webm"))
|
||||
)
|
||||
}
|
||||
"cancelled" => assert!(matches!(recorder.state, State::Idle)),
|
||||
_ => assert!(matches!(recorder.state, State::Error(ref message) if message == "Recording failed")),
|
||||
_ => assert!(
|
||||
matches!(recorder.state, State::Error(ref message) if message == "Recording failed")
|
||||
),
|
||||
}
|
||||
assert!(send.try_send(Event::Exited).is_err());
|
||||
}
|
||||
@@ -407,8 +425,14 @@ mod tests {
|
||||
fn missing_path_and_unexpected_exit_remain_errors() {
|
||||
let mut recorder = Recorder::default();
|
||||
recorder.apply(update("saved", None));
|
||||
assert!(matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file"));
|
||||
for state in [State::Selecting, State::Recording(Instant::now()), State::Saving] {
|
||||
assert!(
|
||||
matches!(recorder.state, State::Error(ref message) if message == "Recorder returned no saved file")
|
||||
);
|
||||
for state in [
|
||||
State::Selecting,
|
||||
State::Recording(Instant::now()),
|
||||
State::Saving,
|
||||
] {
|
||||
recorder.state = state;
|
||||
recorder.apply(Event::Exited);
|
||||
assert!(
|
||||
@@ -445,7 +469,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
send.try_send(Event::Exited).unwrap();
|
||||
assert!(matches!(stream.next().now_or_never(), Some(Some(Event::Exited))));
|
||||
assert!(matches!(
|
||||
stream.next().now_or_never(),
|
||||
Some(Some(Event::Exited))
|
||||
));
|
||||
drop(send);
|
||||
assert!(matches!(stream.next().now_or_never(), Some(None)));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ mod tests {
|
||||
command.env("ICED_BACKEND", "tiny-skia,wgpu");
|
||||
apply_app_backend(&mut command, None);
|
||||
assert_eq!(
|
||||
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
|
||||
command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == "ICED_BACKEND")
|
||||
.unwrap()
|
||||
.1,
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -56,7 +60,11 @@ mod tests {
|
||||
let mut command = Command::new("ferese-settings");
|
||||
apply_app_backend(&mut command, Some(OsStr::new(backend)));
|
||||
assert_eq!(
|
||||
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
|
||||
command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == "ICED_BACKEND")
|
||||
.unwrap()
|
||||
.1,
|
||||
Some(OsStr::new(backend))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,15 +159,24 @@ impl Service {
|
||||
let (updates, rx) = tokio::sync::watch::channel(None);
|
||||
// Polling and writes have separate workers: a missing D-Bus service must
|
||||
// never hold up volume/brightness changes. Publish only coherent polls.
|
||||
let shared = Arc::new((Mutex::new((0u64, false, None::<ActionError>, false, 0)), Condvar::new()));
|
||||
let shared = Arc::new((
|
||||
Mutex::new((0u64, false, None::<ActionError>, false, 0)),
|
||||
Condvar::new(),
|
||||
));
|
||||
let polling = shared.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut network = dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone());
|
||||
let mut network =
|
||||
dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone());
|
||||
let mut bluetooth = dbus_cache::Cache::with_wake("org.bluez", polling.clone());
|
||||
let mut battery_cache = dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone());
|
||||
let mut profiles = dbus_cache::Cache::with_wake("org.freedesktop.UPower.PowerProfiles", polling.clone());
|
||||
let mut legacy_profiles = dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone());
|
||||
let mut battery_cache =
|
||||
dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone());
|
||||
let mut profiles = dbus_cache::Cache::with_wake(
|
||||
"org.freedesktop.UPower.PowerProfiles",
|
||||
polling.clone(),
|
||||
);
|
||||
let mut legacy_profiles =
|
||||
dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone());
|
||||
let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone());
|
||||
let mut audio = audio_cache::Cache::new(polling.clone());
|
||||
let mut hardware = hardware::Monitor::start(polling.clone()).ok();
|
||||
@@ -191,7 +200,12 @@ impl Service {
|
||||
.map(Some)
|
||||
});
|
||||
let fallback_profiles = legacy_profiles.read(before, |connection| {
|
||||
read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles").map(Some)
|
||||
read_power_profiles(
|
||||
connection,
|
||||
"net.hadess.PowerProfiles",
|
||||
"/net/hadess/PowerProfiles",
|
||||
)
|
||||
.map(Some)
|
||||
});
|
||||
|
||||
let mut snapshot = Snapshot {
|
||||
@@ -298,10 +312,12 @@ impl Service {
|
||||
pub fn send(&mut self, action: Action) -> Result<(), ActionError> {
|
||||
let generation = self.generation + 1;
|
||||
let error_action = action.clone();
|
||||
self.tx.try_send((generation, action)).map_err(|_| ActionError {
|
||||
action: error_action,
|
||||
message: "Controls are busy; please try again".to_owned(),
|
||||
})?;
|
||||
self.tx
|
||||
.try_send((generation, action))
|
||||
.map_err(|_| ActionError {
|
||||
action: error_action,
|
||||
message: "Controls are busy; please try again".to_owned(),
|
||||
})?;
|
||||
self.generation = generation;
|
||||
|
||||
Ok(())
|
||||
@@ -328,7 +344,8 @@ pub fn available(program: &str) -> bool {
|
||||
return executable(Path::new(program));
|
||||
}
|
||||
|
||||
env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
|
||||
env::var_os("PATH")
|
||||
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
@@ -380,7 +397,10 @@ impl StatusBus {
|
||||
}
|
||||
}
|
||||
|
||||
fn query<T>(&mut self, query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>) -> Option<T> {
|
||||
fn query<T>(
|
||||
&mut self,
|
||||
query: impl FnOnce(&zbus::blocking::Connection) -> zbus::Result<T>,
|
||||
) -> Option<T> {
|
||||
if self.connection.is_none() {
|
||||
let builder = if self.system {
|
||||
zbus::blocking::connection::Builder::system()
|
||||
@@ -388,7 +408,11 @@ impl StatusBus {
|
||||
zbus::blocking::connection::Builder::session()
|
||||
};
|
||||
|
||||
self.connection = builder.ok()?.method_timeout(Duration::from_secs(2)).build().ok();
|
||||
self.connection = builder
|
||||
.ok()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok();
|
||||
}
|
||||
|
||||
match query(self.connection.as_ref()?) {
|
||||
@@ -436,7 +460,9 @@ impl StatusBus {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result<Option<(bool, bool, bool)>> {
|
||||
fn read_capabilities(
|
||||
connection: &zbus::blocking::Connection,
|
||||
) -> zbus::Result<Option<(bool, bool, bool)>> {
|
||||
let can = |method| -> zbus::Result<bool> {
|
||||
let value: String = connection
|
||||
.call_method(
|
||||
@@ -452,7 +478,11 @@ fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result<Op
|
||||
Ok(value == "yes" || value == "challenge")
|
||||
};
|
||||
|
||||
Ok(Some((can("CanPowerOff")?, can("CanReboot")?, can("CanSuspend")?)))
|
||||
Ok(Some((
|
||||
can("CanPowerOff")?,
|
||||
can("CanReboot")?,
|
||||
can("CanSuspend")?,
|
||||
)))
|
||||
}
|
||||
|
||||
fn read_battery(connection: &zbus::blocking::Connection) -> zbus::Result<Option<Battery>> {
|
||||
@@ -500,7 +530,8 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
|
||||
}
|
||||
|
||||
fn audio() -> Option<Audio> {
|
||||
let (volume, muted) = parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
|
||||
let (volume, muted) =
|
||||
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
|
||||
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
|
||||
let output = info
|
||||
.lines()
|
||||
@@ -508,11 +539,17 @@ fn audio() -> Option<Audio> {
|
||||
.map(|s| s.trim_matches('"').to_owned())
|
||||
.unwrap_or_else(|| "Default output".into());
|
||||
|
||||
Some(Audio { volume, muted, output })
|
||||
Some(Audio {
|
||||
volume,
|
||||
muted,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
fn read(path: &Path, name: &str) -> Option<String> {
|
||||
fs::read_to_string(path.join(name)).ok().map(|s| s.trim().to_owned())
|
||||
fs::read_to_string(path.join(name))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
}
|
||||
|
||||
fn battery() -> Option<Battery> {
|
||||
@@ -521,7 +558,10 @@ fn battery() -> Option<Battery> {
|
||||
let mut batteries = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| read(p, "type").as_deref() == Some("Battery") && read(p, "scope").as_deref() != Some("Device"))
|
||||
.filter(|p| {
|
||||
read(p, "type").as_deref() == Some("Battery")
|
||||
&& read(p, "scope").as_deref() != Some("Device")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
batteries.sort();
|
||||
@@ -541,7 +581,13 @@ fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
|
||||
"org.freedesktop.UPower.PowerProfiles",
|
||||
"/org/freedesktop/UPower/PowerProfiles",
|
||||
)
|
||||
.or_else(|_| read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"))
|
||||
.or_else(|_| {
|
||||
read_power_profiles(
|
||||
connection,
|
||||
"net.hadess.PowerProfiles",
|
||||
"/net/hadess/PowerProfiles",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -552,8 +598,10 @@ fn read_power_profiles(
|
||||
) -> zbus::Result<PowerProfiles> {
|
||||
let proxy = zbus::blocking::Proxy::new(connection, destination, path, destination)?;
|
||||
let active = proxy.get_property::<String>("ActiveProfile")?;
|
||||
let profiles =
|
||||
proxy.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>("Profiles")?;
|
||||
let profiles = proxy
|
||||
.get_property::<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>(
|
||||
"Profiles",
|
||||
)?;
|
||||
let names = ["power-saver", "balanced", "performance"];
|
||||
let available = names.map(|name| {
|
||||
profiles.iter().any(|profile| {
|
||||
@@ -616,18 +664,30 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
|
||||
),
|
||||
Action::Mute(on) => run(
|
||||
"wpctl",
|
||||
&["set-mute", "@DEFAULT_AUDIO_SINK@", if *on { "1" } else { "0" }],
|
||||
&[
|
||||
"set-mute",
|
||||
"@DEFAULT_AUDIO_SINK@",
|
||||
if *on { "1" } else { "0" },
|
||||
],
|
||||
),
|
||||
Action::Brightness(value) => run(
|
||||
"brightnessctl",
|
||||
&["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))],
|
||||
&[
|
||||
"--class=backlight",
|
||||
"set",
|
||||
&format!("{}%", value.clamp(&1, &100)),
|
||||
],
|
||||
),
|
||||
Action::NightLight(enabled) => return night_light::Change::Enabled(*enabled).save(),
|
||||
Action::NightLightTemperature(temperature) => {
|
||||
return night_light::Change::Temperature(*temperature).save();
|
||||
}
|
||||
Action::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()),
|
||||
Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => {
|
||||
Action::Dnd(_) | Action::Notifications => {
|
||||
return Err("Native notifications are unavailable".into());
|
||||
}
|
||||
Action::PowerProfile(profile)
|
||||
if matches!(*profile, "power-saver" | "balanced" | "performance") =>
|
||||
{
|
||||
run("powerprofilesctl", &["set", profile])
|
||||
}
|
||||
Action::PowerProfile(_) => return Err("Invalid power profile".to_owned()),
|
||||
@@ -790,7 +850,10 @@ mod tests {
|
||||
assert!(bus.can_power("CanReboot"));
|
||||
assert!(!bus.can_power("CanSuspend"));
|
||||
mode.store(1, Ordering::Relaxed);
|
||||
assert!(!bus.can_power("CanPowerOff"), "capabilities must not be cached");
|
||||
assert!(
|
||||
!bus.can_power("CanPowerOff"),
|
||||
"capabilities must not be cached"
|
||||
);
|
||||
assert!(bus.connection.is_some());
|
||||
assert!(!bus.notification_service_owned());
|
||||
server.request_name("org.erikreider.swaync").unwrap();
|
||||
@@ -821,7 +884,12 @@ mod tests {
|
||||
assert_eq!(brightness_percent(2.0, 3.0), Some(67));
|
||||
assert_eq!(brightness_percent(0.0, 400.0), Some(0));
|
||||
|
||||
for (current, max) in [(1.0, 0.0), (-1.0, 100.0), (f64::NAN, 100.0), (1.0, f64::INFINITY)] {
|
||||
for (current, max) in [
|
||||
(1.0, 0.0),
|
||||
(-1.0, 100.0),
|
||||
(f64::NAN, 100.0),
|
||||
(1.0, f64::INFINITY),
|
||||
] {
|
||||
assert_eq!(brightness_percent(current, max), None);
|
||||
}
|
||||
}
|
||||
@@ -885,7 +953,10 @@ mod tests {
|
||||
panic!("worker result missing");
|
||||
};
|
||||
assert_eq!(update.generation, 3);
|
||||
assert!(matches!(stream.as_mut().poll_next(&mut context), Poll::Ready(None)));
|
||||
assert!(matches!(
|
||||
stream.as_mut().poll_next(&mut context),
|
||||
Poll::Ready(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -906,7 +977,10 @@ mod tests {
|
||||
state.1 = false;
|
||||
shared.1.notify_one();
|
||||
}
|
||||
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap(), Some(8));
|
||||
assert_eq!(
|
||||
receiver.recv_timeout(Duration::from_secs(2)).unwrap(),
|
||||
Some(8)
|
||||
);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ impl Cache {
|
||||
self.read_at(action, Instant::now(), super::audio)
|
||||
}
|
||||
|
||||
fn read_at(&mut self, action: u64, now: Instant, read: impl FnOnce() -> Option<Audio>) -> Option<Audio> {
|
||||
fn read_at(
|
||||
&mut self,
|
||||
action: u64,
|
||||
now: Instant,
|
||||
read: impl FnOnce() -> Option<Audio>,
|
||||
) -> Option<Audio> {
|
||||
let revision = self.signals.revision();
|
||||
// Without a live subscription, keep the old bounded polling fallback.
|
||||
// A missing default sink retries slowly, even if the server is connected.
|
||||
@@ -66,7 +71,10 @@ mod tests {
|
||||
|
||||
fn cache() -> Cache {
|
||||
Cache {
|
||||
signals: Signals::test(Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()))),
|
||||
signals: Signals::test(Arc::new((
|
||||
Mutex::new((0, false, None, false, 0)),
|
||||
Condvar::new(),
|
||||
))),
|
||||
revision: 0,
|
||||
action: u64::MAX,
|
||||
value: None,
|
||||
@@ -89,7 +97,9 @@ mod tests {
|
||||
assert_eq!(cache.read_at(0, now, sample).unwrap().volume, 48);
|
||||
|
||||
for second in 1..=120 {
|
||||
let audio = cache.read_at(0, now + Duration::from_secs(second), || panic!("idle query"));
|
||||
let audio = cache.read_at(0, now + Duration::from_secs(second), || {
|
||||
panic!("idle query")
|
||||
});
|
||||
assert_eq!(audio.unwrap().volume, 48);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +179,10 @@ mod tests {
|
||||
.read_at(0, now + Duration::from_secs(4), || panic!("early retry"))
|
||||
.is_none()
|
||||
);
|
||||
assert!(cache.read_at(0, now + Duration::from_secs(5), sample).is_some());
|
||||
assert!(
|
||||
cache
|
||||
.read_at(0, now + Duration::from_secs(5), sample)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,13 +129,18 @@ struct Bound {
|
||||
_proxy: Box<dyn ProxyT>,
|
||||
}
|
||||
|
||||
fn parameter_events(state: Arc<State>) -> impl Fn(i32, ParamType, u32, u32, Option<&pw::spa::pod::Pod>) {
|
||||
fn parameter_events(
|
||||
state: Arc<State>,
|
||||
) -> impl Fn(i32, ParamType, u32, u32, Option<&pw::spa::pod::Pod>) {
|
||||
let values = RefCell::new(HashMap::<u32, Vec<u8>>::new());
|
||||
move |_, _, index, _, param| {
|
||||
let bytes = param.map_or(&[][..], pw::spa::pod::Pod::as_bytes);
|
||||
let mut values = values.borrow_mut();
|
||||
|
||||
if values.get(&index).is_none_or(|previous| previous.as_slice() != bytes) {
|
||||
if values
|
||||
.get(&index)
|
||||
.is_none_or(|previous| previous.as_slice() != bytes)
|
||||
{
|
||||
values.insert(index, bytes.to_vec());
|
||||
state.changed();
|
||||
}
|
||||
@@ -165,20 +170,28 @@ fn bind(
|
||||
}
|
||||
ObjectType::Device if class == Some("Audio/Device") => {
|
||||
let device: pw::device::Device = registry.bind(object)?;
|
||||
let listener = device.add_listener_local().param(parameter_events(state)).register();
|
||||
let listener = device
|
||||
.add_listener_local()
|
||||
.param(parameter_events(state))
|
||||
.register();
|
||||
device.subscribe_params(&[ParamType::Route]);
|
||||
Bound {
|
||||
_listener: Box::new(listener),
|
||||
_proxy: Box::new(device),
|
||||
}
|
||||
}
|
||||
ObjectType::Metadata if object.props.and_then(|props| props.get("metadata.name")) == Some("default") => {
|
||||
ObjectType::Metadata
|
||||
if object.props.and_then(|props| props.get("metadata.name")) == Some("default") =>
|
||||
{
|
||||
let metadata: pw::metadata::Metadata = registry.bind(object)?;
|
||||
let listener = metadata
|
||||
.add_listener_local()
|
||||
.property(move |subject, key, _, _| {
|
||||
if subject == 0
|
||||
&& matches!(key, None | Some("default.audio.sink" | "default.configured.audio.sink"))
|
||||
&& matches!(
|
||||
key,
|
||||
None | Some("default.audio.sink" | "default.configured.audio.sink")
|
||||
)
|
||||
{
|
||||
state.changed();
|
||||
}
|
||||
@@ -199,7 +212,8 @@ fn bind(
|
||||
fn monitor(state: Arc<State>, stop: &Stop, remote: Option<&str>) -> Result<(), pw::Error> {
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None)?;
|
||||
let properties = remote.map(|remote| pw::properties::properties! { *pw::keys::REMOTE_NAME => remote });
|
||||
let properties =
|
||||
remote.map(|remote| pw::properties::properties! { *pw::keys::REMOTE_NAME => remote });
|
||||
let core = context.connect_rc(properties)?;
|
||||
let registry = core.get_registry_rc()?;
|
||||
let objects = Rc::new(RefCell::new(HashMap::new()));
|
||||
@@ -324,12 +338,19 @@ context.objects = [
|
||||
let mut server = Server(command.spawn().unwrap());
|
||||
wait_for(|| root.path().join("pipewire-0").exists());
|
||||
let wake = Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()));
|
||||
let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into()));
|
||||
let signals = Signals::start_remote(
|
||||
wake,
|
||||
Some(root.path().join("pipewire-0").to_str().unwrap().into()),
|
||||
);
|
||||
wait_for(|| signals.alive());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let revision = signals.revision();
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
assert_eq!(signals.revision(), revision, "idle audio emitted repeated updates");
|
||||
assert_eq!(
|
||||
signals.revision(),
|
||||
revision,
|
||||
"idle audio emitted repeated updates"
|
||||
);
|
||||
let run = |program: &str, args: &[&str]| {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
@@ -337,12 +358,22 @@ context.objects = [
|
||||
.env("XDG_RUNTIME_DIR", root.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output.stdout
|
||||
};
|
||||
run(
|
||||
"pw-metadata",
|
||||
&["-n", "default", "0", "default.audio.sink", r#"{"name":"test-sink"}"#],
|
||||
&[
|
||||
"-n",
|
||||
"default",
|
||||
"0",
|
||||
"default.audio.sink",
|
||||
r#"{"name":"test-sink"}"#,
|
||||
],
|
||||
);
|
||||
wait_for(|| signals.revision() > revision);
|
||||
let graph: serde_json::Value = serde_json::from_slice(&run("pw-dump", &[])).unwrap();
|
||||
@@ -375,7 +406,12 @@ context.objects = [
|
||||
let revision = signals.revision();
|
||||
run(
|
||||
"pw-cli",
|
||||
&["set-param", &id, "Props", "{ channelVolumes = [ 0.125 0.125 ] }"],
|
||||
&[
|
||||
"set-param",
|
||||
&id,
|
||||
"Props",
|
||||
"{ channelVolumes = [ 0.125 0.125 ] }",
|
||||
],
|
||||
);
|
||||
wait_for(|| signals.revision() > revision);
|
||||
let revision = signals.revision();
|
||||
|
||||
@@ -37,7 +37,10 @@ pub(super) fn read(connection: &Connection) -> zbus::Result<Option<Bluetooth>> {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some((path, _)) = adapters.iter().find(|(_, properties)| {
|
||||
properties.get("Address").and_then(|value| <&str>::try_from(value).ok()) == Some(address)
|
||||
properties
|
||||
.get("Address")
|
||||
.and_then(|value| <&str>::try_from(value).ok())
|
||||
== Some(address)
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -93,9 +96,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
for (path, adapter, connected, alias) in [
|
||||
("/org/bluez/hci0/dev_a", "/org/bluez/hci0", true, "Headphones"),
|
||||
("/org/bluez/hci0/dev_b", "/org/bluez/hci0", false, "Keyboard"),
|
||||
("/org/bluez/hci1/dev_a", "/org/bluez/hci1", true, "Other adapter"),
|
||||
(
|
||||
"/org/bluez/hci0/dev_a",
|
||||
"/org/bluez/hci0",
|
||||
true,
|
||||
"Headphones",
|
||||
),
|
||||
(
|
||||
"/org/bluez/hci0/dev_b",
|
||||
"/org/bluez/hci0",
|
||||
false,
|
||||
"Keyboard",
|
||||
),
|
||||
(
|
||||
"/org/bluez/hci1/dev_a",
|
||||
"/org/bluez/hci1",
|
||||
true,
|
||||
"Other adapter",
|
||||
),
|
||||
] {
|
||||
graph.insert(
|
||||
OwnedObjectPath::try_from(path).unwrap(),
|
||||
|
||||
@@ -16,7 +16,11 @@ struct Signals {
|
||||
}
|
||||
|
||||
impl Signals {
|
||||
fn new(connection: &Connection, service: &str, wake: Option<Arc<PollState>>) -> zbus::Result<Self> {
|
||||
fn new(
|
||||
connection: &Connection,
|
||||
service: &str,
|
||||
wake: Option<Arc<PollState>>,
|
||||
) -> zbus::Result<Self> {
|
||||
// Install both rules before the snapshot, including when the service is
|
||||
// currently absent. Owner changes invalidate paths after a restart.
|
||||
let changes = MessageIterator::for_match_rule(
|
||||
@@ -45,7 +49,9 @@ impl Signals {
|
||||
let (stop, stopped) = oneshot::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
// Enter a runtime for zbus stream cleanup, including cancellation.
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
if let Ok(runtime) = runtime {
|
||||
// Dropping the blocking wrappers enters zbus' runtime, so do
|
||||
// this before entering our async runtime.
|
||||
@@ -120,8 +126,16 @@ impl<T: Clone> Cache<T> {
|
||||
(self.action != u64::MAX && (self.signals.is_none() || self.retry)).then_some(self.retry_at)
|
||||
}
|
||||
|
||||
pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result<Option<T>>) -> Option<T> {
|
||||
if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) {
|
||||
pub(super) fn read(
|
||||
&mut self,
|
||||
action: u64,
|
||||
read: fn(&Connection) -> zbus::Result<Option<T>>,
|
||||
) -> Option<T> {
|
||||
if self
|
||||
.signals
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.alive.load(Ordering::Acquire))
|
||||
{
|
||||
self.signals = None;
|
||||
self.bus.connection = None;
|
||||
self.value = None;
|
||||
@@ -135,7 +149,10 @@ impl<T: Clone> Cache<T> {
|
||||
}
|
||||
// If subscription setup fails, read fresh rather than trusting a cache
|
||||
// with no invalidation. Retry setup at the bounded recovery deadline.
|
||||
let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire));
|
||||
let revision = self
|
||||
.signals
|
||||
.as_ref()
|
||||
.map(|s| s.revision.load(Ordering::Acquire));
|
||||
if (revision.is_none() && Instant::now() >= self.retry_at)
|
||||
|| revision.is_some_and(|revision| revision != self.revision)
|
||||
|| action != self.action
|
||||
@@ -211,10 +228,19 @@ mod tests {
|
||||
assert_eq!(cache.read(0, read_counter), Some(1));
|
||||
}
|
||||
assert_eq!(count.load(Ordering::Relaxed), 1);
|
||||
assert!(cache.next_retry().is_none(), "healthy subscriptions must not poll");
|
||||
assert!(
|
||||
cache.next_retry().is_none(),
|
||||
"healthy subscriptions must not poll"
|
||||
);
|
||||
assert_eq!(cache.read(1, read_counter), Some(2));
|
||||
server
|
||||
.emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &())
|
||||
.emit_signal(
|
||||
None::<&str>,
|
||||
"/test",
|
||||
"org.ferese.StatusCounter",
|
||||
"Changed",
|
||||
&(),
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while cache.read(1, read_counter) != Some(3) {
|
||||
@@ -227,7 +253,10 @@ mod tests {
|
||||
assert!(Instant::now() < deadline, "owner loss kept stale data");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(cache.next_retry().is_none(), "absent services wait for owner changes");
|
||||
assert!(
|
||||
cache.next_retry().is_none(),
|
||||
"absent services wait for owner changes"
|
||||
);
|
||||
server.request_name("org.ferese.StatusCounter").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while cache.read(1, read_counter).is_none() {
|
||||
@@ -270,13 +299,24 @@ mod tests {
|
||||
};
|
||||
let before = signals.revision.load(Ordering::Acquire);
|
||||
server
|
||||
.emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &())
|
||||
.emit_signal(
|
||||
None::<&str>,
|
||||
"/test",
|
||||
"org.ferese.StatusTest",
|
||||
"Changed",
|
||||
&(),
|
||||
)
|
||||
.unwrap();
|
||||
let (state, timeout) = wake
|
||||
.1
|
||||
.wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| state.4 == 0)
|
||||
.wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| {
|
||||
state.4 == 0
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!timeout.timed_out(), "service signal must wake the status worker");
|
||||
assert!(
|
||||
!timeout.timed_out(),
|
||||
"service signal must wake the status worker"
|
||||
);
|
||||
assert!(state.4 > 0);
|
||||
drop(state);
|
||||
wait(before);
|
||||
|
||||
@@ -73,12 +73,15 @@ impl Monitor {
|
||||
loop {
|
||||
// SAFETY: both descriptors remain owned by this thread; poll writes within the array.
|
||||
let result = unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) };
|
||||
if result < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
|
||||
if result < 0
|
||||
&& std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if result < 0
|
||||
|| descriptors[1].revents != 0
|
||||
|| descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0
|
||||
|| descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL)
|
||||
!= 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,12 @@ mod tests {
|
||||
impl Wireless {
|
||||
#[zbus(property)]
|
||||
fn active_access_point(&self) -> OwnedObjectPath {
|
||||
OwnedObjectPath::try_from(if self.0.lock().unwrap().active { "/ap" } else { "/" }).unwrap()
|
||||
OwnedObjectPath::try_from(if self.0.lock().unwrap().active {
|
||||
"/ap"
|
||||
} else {
|
||||
"/"
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
struct Ap(Arc<Mutex<State>>);
|
||||
|
||||
@@ -51,19 +51,24 @@ impl Change {
|
||||
pub(super) fn save(self) -> Result<(), String> {
|
||||
let path = ferese_config::config_path().ok_or("No configuration directory")?;
|
||||
let file = ferese_config::file::ConfigFile::open(&path)?;
|
||||
let mut document = ferese_config::Document::parse(file.source()).map_err(|e| e.to_string())?;
|
||||
let mut document =
|
||||
ferese_config::Document::parse(file.source()).map_err(|e| e.to_string())?;
|
||||
let (field, value) = match self {
|
||||
Self::Enabled(value) => ("night_light.enabled", json!(value)),
|
||||
Self::Temperature(value) => ("night_light.temperature", json!(value)),
|
||||
};
|
||||
document.set(field, value).map_err(|e| e.to_string())?;
|
||||
let settings: ferese_config::night_light::Settings =
|
||||
serde_json::from_value(document.value()["night_light"].clone()).map_err(|e| e.to_string())?;
|
||||
serde_json::from_value(document.value()["night_light"].clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
settings.validate()?;
|
||||
file.replace(&document.to_string(), None, |staged| {
|
||||
run(
|
||||
"ferese",
|
||||
&["--check-config", staged.to_str().ok_or("Invalid configuration path")?],
|
||||
&[
|
||||
"--check-config",
|
||||
staged.to_str().ok_or("Invalid configuration path")?,
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
})?;
|
||||
|
||||
@@ -21,8 +21,8 @@ pub(super) fn view<'a>(
|
||||
opacity: p,
|
||||
} = style;
|
||||
if let Some(a) = &shell.status.audio {
|
||||
let mut volume_heading =
|
||||
row![shell.render.text("Volume").size(13).width(Length::Fill)].align_y(Alignment::Center);
|
||||
let mut volume_heading = row![shell.render.text("Volume").size(13).width(Length::Fill)]
|
||||
.align_y(Alignment::Center);
|
||||
if combined {
|
||||
volume_heading = volume_heading.push(shell_switch(
|
||||
shell.render,
|
||||
@@ -35,7 +35,11 @@ pub(super) fn view<'a>(
|
||||
}
|
||||
let audio = column![
|
||||
volume_heading,
|
||||
shell.render.text(&a.output).size(12).class(theme::Text::Color(muted)),
|
||||
shell
|
||||
.render
|
||||
.text(&a.output)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
slider_row(
|
||||
shell.render,
|
||||
audio_icon(a.volume, a.muted),
|
||||
@@ -62,7 +66,9 @@ pub(super) fn heading<'a>(
|
||||
mut heading: super::MenuHeading<'a>,
|
||||
style: MenuStyle,
|
||||
) -> super::MenuHeading<'a> {
|
||||
let MenuStyle { theme, opacity: p, .. } = style;
|
||||
let MenuStyle {
|
||||
theme, opacity: p, ..
|
||||
} = style;
|
||||
if let Some(audio) = &shell.status.audio {
|
||||
heading = heading.push(shell_switch(
|
||||
shell.render,
|
||||
|
||||
@@ -5,7 +5,9 @@ use cosmic::{Element, theme};
|
||||
|
||||
use super::Menu;
|
||||
use crate::status::Snapshot;
|
||||
use crate::{BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording};
|
||||
use crate::{
|
||||
BarMetrics, FereseShell, Message, accented_icon, bar_content, color, motion, recording,
|
||||
};
|
||||
|
||||
impl FereseShell {
|
||||
pub(crate) fn view_media_item(
|
||||
@@ -48,8 +50,9 @@ impl FereseShell {
|
||||
} else {
|
||||
status_icon(kind, &self.status)
|
||||
};
|
||||
let selected =
|
||||
selected || (kind == Menu::Recording && matches!(self.recorder.state, recording::State::Recording(_)));
|
||||
let selected = selected
|
||||
|| (kind == Menu::Recording
|
||||
&& matches!(self.recorder.state, recording::State::Recording(_)));
|
||||
let foreground = color(if selected {
|
||||
theme.accent
|
||||
} else if enabled {
|
||||
@@ -82,20 +85,17 @@ impl FereseShell {
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.count > 0 && !n.dnd)
|
||||
{
|
||||
content = content.push(
|
||||
container(self.render.text(""))
|
||||
.width(4)
|
||||
.height(4)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(foreground)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: self.render.radius(2.0).into(),
|
||||
..Default::default()
|
||||
},
|
||||
content = content.push(container(self.render.text("")).width(4).height(4).class(
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(foreground)),
|
||||
border: Border {
|
||||
shape: BorderShape::Continuous,
|
||||
radius: self.render.radius(2.0).into(),
|
||||
..Default::default()
|
||||
})),
|
||||
);
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
}
|
||||
let content = bar_content(content, metrics.group_item_height);
|
||||
let recording_busy = kind == Menu::Recording && self.recorder.busy();
|
||||
@@ -107,7 +107,10 @@ impl FereseShell {
|
||||
} else {
|
||||
status_label(kind, &self.status)
|
||||
})
|
||||
.padding([0.0, ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0)])
|
||||
.padding([
|
||||
0.0,
|
||||
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
|
||||
])
|
||||
.height(metrics.group_item_height)
|
||||
.on_press_with_rectangle(move |offset, bounds| {
|
||||
if kind == Menu::Recording {
|
||||
@@ -134,7 +137,10 @@ impl FereseShell {
|
||||
fn status_label(kind: Menu, s: &Snapshot) -> String {
|
||||
match kind {
|
||||
Menu::Network => match &s.network {
|
||||
Some(n) if n.enabled => format!("Wi-Fi: {}", n.connection.as_deref().unwrap_or("not connected")),
|
||||
Some(n) if n.enabled => format!(
|
||||
"Wi-Fi: {}",
|
||||
n.connection.as_deref().unwrap_or("not connected")
|
||||
),
|
||||
_ => "Wi-Fi: off".into(),
|
||||
},
|
||||
Menu::Bluetooth => match &s.bluetooth {
|
||||
@@ -204,7 +210,9 @@ pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
}
|
||||
}
|
||||
Menu::Bluetooth => match &s.bluetooth {
|
||||
Some(b) if b.enabled && !b.devices.is_empty() => (ferese_theme::icons::BLUETOOTH_CONNECTED, true),
|
||||
Some(b) if b.enabled && !b.devices.is_empty() => {
|
||||
(ferese_theme::icons::BLUETOOTH_CONNECTED, true)
|
||||
}
|
||||
Some(b) if b.enabled => (ferese_theme::icons::BLUETOOTH_ON, true),
|
||||
_ => (ferese_theme::icons::BLUETOOTH_OFF, false),
|
||||
},
|
||||
@@ -261,26 +269,41 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(status_icon(Menu::Audio, &s), (ferese_theme::icons::VOLUME_MUTE, false));
|
||||
assert_eq!(
|
||||
status_icon(Menu::Audio, &s),
|
||||
(ferese_theme::icons::VOLUME_MUTE, false)
|
||||
);
|
||||
|
||||
s.battery = Some(status::Battery {
|
||||
percent: 2,
|
||||
status: "Charging".into(),
|
||||
});
|
||||
assert_eq!(status_icon(Menu::Battery, &s).0, ferese_theme::icons::BATTERY_CHARGING);
|
||||
assert_eq!(
|
||||
status_icon(Menu::Battery, &s).0,
|
||||
ferese_theme::icons::BATTERY_CHARGING
|
||||
);
|
||||
|
||||
s.notifications = Some(status::Notifications { count: 3, dnd: true });
|
||||
s.notifications = Some(status::Notifications {
|
||||
count: 3,
|
||||
dnd: true,
|
||||
});
|
||||
assert_eq!(
|
||||
status_icon(Menu::Notifications, &s).0,
|
||||
ferese_theme::icons::NOTIFICATIONS_OFF
|
||||
);
|
||||
assert_eq!(status_label(Menu::Notifications, &s), "Notifications: do not disturb");
|
||||
assert_eq!(
|
||||
status_label(Menu::Notifications, &s),
|
||||
"Notifications: do not disturb"
|
||||
);
|
||||
|
||||
s.bluetooth = Some(status::Bluetooth {
|
||||
enabled: false,
|
||||
devices: vec!["Headphones".into()],
|
||||
});
|
||||
assert_eq!(status_icon(Menu::Bluetooth, &s).0, ferese_theme::icons::BLUETOOTH_OFF);
|
||||
assert_eq!(
|
||||
status_icon(Menu::Bluetooth, &s).0,
|
||||
ferese_theme::icons::BLUETOOTH_OFF
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,11 @@ use super::{MenuRows, MenuStyle};
|
||||
use crate::status::Action;
|
||||
use crate::{FereseShell, Message, ShellTheme, bar_icon, color_with_opacity, motion};
|
||||
|
||||
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn view<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
let MenuStyle {
|
||||
theme,
|
||||
primary: _,
|
||||
@@ -37,9 +41,17 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
if let Some(profiles) = &shell.status.power_profiles {
|
||||
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
|
||||
for (index, (label, icon, profile)) in [
|
||||
("Power saver", ferese_theme::icons::POWER_SAVER, "power-saver"),
|
||||
(
|
||||
"Power saver",
|
||||
ferese_theme::icons::POWER_SAVER,
|
||||
"power-saver",
|
||||
),
|
||||
("Balanced", ferese_theme::icons::POWER_BALANCED, "balanced"),
|
||||
("Performance", ferese_theme::icons::POWER_PERFORMANCE, "performance"),
|
||||
(
|
||||
"Performance",
|
||||
ferese_theme::icons::POWER_PERFORMANCE,
|
||||
"performance",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -89,18 +101,33 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
),
|
||||
] {
|
||||
if enabled {
|
||||
power = power.push(power_tile_action(shell.render, label, icon, action, theme, p));
|
||||
power = power.push(power_tile_action(
|
||||
shell.render,
|
||||
label,
|
||||
icon,
|
||||
action,
|
||||
theme,
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if shell.status.reboot || shell.status.poweroff || shell.status.suspend {
|
||||
rows = rows.push(ferese_theme::menus::section_label("Power", shell.render.font, muted));
|
||||
rows = rows.push(ferese_theme::menus::section_label(
|
||||
"Power",
|
||||
shell.render.font,
|
||||
muted,
|
||||
));
|
||||
rows = rows.push(power);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(super) fn brightness<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn brightness<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
let MenuStyle {
|
||||
theme,
|
||||
primary,
|
||||
@@ -135,7 +162,14 @@ fn power_tile<'a>(
|
||||
theme: ShellTheme,
|
||||
opacity: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let foreground = color_with_opacity(if selected { theme.accent } else { theme.text_primary }, opacity);
|
||||
let foreground = color_with_opacity(
|
||||
if selected {
|
||||
theme.accent
|
||||
} else {
|
||||
theme.text_primary
|
||||
},
|
||||
opacity,
|
||||
);
|
||||
let tile = container(
|
||||
column![bar_icon(icon, 19, foreground), render.text(label).size(11)]
|
||||
.spacing(3)
|
||||
@@ -146,7 +180,9 @@ fn power_tile<'a>(
|
||||
let btn = button::custom(tile)
|
||||
.width(Length::FillPortion(1))
|
||||
.padding([8, 2])
|
||||
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(profile))));
|
||||
.on_press(cosmic::Action::App(Message::Control(Action::PowerProfile(
|
||||
profile,
|
||||
))));
|
||||
|
||||
motion::button(render, btn, foreground, selected, opacity)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ fn device_list_height(count: usize) -> Option<f32> {
|
||||
(count > 4).then_some(DEVICE_LIST_HEIGHT)
|
||||
}
|
||||
|
||||
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle, in_hub: bool) -> MenuRows<'a> {
|
||||
pub(super) fn view<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
in_hub: bool,
|
||||
) -> MenuRows<'a> {
|
||||
let MenuStyle { muted, .. } = style;
|
||||
if let Some(b) = &shell.status.bluetooth {
|
||||
rows = rows.push(status_summary(
|
||||
@@ -43,7 +48,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
devices = devices.push(
|
||||
column![
|
||||
shell.render.text(device).size(14),
|
||||
shell.render.text("Connected").size(12).class(theme::Text::Color(muted)),
|
||||
shell
|
||||
.render
|
||||
.text("Connected")
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
]
|
||||
.spacing(3),
|
||||
);
|
||||
|
||||
@@ -35,11 +35,16 @@ pub(super) fn hub<'a>(
|
||||
let navigation = |label: &'static str, message: Message| {
|
||||
crate::motion::button(
|
||||
render,
|
||||
cosmic::widget::button::custom(render.text(label).size(11).class(cosmic::theme::Text::Color(primary)))
|
||||
.name(label)
|
||||
.padding([2, 4])
|
||||
.height(22)
|
||||
.on_press(cosmic::Action::App(message)),
|
||||
cosmic::widget::button::custom(
|
||||
render
|
||||
.text(label)
|
||||
.size(11)
|
||||
.class(cosmic::theme::Text::Color(primary)),
|
||||
)
|
||||
.name(label)
|
||||
.padding([2, 4])
|
||||
.height(22)
|
||||
.on_press(cosmic::Action::App(message)),
|
||||
primary,
|
||||
false,
|
||||
opacity,
|
||||
|
||||
@@ -30,7 +30,11 @@ pub(super) fn status_summary<'a>(
|
||||
.center_y(32)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
a: if enabled { 0.22 * opacity } else { 0.06 * opacity },
|
||||
a: if enabled {
|
||||
0.22 * opacity
|
||||
} else {
|
||||
0.06 * opacity
|
||||
},
|
||||
..badge_color
|
||||
})),
|
||||
border: Border {
|
||||
@@ -58,7 +62,10 @@ pub(super) fn status_summary<'a>(
|
||||
badge,
|
||||
column![
|
||||
render.text(title).size(14),
|
||||
render.text(subtitle).size(12).class(theme::Text::Color(muted)),
|
||||
render
|
||||
.text(subtitle)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(muted)),
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill)
|
||||
@@ -176,13 +183,17 @@ pub(super) fn slider_row(
|
||||
icon_accent: Color,
|
||||
opacity: f32,
|
||||
) -> Element<'static, cosmic::Action<Message>> {
|
||||
let control = slider(if brightness { 1..=100 } else { 0..=100 }, value, move |value| {
|
||||
cosmic::Action::App(Message::Control(if brightness {
|
||||
Action::Brightness(value)
|
||||
} else {
|
||||
Action::Volume(value)
|
||||
}))
|
||||
})
|
||||
let control = slider(
|
||||
if brightness { 1..=100 } else { 0..=100 },
|
||||
value,
|
||||
move |value| {
|
||||
cosmic::Action::App(Message::Control(if brightness {
|
||||
Action::Brightness(value)
|
||||
} else {
|
||||
Action::Volume(value)
|
||||
}))
|
||||
},
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(24)
|
||||
.class(ferese_theme::menus::slider(foreground, opacity));
|
||||
|
||||
@@ -10,7 +10,11 @@ use super::{Menu, OpenMenu, PopoverAnchor};
|
||||
use crate::status::Action;
|
||||
use crate::{FereseShell, Message};
|
||||
|
||||
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_panel::Edge) -> SctkPositioner {
|
||||
fn menu_positioner(
|
||||
rectangle: Rectangle<i32>,
|
||||
height_limit: f32,
|
||||
edge: ferese_panel::Edge,
|
||||
) -> SctkPositioner {
|
||||
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
|
||||
let (anchor, gravity, gap) = match edge {
|
||||
ferese_panel::Edge::Top => (2u32, 6u32, 8),
|
||||
@@ -21,7 +25,9 @@ fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_pa
|
||||
anchor: anchor.try_into().unwrap(),
|
||||
gravity: gravity.try_into().unwrap(),
|
||||
offset: (rectangle.width / 2, gap),
|
||||
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
|
||||
size_limits: Limits::NONE
|
||||
.max_width(368.0)
|
||||
.max_height(720.0_f32.max(height_limit)),
|
||||
constraint_adjustment: 3,
|
||||
..Default::default()
|
||||
}
|
||||
@@ -41,7 +47,10 @@ impl FereseShell {
|
||||
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
|
||||
if (self.snapshot.binding_mode.is_some() && anchor.item.is_some())
|
||||
|| !anchor.belongs_to(&self.config.panels[0])
|
||||
|| !self.outputs.iter().any(|output| output.bar == anchor.parent)
|
||||
|| !self
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|output| output.bar == anchor.parent)
|
||||
|| self.bar_hidden(anchor.parent)
|
||||
|| !kind.available(&self.status)
|
||||
{
|
||||
@@ -60,7 +69,9 @@ impl FereseShell {
|
||||
return close.chain(open);
|
||||
}
|
||||
if self.menu.as_ref().is_some_and(|menu| {
|
||||
menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle
|
||||
menu.kind == kind
|
||||
&& menu.anchor.same_item(&anchor)
|
||||
&& menu.anchor.rectangle == anchor.rectangle
|
||||
}) {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.motion.closing()
|
||||
@@ -94,7 +105,11 @@ impl FereseShell {
|
||||
if !kind.available(&self.status) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) {
|
||||
if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.kind == Menu::Notifications)
|
||||
{
|
||||
self.notifications.history_open = false;
|
||||
self.notifications.hovered = None;
|
||||
}
|
||||
@@ -120,7 +135,9 @@ impl FereseShell {
|
||||
menu.switch_panel(kind, self.config.animations, Instant::now());
|
||||
return Task::batch([
|
||||
notifications,
|
||||
cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner),
|
||||
cosmic::iced::platform_specific::shell::commands::popup::reposition(
|
||||
menu.id, positioner,
|
||||
),
|
||||
]);
|
||||
}
|
||||
let id = window::Id::unique();
|
||||
@@ -166,7 +183,9 @@ impl FereseShell {
|
||||
}
|
||||
Task::batch([
|
||||
tooltip,
|
||||
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
|
||||
cosmic::task::message(cosmic::Action::Surface(
|
||||
cosmic::surface::action::destroy_popup(menu.id),
|
||||
)),
|
||||
self.sync_notification_surface(),
|
||||
])
|
||||
}
|
||||
@@ -302,7 +321,9 @@ mod tests {
|
||||
width: 32,
|
||||
height: 30,
|
||||
};
|
||||
for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] {
|
||||
for (edge, anchor, gravity, gap) in
|
||||
[(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)]
|
||||
{
|
||||
let positioner = menu_positioner(rectangle, 620., edge);
|
||||
assert_eq!(positioner.anchor_rect, rectangle);
|
||||
assert_eq!(positioner.anchor, anchor.try_into().unwrap());
|
||||
@@ -408,7 +429,10 @@ mod tests {
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal);
|
||||
assert_eq!(
|
||||
menu.motion.progress_at(now + Duration::from_millis(60)),
|
||||
normal
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() {
|
||||
@@ -426,7 +450,10 @@ mod tests {
|
||||
};
|
||||
assert!(anchor.belongs_to(&panel));
|
||||
let items = &mut panel.end.groups[0].items;
|
||||
let network = items.iter().position(|item| item.id.0 == "network").unwrap();
|
||||
let network = items
|
||||
.iter()
|
||||
.position(|item| item.id.0 == "network")
|
||||
.unwrap();
|
||||
items[network].visible = false;
|
||||
assert!(!anchor.belongs_to(&panel));
|
||||
panel.end.groups[0].items.remove(network);
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::json;
|
||||
|
||||
use super::{Menu, MenuRows, MenuStyle};
|
||||
use crate::{
|
||||
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color, color_with_opacity, motion,
|
||||
BarMetrics, FereseShell, Message, ShellTheme, accented_icon, bar_content, color,
|
||||
color_with_opacity, motion,
|
||||
};
|
||||
|
||||
const SEEK_THUMB_DIAMETER: f32 = 10.0;
|
||||
@@ -80,30 +81,39 @@ pub(super) fn bar<'a>(
|
||||
let Some(player) = &shell.media.snapshot.selected else {
|
||||
return shell.render.text("").into();
|
||||
};
|
||||
let foreground = color(if selected { palette.accent } else { palette.text_primary });
|
||||
let vertical_padding = ((metrics.group_item_height - f32::from(metrics.icon_size)) * 0.5).clamp(0.0, 2.0);
|
||||
let foreground = color(if selected {
|
||||
palette.accent
|
||||
} else {
|
||||
palette.text_primary
|
||||
});
|
||||
let vertical_padding =
|
||||
((metrics.group_item_height - f32::from(metrics.icon_size)) * 0.5).clamp(0.0, 2.0);
|
||||
let height = metrics.group_item_height - 2.0 * vertical_padding;
|
||||
let side = height.min(24.0);
|
||||
let cover: Element<'_, cosmic::Action<Message>> =
|
||||
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(side)
|
||||
.height(side)
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.border_radius(shell.render.radius(6.0))
|
||||
.shape(BorderShape::Continuous)
|
||||
.into(),
|
||||
None => container(accented_icon(
|
||||
icons::MEDIA,
|
||||
side as u16,
|
||||
foreground,
|
||||
color(palette.accent),
|
||||
))
|
||||
.center_x(side)
|
||||
.center_y(side)
|
||||
let cover: Element<'_, cosmic::Action<Message>> = match shell
|
||||
.media
|
||||
.artwork
|
||||
.as_ref()
|
||||
.and_then(|art| art.handle.as_ref())
|
||||
{
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(side)
|
||||
.height(side)
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.border_radius(shell.render.radius(6.0))
|
||||
.shape(BorderShape::Continuous)
|
||||
.into(),
|
||||
};
|
||||
None => container(accented_icon(
|
||||
icons::MEDIA,
|
||||
side as u16,
|
||||
foreground,
|
||||
color(palette.accent),
|
||||
))
|
||||
.center_x(side)
|
||||
.center_y(side)
|
||||
.width(side)
|
||||
.into(),
|
||||
};
|
||||
let label = shell
|
||||
.render
|
||||
.text(player.label())
|
||||
@@ -120,7 +130,8 @@ pub(super) fn bar<'a>(
|
||||
let preferred_label_width: f32 = if compact { 112. } else { 320. };
|
||||
// Outer padding, the transport button, details padding, and cover spacing
|
||||
// share the allocation with the label.
|
||||
let chrome = 4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
|
||||
let chrome =
|
||||
4. + 2. + f32::from(metrics.icon_size) + 12. + 8. + if compact { 0. } else { side + 8. };
|
||||
let label_width = width.map_or(preferred_label_width, |width| {
|
||||
(width - chrome).max(0.).min(preferred_label_width)
|
||||
});
|
||||
@@ -150,7 +161,11 @@ pub(super) fn bar<'a>(
|
||||
let playing = player.status == Playback::Playing;
|
||||
let mut toggle = button::custom(bar_content(
|
||||
accented_icon(
|
||||
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
|
||||
if playing {
|
||||
icons::MEDIA_PAUSE
|
||||
} else {
|
||||
icons::MEDIA_PLAY
|
||||
},
|
||||
metrics.icon_size,
|
||||
foreground,
|
||||
color(palette.accent),
|
||||
@@ -186,7 +201,9 @@ pub(super) fn bar<'a>(
|
||||
|
||||
let mut area = mouse_area(controls);
|
||||
if player.can_raise {
|
||||
area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(shell.media.action("raise"))));
|
||||
area = area.on_middle_release(cosmic::Action::App(Message::MediaAction(
|
||||
shell.media.action("raise"),
|
||||
)));
|
||||
}
|
||||
if player.can_control && player.volume.is_some() {
|
||||
let action = shell.media.action("volume");
|
||||
@@ -205,7 +222,11 @@ fn volume_delta(delta: mouse::ScrollDelta) -> f64 {
|
||||
mouse::ScrollDelta::Lines { y, .. } => f64::from(y) * 0.05,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => f64::from(y) * 0.001,
|
||||
};
|
||||
if delta.is_finite() { delta.clamp(-1.0, 1.0) } else { 0.0 }
|
||||
if delta.is_finite() {
|
||||
delta.clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn transport<'a>(
|
||||
@@ -228,7 +249,8 @@ fn transport_sized<'a>(
|
||||
style: MenuStyle,
|
||||
size: f32,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
let fill = (action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
|
||||
let fill =
|
||||
(action == "play-pause").then_some(if enabled { style.primary } else { style.muted });
|
||||
let foreground = match fill {
|
||||
Some(fill) => cosmic::iced::Color {
|
||||
a: fill.a,
|
||||
@@ -247,7 +269,10 @@ fn transport_sized<'a>(
|
||||
accented_icon(
|
||||
source,
|
||||
(size - 20.) as u16,
|
||||
cosmic::iced::Color { a: 1., ..foreground },
|
||||
cosmic::iced::Color {
|
||||
a: 1.,
|
||||
..foreground
|
||||
},
|
||||
color(style.theme.accent),
|
||||
)
|
||||
.opacity(foreground.a),
|
||||
@@ -260,13 +285,19 @@ fn transport_sized<'a>(
|
||||
.height(size)
|
||||
.name(label);
|
||||
if enabled {
|
||||
control = control.on_press(cosmic::Action::App(Message::MediaAction(shell.media.action(action))));
|
||||
control = control.on_press(cosmic::Action::App(Message::MediaAction(
|
||||
shell.media.action(action),
|
||||
)));
|
||||
}
|
||||
|
||||
motion::circular_button(shell.render, control, foreground, fill, style.opacity, size)
|
||||
}
|
||||
|
||||
pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn hub<'a>(
|
||||
shell: &'a FereseShell,
|
||||
rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
let Some(player) = &shell.media.snapshot.selected else {
|
||||
return rows.push(
|
||||
shell
|
||||
@@ -276,31 +307,35 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
|
||||
.class(theme::Text::Color(style.muted)),
|
||||
);
|
||||
};
|
||||
let cover: Element<'_, cosmic::Action<Message>> =
|
||||
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(64)
|
||||
.height(64)
|
||||
.opacity(style.opacity)
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.border_radius(10.)
|
||||
.into(),
|
||||
None => container(
|
||||
accented_icon(
|
||||
icons::MEDIA,
|
||||
26,
|
||||
color(style.theme.text_primary),
|
||||
color(style.theme.accent),
|
||||
)
|
||||
.opacity(style.opacity),
|
||||
)
|
||||
.center_x(64)
|
||||
.center_y(64)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.)
|
||||
}))
|
||||
let cover: Element<'_, cosmic::Action<Message>> = match shell
|
||||
.media
|
||||
.artwork
|
||||
.as_ref()
|
||||
.and_then(|art| art.handle.as_ref())
|
||||
{
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(64)
|
||||
.height(64)
|
||||
.opacity(style.opacity)
|
||||
.content_fit(cosmic::iced::ContentFit::Cover)
|
||||
.border_radius(10.)
|
||||
.into(),
|
||||
};
|
||||
None => container(
|
||||
accented_icon(
|
||||
icons::MEDIA,
|
||||
26,
|
||||
color(style.theme.text_primary),
|
||||
color(style.theme.accent),
|
||||
)
|
||||
.opacity(style.opacity),
|
||||
)
|
||||
.center_x(64)
|
||||
.center_y(64)
|
||||
.class(theme::Container::custom(move |_| {
|
||||
ferese_theme::controls::surface_appearance(style.primary.scale_alpha(0.06), 10.)
|
||||
}))
|
||||
.into(),
|
||||
};
|
||||
let mut info = column![
|
||||
shell
|
||||
.render
|
||||
@@ -327,8 +362,9 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
|
||||
player.length_us.filter(|length| *length > 0),
|
||||
player.position_at(shell.media.now_us),
|
||||
) {
|
||||
let value =
|
||||
(shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64 * 10_000.).round() as u32;
|
||||
let value = (shell.media.seeking.unwrap_or(position).min(length) as f64 / length as f64
|
||||
* 10_000.)
|
||||
.round() as u32;
|
||||
if player.can_control && player.can_seek && player.track_id.is_some() {
|
||||
info = info.push(
|
||||
slider(0..=10_000_u32, value, move |value| {
|
||||
@@ -358,7 +394,11 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
|
||||
),
|
||||
transport_sized(
|
||||
shell,
|
||||
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
|
||||
if playing {
|
||||
icons::MEDIA_PAUSE
|
||||
} else {
|
||||
icons::MEDIA_PLAY
|
||||
},
|
||||
if playing { "Pause" } else { "Play" },
|
||||
"play-pause",
|
||||
player.can_toggle(),
|
||||
@@ -381,22 +421,35 @@ pub(super) fn hub<'a>(shell: &'a FereseShell, rows: MenuRows<'a>, style: MenuSty
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn view<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
if let Some(player) = &shell.media.snapshot.selected {
|
||||
let cover: Element<'_, cosmic::Action<Message>> =
|
||||
match shell.media.artwork.as_ref().and_then(|art| art.handle.as_ref()) {
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(64)
|
||||
.height(64)
|
||||
.content_fit(cosmic::iced::ContentFit::Contain)
|
||||
.border_radius(shell.render.radius(12.0))
|
||||
.shape(BorderShape::Continuous)
|
||||
.into(),
|
||||
None => container(accented_icon(icons::MEDIA, 30, style.muted, color(style.theme.accent)))
|
||||
.center_x(64)
|
||||
.center_y(64)
|
||||
.into(),
|
||||
};
|
||||
let cover: Element<'_, cosmic::Action<Message>> = match shell
|
||||
.media
|
||||
.artwork
|
||||
.as_ref()
|
||||
.and_then(|art| art.handle.as_ref())
|
||||
{
|
||||
Some(handle) => image(handle.clone())
|
||||
.width(64)
|
||||
.height(64)
|
||||
.content_fit(cosmic::iced::ContentFit::Contain)
|
||||
.border_radius(shell.render.radius(12.0))
|
||||
.shape(BorderShape::Continuous)
|
||||
.into(),
|
||||
None => container(accented_icon(
|
||||
icons::MEDIA,
|
||||
30,
|
||||
style.muted,
|
||||
color(style.theme.accent),
|
||||
))
|
||||
.center_x(64)
|
||||
.center_y(64)
|
||||
.into(),
|
||||
};
|
||||
let text_size = if player.label().len() >= 24 { 13 } else { 15 };
|
||||
let mut info = column![
|
||||
shell
|
||||
@@ -470,20 +523,23 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
} else {
|
||||
progress = progress.push(seek_pill(
|
||||
container(
|
||||
cosmic::iced::widget::ProgressBar::new(0.0..=1.0, (position as f64 / length as f64) as f32)
|
||||
.girth(4)
|
||||
.length(Length::Fill)
|
||||
.class(cosmic::theme::iced::ProgressBar::custom(move |_| {
|
||||
cosmic::iced::widget::progress_bar::Style {
|
||||
background: style.muted.scale_alpha(0.35).into(),
|
||||
bar: color_with_opacity(style.theme.accent, style.opacity).into(),
|
||||
border: cosmic::iced::Border {
|
||||
shape: BorderShape::Circular,
|
||||
radius: 2.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})),
|
||||
cosmic::iced::widget::ProgressBar::new(
|
||||
0.0..=1.0,
|
||||
(position as f64 / length as f64) as f32,
|
||||
)
|
||||
.girth(4)
|
||||
.length(Length::Fill)
|
||||
.class(cosmic::theme::iced::ProgressBar::custom(move |_| {
|
||||
cosmic::iced::widget::progress_bar::Style {
|
||||
background: style.muted.scale_alpha(0.35).into(),
|
||||
bar: color_with_opacity(style.theme.accent, style.opacity).into(),
|
||||
border: cosmic::iced::Border {
|
||||
shape: BorderShape::Circular,
|
||||
radius: 2.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})),
|
||||
)
|
||||
.center_y(20),
|
||||
style,
|
||||
@@ -525,7 +581,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
),
|
||||
transport(
|
||||
shell,
|
||||
if playing { icons::MEDIA_PAUSE } else { icons::MEDIA_PLAY },
|
||||
if playing {
|
||||
icons::MEDIA_PAUSE
|
||||
} else {
|
||||
icons::MEDIA_PLAY
|
||||
},
|
||||
if playing { "Pause" } else { "Play" },
|
||||
"play-pause",
|
||||
player.can_toggle(),
|
||||
@@ -597,7 +657,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
shell.render,
|
||||
label,
|
||||
Message::MediaAction(pin),
|
||||
if player.ignored { style.muted } else { style.primary },
|
||||
if player.ignored {
|
||||
style.muted
|
||||
} else {
|
||||
style.primary
|
||||
},
|
||||
style.opacity
|
||||
),
|
||||
super::controls::menu_button(
|
||||
@@ -616,7 +680,13 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
}
|
||||
|
||||
if let Some(error) = &shell.media.error {
|
||||
rows = rows.push(shell.render.text(error).size(12).class(theme::Text::Color(style.muted)));
|
||||
rows = rows.push(
|
||||
shell
|
||||
.render
|
||||
.text(error)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(style.muted)),
|
||||
);
|
||||
}
|
||||
|
||||
rows
|
||||
@@ -645,9 +715,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn wheel_changes_are_bounded_and_pixel_scrolling_is_not_a_full_step() {
|
||||
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }), 0.05);
|
||||
assert_eq!(volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }), 0.001);
|
||||
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: f32::NAN }), 0.0);
|
||||
assert_eq!(volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }), 1.0);
|
||||
assert_eq!(
|
||||
volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1.0 }),
|
||||
0.05
|
||||
);
|
||||
assert_eq!(
|
||||
volume_delta(mouse::ScrollDelta::Pixels { x: 0.0, y: 1.0 }),
|
||||
0.001
|
||||
);
|
||||
assert_eq!(
|
||||
volume_delta(mouse::ScrollDelta::Lines {
|
||||
x: 0.0,
|
||||
y: f32::NAN
|
||||
}),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
volume_delta(mouse::ScrollDelta::Lines { x: 0.0, y: 1000.0 }),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ use status::{Action, Snapshot};
|
||||
|
||||
use super::*;
|
||||
|
||||
type MenuHeading<'a> = cosmic::widget::Row<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
|
||||
type MenuHeading<'a> =
|
||||
cosmic::widget::Row<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
|
||||
|
||||
type MenuRows<'a> = cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
|
||||
type MenuRows<'a> =
|
||||
cosmic::widget::Column<'a, cosmic::Action<Message>, cosmic::Theme, cosmic::Renderer>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MenuStyle {
|
||||
@@ -59,7 +61,10 @@ impl Menu {
|
||||
) | (Self::Network, Action::Wifi(_))
|
||||
| (Self::Bluetooth, Action::Bluetooth(_))
|
||||
| (Self::Audio, Action::Volume(_) | Action::Mute(_))
|
||||
| (Self::Battery, Action::Brightness(_) | Action::PowerProfile(_))
|
||||
| (
|
||||
Self::Battery,
|
||||
Action::Brightness(_) | Action::PowerProfile(_)
|
||||
)
|
||||
| (Self::Notifications, Action::Dnd(_) | Action::Notifications)
|
||||
);
|
||||
relevant.then_some(error.message.as_str())
|
||||
@@ -132,10 +137,9 @@ pub(crate) struct PopoverAnchor {
|
||||
impl PopoverAnchor {
|
||||
pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool {
|
||||
self.panel == panel.id
|
||||
&& self
|
||||
.item
|
||||
.as_ref()
|
||||
.is_none_or(|id| id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible))
|
||||
&& self.item.as_ref().is_none_or(|id| {
|
||||
id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn same_item(&self, other: &Self) -> bool {
|
||||
@@ -176,7 +180,11 @@ impl OpenMenu {
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(crate) fn view_hub_menu(&self, kind: Menu, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||
pub(crate) fn view_hub_menu(
|
||||
&self,
|
||||
kind: Menu,
|
||||
opacity: f32,
|
||||
) -> Element<'_, cosmic::Action<Message>> {
|
||||
let theme = self.config.theme;
|
||||
let style = MenuStyle {
|
||||
theme,
|
||||
@@ -216,7 +224,12 @@ impl FereseShell {
|
||||
Menu::Bluetooth => bluetooth::view(self, rows, style, true),
|
||||
Menu::Audio => audio::view(self, rows, style, false),
|
||||
Menu::Battery => battery::view(self, rows, style),
|
||||
Menu::Calendar => rows.push(calendar::hub(self.render, self.calendar_offset, theme, opacity)),
|
||||
Menu::Calendar => rows.push(calendar::hub(
|
||||
self.render,
|
||||
self.calendar_offset,
|
||||
theme,
|
||||
opacity,
|
||||
)),
|
||||
Menu::Media => media::hub(self, rows, style),
|
||||
Menu::Notifications => {
|
||||
let mut rows = notification_controls(self, rows, style, true);
|
||||
@@ -305,8 +318,17 @@ impl FereseShell {
|
||||
.class(theme::Text::Color(style.muted)),
|
||||
);
|
||||
}
|
||||
if let Some(error) = self.status_error.as_ref().and_then(|error| kind.action_error(error)) {
|
||||
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(style.muted)));
|
||||
if let Some(error) = self
|
||||
.status_error
|
||||
.as_ref()
|
||||
.and_then(|error| kind.action_error(error))
|
||||
{
|
||||
rows = rows.push(
|
||||
self.render
|
||||
.text(error)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(style.muted)),
|
||||
);
|
||||
}
|
||||
container(rows)
|
||||
.width(Length::Fill)
|
||||
@@ -377,7 +399,12 @@ impl FereseShell {
|
||||
.width(Length::Fixed(22.))
|
||||
.into()
|
||||
};
|
||||
let badge = ferese_theme::menus::badge(heading_icon, color(theme.accent), p, self.render.radius(20.));
|
||||
let badge = ferese_theme::menus::badge(
|
||||
heading_icon,
|
||||
color(theme.accent),
|
||||
p,
|
||||
self.render.radius(20.),
|
||||
);
|
||||
let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font);
|
||||
let style = MenuStyle {
|
||||
theme,
|
||||
@@ -402,7 +429,9 @@ impl FereseShell {
|
||||
Menu::Bluetooth => bluetooth::view(self, rows, style, false),
|
||||
Menu::Audio => audio::view(self, rows, style, false),
|
||||
Menu::Battery => battery::view(self, rows, style),
|
||||
Menu::Calendar => rows.push(calendar::view(self.render, self.calendar_offset, theme, p)),
|
||||
Menu::Calendar => {
|
||||
rows.push(calendar::view(self.render, self.calendar_offset, theme, p))
|
||||
}
|
||||
Menu::Notifications => notification_controls(self, rows, style, false),
|
||||
Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)),
|
||||
Menu::Recording | Menu::Workspaces => rows,
|
||||
@@ -423,25 +452,34 @@ impl FereseShell {
|
||||
.as_ref()
|
||||
.and_then(|error| menu.kind.action_error(error))
|
||||
{
|
||||
rows = rows.push(self.render.text(error).size(12).class(theme::Text::Color(Color {
|
||||
a: p,
|
||||
..Color::from_rgb8(230, 172, 90)
|
||||
})));
|
||||
rows = rows.push(
|
||||
self.render
|
||||
.text(error)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(Color {
|
||||
a: p,
|
||||
..Color::from_rgb8(230, 172, 90)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let compositor_material = menu.effects.is_some();
|
||||
let body: Element<'_, cosmic::Action<Message>> = if matches!(kind, Menu::Overflow | Menu::Activities) {
|
||||
cosmic::widget::scrollable(rows).height(Length::Shrink).into()
|
||||
} else {
|
||||
rows.into()
|
||||
};
|
||||
let body: Element<'_, cosmic::Action<Message>> =
|
||||
if matches!(kind, Menu::Overflow | Menu::Activities) {
|
||||
cosmic::widget::scrollable(rows)
|
||||
.height(Length::Shrink)
|
||||
.into()
|
||||
} else {
|
||||
rows.into()
|
||||
};
|
||||
let panel = container(body)
|
||||
.id("ferese-blur-card")
|
||||
.width(kind.width())
|
||||
.padding(16)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: (!compositor_material)
|
||||
.then_some(Background::Color(color_with_opacity(theme.surface_popover, p))),
|
||||
background: (!compositor_material).then_some(Background::Color(
|
||||
color_with_opacity(theme.surface_popover, p),
|
||||
)),
|
||||
text_color: Some(primary),
|
||||
icon_color: Some(primary),
|
||||
border: Border {
|
||||
@@ -488,7 +526,14 @@ fn notification_controls<'a>(
|
||||
opacity: p,
|
||||
} = style;
|
||||
if let Some(n) = &shell.status.notifications {
|
||||
let toggle = toggle_row(shell.render, "Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p);
|
||||
let toggle = toggle_row(
|
||||
shell.render,
|
||||
"Do Not Disturb",
|
||||
n.dnd,
|
||||
Action::Dnd(!n.dnd),
|
||||
theme,
|
||||
p,
|
||||
);
|
||||
rows = rows.push(if combined {
|
||||
control_card(shell.render, toggle, primary, p)
|
||||
} else {
|
||||
@@ -528,7 +573,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn activity_popups_scroll_to_the_last_row_in_regular_panels() {
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, renderer::Headless, widget};
|
||||
use cosmic::iced::advanced::{
|
||||
Layout, Shell, clipboard, layout, renderer::Headless, widget,
|
||||
};
|
||||
use cosmic::iced::{Event, Rectangle, Size, Vector, mouse};
|
||||
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
|
||||
#[derive(Default)]
|
||||
@@ -575,7 +622,10 @@ mod tests {
|
||||
for n in 0..24 {
|
||||
store
|
||||
.start(
|
||||
Owner { pid: n + 1, birth: 1 },
|
||||
Owner {
|
||||
pid: n + 1,
|
||||
birth: 1,
|
||||
},
|
||||
n.to_string(),
|
||||
Data {
|
||||
source: Source::Publisher,
|
||||
|
||||
@@ -7,9 +7,15 @@ use super::{Menu, MenuRows, MenuStyle};
|
||||
use crate::FereseShell;
|
||||
use crate::status::Action;
|
||||
|
||||
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn view<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
let MenuStyle {
|
||||
primary, opacity: p, ..
|
||||
primary,
|
||||
opacity: p,
|
||||
..
|
||||
} = style;
|
||||
if let Some(n) = &shell.status.network {
|
||||
let connected = n.enabled && n.connection.is_some();
|
||||
@@ -42,7 +48,11 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
rows = rows.push(
|
||||
column![
|
||||
row![
|
||||
shell.render.text("Signal strength").size(12).width(Length::Fill),
|
||||
shell
|
||||
.render
|
||||
.text("Signal strength")
|
||||
.size(12)
|
||||
.width(Length::Fill),
|
||||
shell.render.text(strength).size(12)
|
||||
],
|
||||
level_meter(shell.render, n.signal, primary, p),
|
||||
@@ -59,7 +69,9 @@ pub(super) fn heading<'a>(
|
||||
mut heading: super::MenuHeading<'a>,
|
||||
style: MenuStyle,
|
||||
) -> super::MenuHeading<'a> {
|
||||
let MenuStyle { theme, opacity: p, .. } = style;
|
||||
let MenuStyle {
|
||||
theme, opacity: p, ..
|
||||
} = style;
|
||||
if let Some(network) = &shell.status.network {
|
||||
heading = heading.push(shell_switch(
|
||||
shell.render,
|
||||
|
||||
@@ -2,7 +2,11 @@ use cosmic::widget::{column, slider};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
|
||||
pub(super) fn view<'a>(
|
||||
shell: &'a FereseShell,
|
||||
mut rows: MenuRows<'a>,
|
||||
style: MenuStyle,
|
||||
) -> MenuRows<'a> {
|
||||
let Some(status) = &shell.status.night_light else {
|
||||
return rows.push(shell.render.text("Night light is unavailable").size(13));
|
||||
};
|
||||
@@ -28,9 +32,19 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
shell.render.text("Lower temperatures are warmer").size(12),
|
||||
]
|
||||
.spacing(8);
|
||||
rows = rows.push(control_card(shell.render, warmth.into(), style.primary, style.opacity));
|
||||
rows = rows.push(control_card(
|
||||
shell.render,
|
||||
warmth.into(),
|
||||
style.primary,
|
||||
style.opacity,
|
||||
));
|
||||
if status.supported_outputs == 0 {
|
||||
rows = rows.push(shell.render.text("No display supports night light").size(13));
|
||||
rows = rows.push(
|
||||
shell
|
||||
.render
|
||||
.text("No display supports night light")
|
||||
.size(13),
|
||||
);
|
||||
} else if status.enabled && status.failed_outputs > 0 {
|
||||
rows = rows.push(
|
||||
shell
|
||||
|
||||
@@ -22,7 +22,11 @@ pub(super) fn view<'a>(
|
||||
} = style;
|
||||
use ferese_config::theme::{Appearance, Mode};
|
||||
let mut modes = row([]).spacing(6);
|
||||
for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] {
|
||||
for (mode, label) in [
|
||||
(Mode::Light, "☀ Light"),
|
||||
(Mode::Dark, "☾ Dark"),
|
||||
(Mode::Auto, "◐ Auto"),
|
||||
] {
|
||||
modes = modes.push(motion::button(
|
||||
shell.render,
|
||||
button::custom(shell.render.text(label).font(shell.render.font).size(13))
|
||||
@@ -45,7 +49,12 @@ pub(super) fn view<'a>(
|
||||
let settings = motion::button(
|
||||
shell.render,
|
||||
button::custom(
|
||||
ferese_theme::icons::tinted(ferese_theme::icons::SETTINGS, 18, color(theme.text_primary)).opacity(p),
|
||||
ferese_theme::icons::tinted(
|
||||
ferese_theme::icons::SETTINGS,
|
||||
18,
|
||||
color(theme.text_primary),
|
||||
)
|
||||
.opacity(p),
|
||||
)
|
||||
.name("Open Ferese Settings")
|
||||
.width(30)
|
||||
@@ -135,7 +144,10 @@ pub(super) fn view<'a>(
|
||||
);
|
||||
}
|
||||
|
||||
if shell.status.network.is_some() || shell.status.bluetooth.is_some() || shell.status.battery.is_some() {
|
||||
if shell.status.network.is_some()
|
||||
|| shell.status.bluetooth.is_some()
|
||||
|| shell.status.battery.is_some()
|
||||
{
|
||||
rows = rows.push(control_card(shell.render, connections.into(), primary, p));
|
||||
}
|
||||
rows = audio::view(shell, rows, style, true);
|
||||
@@ -195,7 +207,10 @@ fn connection_control<'a>(
|
||||
.height(28)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.align_y(alignment::Vertical::Center);
|
||||
let content = column![icon].spacing(2).width(Length::Fill).align_x(Alignment::Center);
|
||||
let content = column![icon]
|
||||
.spacing(2)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center);
|
||||
let content = content.push(
|
||||
render
|
||||
.text(connection_caption(detail))
|
||||
@@ -210,12 +225,18 @@ fn connection_control<'a>(
|
||||
.class(theme::Text::Color(muted)),
|
||||
);
|
||||
let Some(action) = action else {
|
||||
return container(content).width(Length::FillPortion(1)).padding(4).into();
|
||||
return container(content)
|
||||
.width(Length::FillPortion(1))
|
||||
.padding(4)
|
||||
.into();
|
||||
};
|
||||
let button = button::custom(content)
|
||||
.width(Length::Fill)
|
||||
.padding(4)
|
||||
.name(format!("{label}: {}, {detail}", if enabled { "on" } else { "off" }))
|
||||
.name(format!(
|
||||
"{label}: {}, {detail}",
|
||||
if enabled { "on" } else { "off" }
|
||||
))
|
||||
.on_press(cosmic::Action::App(Message::Control(action)));
|
||||
let tile: Element<'_, cosmic::Action<Message>> = if selected {
|
||||
button
|
||||
@@ -277,8 +298,14 @@ mod tests {
|
||||
assert_eq!(super::connection_caption("88%"), "88%");
|
||||
assert_eq!(super::connection_caption("Flow84@Lofree"), "Flow84@Lofr…");
|
||||
assert_eq!(super::connection_caption("123456789012"), "123456789012");
|
||||
assert_eq!(super::connection_caption("My very long headphones"), "My very lon…");
|
||||
assert_eq!(super::connection_caption(" Device\n name "), "Device name");
|
||||
assert_eq!(
|
||||
super::connection_caption("My very long headphones"),
|
||||
"My very lon…"
|
||||
);
|
||||
assert_eq!(
|
||||
super::connection_caption(" Device\n name "),
|
||||
"Device name"
|
||||
);
|
||||
let caption = super::connection_caption("耳機耳機耳機耳機耳機耳機耳機");
|
||||
assert_eq!(caption.chars().count(), 12);
|
||||
assert!(caption.ends_with('…'));
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
use super::{
|
||||
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event,
|
||||
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
|
||||
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
|
||||
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
|
||||
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, theme,
|
||||
wayland, window, wl_output, wl_registry, wl_surface,
|
||||
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING,
|
||||
Element, Event, EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1,
|
||||
GlobalListContents, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, Length, Limits,
|
||||
Message, Ordering, OutputSurfaces, PlatformSpecific, Proxy, QueueHandle,
|
||||
SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
|
||||
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden,
|
||||
registry_queue_init, status_ui, theme, wayland, window, wl_output, wl_registry, wl_surface,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
||||
pub(super) fn panel_placement(
|
||||
edge: ferese_panel::Edge,
|
||||
margin: i32,
|
||||
side: i32,
|
||||
) -> (Anchor, IcedMargin) {
|
||||
let (anchor, top, bottom) = match edge {
|
||||
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
|
||||
ferese_panel::Edge::Bottom => (Anchor::BOTTOM, 0, margin),
|
||||
@@ -27,12 +32,20 @@ pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32)
|
||||
}
|
||||
|
||||
impl FereseShell {
|
||||
pub(super) fn output_event(&mut self, event: wayland::OutputEvent, output: wl_output::WlOutput) -> Task<Message> {
|
||||
pub(super) fn output_event(
|
||||
&mut self,
|
||||
event: wayland::OutputEvent,
|
||||
output: wl_output::WlOutput,
|
||||
) -> Task<Message> {
|
||||
if matches!(event, wayland::OutputEvent::Removed) {
|
||||
if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) {
|
||||
let entry = self.outputs.remove(index);
|
||||
self.refresh_media_art(false);
|
||||
let menu = if self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == entry.bar) {
|
||||
let menu = if self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.anchor.parent == entry.bar)
|
||||
{
|
||||
self.destroy_menu()
|
||||
} else {
|
||||
Task::none()
|
||||
@@ -84,7 +97,12 @@ impl FereseShell {
|
||||
|
||||
let bar = entry.bar;
|
||||
let margin = self.update_panel_margin(bar, false);
|
||||
let popup = if resized && self.menu.as_ref().is_some_and(|menu| menu.anchor.parent == bar) {
|
||||
let popup = if resized
|
||||
&& self
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.anchor.parent == bar)
|
||||
{
|
||||
self.destroy_menu()
|
||||
} else {
|
||||
Task::none()
|
||||
@@ -105,13 +123,18 @@ impl FereseShell {
|
||||
margin,
|
||||
popup,
|
||||
tooltip,
|
||||
window::scale_factor(bar).map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))),
|
||||
window::scale_factor(bar)
|
||||
.map(move |scale| cosmic::Action::App(Message::PanelScale(bar, scale))),
|
||||
]);
|
||||
}
|
||||
|
||||
let bar_surface_id = window::Id::unique();
|
||||
let wallpaper_surface_id = window::Id::unique();
|
||||
let runtime = super::panel_runtime::Runtime::new(&self.config.panels[0], &self.snapshot, name.as_deref());
|
||||
let runtime = super::panel_runtime::Runtime::new(
|
||||
&self.config.panels[0],
|
||||
&self.snapshot,
|
||||
name.as_deref(),
|
||||
);
|
||||
let panel = runtime.panel(&self.config.panels[0]);
|
||||
let geometry = panel.resolved_geometry();
|
||||
let panel_surface = panel.surface();
|
||||
@@ -123,7 +146,8 @@ impl FereseShell {
|
||||
.find(|output| Some(output.name.as_str()) == name.as_deref())
|
||||
.map(|output| output.id),
|
||||
));
|
||||
let (edge_anchor, margin) = panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
let (edge_anchor, margin) =
|
||||
panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
let bar = BarMetrics::from(geometry);
|
||||
let hub_size = runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
|
||||
let bar_anchor = if hub_size.is_some() {
|
||||
@@ -189,9 +213,11 @@ impl FereseShell {
|
||||
namespace: "ferese-shell-top-bar".to_owned(),
|
||||
// Measure at full width before applying the requested side inset.
|
||||
margin,
|
||||
size: Some(hub_size.map_or((None, Some(bar.height.round() as u32)), |size| {
|
||||
(Some(size[0]), Some(size[1]))
|
||||
})),
|
||||
size: Some(
|
||||
hub_size.map_or((None, Some(bar.height.round() as u32)), |size| {
|
||||
(Some(size[0]), Some(size[1]))
|
||||
}),
|
||||
),
|
||||
size_limits: Limits::NONE,
|
||||
// Reserve space toward the desktop; layer-shell accounts for the edge margin.
|
||||
exclusive_zone,
|
||||
@@ -221,9 +247,15 @@ 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
|
||||
.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
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.contains(id))
|
||||
|| self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
@@ -235,11 +267,17 @@ impl FereseShell {
|
||||
if let Some(task) = self.launcher_event(&event, id) {
|
||||
return task;
|
||||
}
|
||||
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(cosmic::iced::mouse::Event::CursorEntered)
|
||||
) {
|
||||
return self.hub_hover(id, true);
|
||||
}
|
||||
if matches!(event, Event::Mouse(cosmic::iced::mouse::Event::CursorLeft)) {
|
||||
return Task::batch([self.dismiss_workspace_tooltip_for_bar(id), self.hub_hover(id, false)]);
|
||||
return Task::batch([
|
||||
self.dismiss_workspace_tooltip_for_bar(id),
|
||||
self.hub_hover(id, false),
|
||||
]);
|
||||
}
|
||||
if let Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_)) = &event
|
||||
&& let Some(modal) = &mut self.system_modal
|
||||
@@ -326,9 +364,15 @@ impl FereseShell {
|
||||
let target = Id::new("ferese-panel-hub");
|
||||
let focus: Box<dyn cosmic::iced::advanced::widget::Operation<cosmic::Action<Message>>> =
|
||||
if modifiers.shift() {
|
||||
Box::new(operation::scoped(target, operation::focusable::focus_previous()))
|
||||
Box::new(operation::scoped(
|
||||
target,
|
||||
operation::focusable::focus_previous(),
|
||||
))
|
||||
} else {
|
||||
Box::new(operation::scoped(target, operation::focusable::focus_next()))
|
||||
Box::new(operation::scoped(
|
||||
target,
|
||||
operation::focusable::focus_next(),
|
||||
))
|
||||
};
|
||||
let task = cosmic::iced::advanced::widget::operate(focus);
|
||||
return if self.hub_mode(id)
|
||||
@@ -348,7 +392,10 @@ impl FereseShell {
|
||||
..
|
||||
}) = &event
|
||||
&& !modifiers.control()
|
||||
&& self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|
||||
&& self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.contains(id))
|
||||
{
|
||||
return self.navigate_system_modal(modifiers.shift());
|
||||
}
|
||||
@@ -380,9 +427,10 @@ impl FereseShell {
|
||||
return self.close_hub(*layer);
|
||||
}
|
||||
match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(event, output))) => {
|
||||
self.output_event(event, output)
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
|
||||
event,
|
||||
output,
|
||||
))) => self.output_event(event, output),
|
||||
Event::Window(window::Event::Rescaled(scale)) => {
|
||||
cosmic::task::message(cosmic::Action::App(Message::PanelScale(id, scale)))
|
||||
}
|
||||
@@ -392,34 +440,47 @@ impl FereseShell {
|
||||
// owner has gone away. IDs are never reused by the shell.
|
||||
Task::batch([
|
||||
destroy_layer_surface(id),
|
||||
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(id))),
|
||||
cosmic::task::message(cosmic::Action::Surface(
|
||||
cosmic::surface::action::destroy_popup(id),
|
||||
)),
|
||||
])
|
||||
}
|
||||
Event::Window(window::Event::Opened { .. })
|
||||
if self.outputs.iter().any(|entry| entry.bar == 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
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.contains(id))
|
||||
|| self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|surface| surface.id == id) =>
|
||||
{
|
||||
Task::batch([
|
||||
window::scale_factor(id).map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))),
|
||||
window::scale_factor(id)
|
||||
.map(move |scale| cosmic::Action::App(Message::PanelScale(id, scale))),
|
||||
self.focus_system_modal(id),
|
||||
window::run(id, native_wayland_surface)
|
||||
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))),
|
||||
window::run(id, native_wayland_surface).map(move |surface| {
|
||||
cosmic::Action::App(Message::NativeSurface(id, surface))
|
||||
}),
|
||||
])
|
||||
}
|
||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if self.menu.is_some() => self.close_menu(),
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(event, surface, popup_id))) => {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
|
||||
event,
|
||||
surface,
|
||||
popup_id,
|
||||
))) => {
|
||||
if matches!(event, wayland::PopupEvent::Done) {
|
||||
self.closing_menu_effects.remove(&popup_id);
|
||||
}
|
||||
if self.workspace_ui.tooltip == Some(popup_id) && matches!(event, wayland::PopupEvent::Done) {
|
||||
if self.workspace_ui.tooltip == Some(popup_id)
|
||||
&& matches!(event, wayland::PopupEvent::Done)
|
||||
{
|
||||
self.workspace_ui.tooltip = None;
|
||||
return self.dismiss_workspace_tooltip();
|
||||
}
|
||||
@@ -444,7 +505,9 @@ impl FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => {
|
||||
Event::Window(window::Event::Closed)
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|
||||
{
|
||||
self.menu = None;
|
||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||
Task::none()
|
||||
@@ -454,7 +517,10 @@ impl FereseShell {
|
||||
self.dismiss_workspace_tooltip()
|
||||
}
|
||||
Event::Window(window::Event::Closed)
|
||||
if self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) =>
|
||||
if self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
.is_some_and(|modal| modal.contains(id)) =>
|
||||
{
|
||||
self.close_system_modal()
|
||||
}
|
||||
@@ -462,9 +528,11 @@ impl FereseShell {
|
||||
self.closing_menu_effects.remove(&id);
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
|
||||
if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) =>
|
||||
{
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
||||
_,
|
||||
surface,
|
||||
frame_id,
|
||||
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
|
||||
if let Some(menu) = &mut self.menu
|
||||
&& menu.effects.is_none()
|
||||
{
|
||||
@@ -472,22 +540,32 @@ impl FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(_, surface, frame_id)))
|
||||
if self.outputs.iter().any(|entry| entry.bar == frame_id) =>
|
||||
{
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
||||
_,
|
||||
surface,
|
||||
frame_id,
|
||||
))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => {
|
||||
self.attach_effects(frame_id, &surface);
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
|
||||
if self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.id == layer_id) =>
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
|
||||
_,
|
||||
surface,
|
||||
layer_id,
|
||||
))) if self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.id == layer_id) =>
|
||||
{
|
||||
let entry = self.notification_surface.as_mut().unwrap();
|
||||
|
||||
if entry.effects.is_none() {
|
||||
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||
match EffectsBinding::attach_role(
|
||||
&mut self.effects_context,
|
||||
&surface,
|
||||
None,
|
||||
1.0,
|
||||
) {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: notification effects unavailable: {error}")
|
||||
@@ -496,9 +574,11 @@ impl FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(_, surface, layer_id)))
|
||||
if self.outputs.iter().any(|entry| entry.bar == layer_id) =>
|
||||
{
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
|
||||
_,
|
||||
surface,
|
||||
layer_id,
|
||||
))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => {
|
||||
self.attach_effects(layer_id, &surface);
|
||||
self.attach_power_material(layer_id, &surface);
|
||||
window::scale_factor(layer_id)
|
||||
@@ -523,8 +603,12 @@ impl FereseShell {
|
||||
// The first panel presentation supplies the role and measured shape
|
||||
// together with its buffer. Enabling the role here would paint the
|
||||
// whole surface before those bounds arrive.
|
||||
let binding =
|
||||
EffectsBinding::attach_role(&mut self.effects_context, surface, None, if hidden { 0.0 } else { 1.0 });
|
||||
let binding = EffectsBinding::attach_role(
|
||||
&mut self.effects_context,
|
||||
surface,
|
||||
None,
|
||||
if hidden { 0.0 } else { 1.0 },
|
||||
);
|
||||
|
||||
match binding {
|
||||
Ok(binding) => entry.effects = Some(binding),
|
||||
@@ -566,21 +650,28 @@ pub(super) fn native_wayland_surface(
|
||||
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
let display = window.display_handle().map_err(|error| error.to_string())?;
|
||||
let handle = window.window_handle().map_err(|error| error.to_string())?;
|
||||
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw())
|
||||
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
|
||||
(display.as_raw(), handle.as_raw())
|
||||
else {
|
||||
return Err("shell material requires a Wayland surface".to_owned());
|
||||
};
|
||||
// SAFETY: iced lends matching, live Wayland handles for this callback.
|
||||
// This backend borrows the display; it never disconnects iced's connection.
|
||||
// Both handles belong to the runtime, which outlives the shell bindings.
|
||||
let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
|
||||
let backend = unsafe {
|
||||
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
|
||||
};
|
||||
let connection = Connection::from_backend(backend);
|
||||
// SAFETY: raw-window-handle identifies this pointer as a live wl_surface.
|
||||
let id = unsafe {
|
||||
wayland_client::backend::ObjectId::from_ptr(wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast())
|
||||
wayland_client::backend::ObjectId::from_ptr(
|
||||
wl_surface::WlSurface::interface(),
|
||||
handle.surface.as_ptr().cast(),
|
||||
)
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
|
||||
let surface =
|
||||
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
|
||||
Ok((connection, surface))
|
||||
}
|
||||
|
||||
@@ -611,7 +702,12 @@ fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::i
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
||||
type EffectPresentation = (
|
||||
Vec<[f32; 5]>,
|
||||
u32,
|
||||
Vec<u32>,
|
||||
ferese_surface_effects_v1::Role,
|
||||
);
|
||||
|
||||
// Keep one registry on the shell connection: wl_registry has no destructor on
|
||||
// the protocol versions we support, so creating one per popup leaks objects.
|
||||
@@ -655,10 +751,9 @@ impl EffectsBinding {
|
||||
let connection = Connection::from_backend(backend);
|
||||
// Native handles can create distinct borrowed Backend wrappers around
|
||||
// the same wl_display. Backend equality compares wrappers, not displays.
|
||||
if context
|
||||
.as_ref()
|
||||
.is_none_or(|context| context.connection.backend().display_ptr() != connection.backend().display_ptr())
|
||||
{
|
||||
if context.as_ref().is_none_or(|context| {
|
||||
context.connection.backend().display_ptr() != connection.backend().display_ptr()
|
||||
}) {
|
||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||
// Cache the registry even when this compositor lacks our extension.
|
||||
let manager = globals
|
||||
@@ -742,17 +837,19 @@ impl EffectsBinding {
|
||||
.collect();
|
||||
validate_region_counts(regions.len(), values.len())?;
|
||||
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)
|
||||
{
|
||||
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(),
|
||||
values
|
||||
.iter()
|
||||
.flat_map(|value| value.to_ne_bytes())
|
||||
.collect(),
|
||||
);
|
||||
self.connection.flush()?;
|
||||
if let Some(old) = previous.as_mut() {
|
||||
@@ -768,7 +865,10 @@ impl EffectsBinding {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub(super) fn set_regions(
|
||||
&self,
|
||||
regions: &[[f32; 5]],
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
|
||||
}
|
||||
|
||||
@@ -795,7 +895,8 @@ impl EffectsBinding {
|
||||
pub(super) fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.set_opacity(1.0)?;
|
||||
if visible {
|
||||
self.surface.set_role(ferese_surface_effects_v1::Role::Panel);
|
||||
self.surface
|
||||
.set_role(ferese_surface_effects_v1::Role::Panel);
|
||||
} else {
|
||||
self.surface.clear_role();
|
||||
}
|
||||
|
||||
@@ -147,8 +147,11 @@ fn effects_lifecycle(advertise: bool) {
|
||||
}
|
||||
});
|
||||
let connection = wayland_client::Connection::from_socket(client).unwrap();
|
||||
let (globals, queue) = wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
|
||||
let compositor = globals.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ()).unwrap();
|
||||
let (globals, queue) =
|
||||
wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
|
||||
let compositor = globals
|
||||
.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ())
|
||||
.unwrap();
|
||||
let mut context = None;
|
||||
let mut first = None;
|
||||
for _ in 0..20 {
|
||||
@@ -157,7 +160,9 @@ fn effects_lifecycle(advertise: bool) {
|
||||
// Match window::run: each native callback lends the same display through
|
||||
// a fresh borrowed backend. Neither wrapper owns the actual display.
|
||||
let borrowed = wayland_client::Connection::from_backend(unsafe {
|
||||
wayland_client::backend::Backend::from_foreign_display(connection.backend().display_ptr())
|
||||
wayland_client::backend::Backend::from_foreign_display(
|
||||
connection.backend().display_ptr(),
|
||||
)
|
||||
});
|
||||
assert_ne!(borrowed, connection);
|
||||
let id = unsafe {
|
||||
@@ -167,7 +172,8 @@ fn effects_lifecycle(advertise: bool) {
|
||||
)
|
||||
}
|
||||
.unwrap();
|
||||
let borrowed_surface = wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
|
||||
let borrowed_surface =
|
||||
wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
|
||||
let binding = super::EffectsBinding::attach_role(&mut context, &borrowed_surface, None, 1.);
|
||||
assert_eq!(binding.is_ok(), advertise);
|
||||
if let Some(first) = &first {
|
||||
@@ -190,7 +196,10 @@ fn effects_lifecycle(advertise: bool) {
|
||||
drop(first);
|
||||
drop(context);
|
||||
connection.roundtrip().unwrap();
|
||||
assert_eq!(counts.lock().unwrap().manager_destroys, usize::from(advertise));
|
||||
assert_eq!(
|
||||
counts.lock().unwrap().manager_destroys,
|
||||
usize::from(advertise)
|
||||
);
|
||||
drop(stop);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user