advance shell integration and session support

This commit is contained in:
Samuel Onoja committed 2026-09-25 09:15:07 +01:00
1 parent 0420242350
commit e85fc1e032
27 files changed
+1165 -201

No files matched your search

+11 -2
View File
@@ -1,12 +1,16 @@
# Ferese # Ferese
Ferese is an experimental Wayland scrolling compositor written in Rust with Ferese is a Wayland scrolling compositor written in Rust with
Smithay. Scrolling columns are the default, tree tiling remains available as an Smithay. Scrolling columns are the default, tree tiling remains available as an
optional per-workspace mode, and geometry changes use timestamp-driven optional per-workspace mode, and geometry changes use timestamp-driven
animation. See the animation. See the
[window-manager specification](docs/ferese-window-manager-spec.md) for the [window-manager specification](docs/ferese-window-manager-spec.md) for the
current architecture and milestones. current architecture and milestones.
For a versioned installation alongside Plasma in SDDM, see
[installation and recovery](docs/installation.md). `Super+Shift+E` or
`feresectl exit` logs out immediately; save your work first.
## Development ## Development
Install the native dependencies required by Smithay's winit and GLES backends, Install the native dependencies required by Smithay's winit and GLES backends,
@@ -24,6 +28,10 @@ cargo run -p ferese -- foot
Set `RUST_LOG=ferese=debug` for detailed compositor logging. Set `RUST_LOG=ferese=debug` for detailed compositor logging.
`Super+F` toggles decorated maximization inside the workspace (bar and outer
gaps retained). `Super+Shift+F` toggles true fullscreen (no bar or window
decorations). Leaving fullscreen restores the previous maximized/tiled state.
Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under
the `ferese::render` tracing target. Summaries include rendered frames, damaged the `ferese::render` tracing target. Summaries include rendered frames, damaged
pixels, average and longest render time, DRM missed-deadline totals, blur-active pixels, average and longest render time, DRM missed-deadline totals, blur-active
@@ -76,6 +84,7 @@ them for a lone tiled window:
```toml ```toml
[layout] [layout]
outer_gap = 4.0
smart_gaps = true smart_gaps = true
``` ```
@@ -245,7 +254,7 @@ cargo run -p feresectl -- get-outputs
``` ```
The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`,
`toggle-floating`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`,
`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and `center-column`, `consume`, `expel`, `close`, `get-focused-window`, and
`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The `get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The
runtime directory and socket are restricted to modes `0700` and `0600`, and the runtime directory and socket are restricted to modes `0700` and `0600`, and the
+44 -5
View File
@@ -110,6 +110,7 @@ pub struct ThemeSettings {
pub border_color: RgbaColor, pub border_color: RgbaColor,
pub accent_color: RgbaColor, pub accent_color: RgbaColor,
pub shadow_color: RgbaColor, pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub window_radius: f64, pub window_radius: f64,
pub shadow_offset_y: f64, pub shadow_offset_y: f64,
pub shadow_blur: f64, pub shadow_blur: f64,
@@ -146,6 +147,8 @@ struct ThemeMaterialConfig {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ThemeColorsConfig { struct ThemeColorsConfig {
#[serde(default = "default_surface_base_color")]
surface_base: String,
#[serde(default = "default_border_color")] #[serde(default = "default_border_color")]
border: String, border: String,
#[serde(default = "default_accent_color")] #[serde(default = "default_accent_color")]
@@ -157,6 +160,7 @@ struct ThemeColorsConfig {
impl Default for ThemeColorsConfig { impl Default for ThemeColorsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
surface_base: default_surface_base_color(),
border: default_border_color(), border: default_border_color(),
accent: default_accent_color(), accent: default_accent_color(),
shadow: default_shadow_color(), shadow: default_shadow_color(),
@@ -235,12 +239,14 @@ struct BindingModifiers {
pub enum BindingAction { pub enum BindingAction {
Spawn(Vec<String>), Spawn(Vec<String>),
Close, Close,
Exit,
Focus(ferese_layout::Direction), Focus(ferese_layout::Direction),
Move(ferese_layout::Direction), Move(ferese_layout::Direction),
Resize(ferese_layout::Direction), Resize(ferese_layout::Direction),
SwitchWorkspace(u8), SwitchWorkspace(u8),
MoveToWorkspace(u8), MoveToWorkspace(u8),
ToggleFullscreen, ToggleFullscreen,
ToggleMaximized,
ToggleLayout, ToggleLayout,
CycleColumnWidth, CycleColumnWidth,
CenterColumn, CenterColumn,
@@ -803,6 +809,10 @@ impl Config {
border_color: parse_color(&self.theme.colors.border, "colors.border")?, border_color: parse_color(&self.theme.colors.border, "colors.border")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color(
&self.theme.colors.surface_base,
"colors.surface_base",
)?,
window_radius, window_radius,
shadow_offset_y, shadow_offset_y,
shadow_blur, shadow_blur,
@@ -1059,6 +1069,10 @@ fn parse_action(
no_argument()?; no_argument()?;
Ok(BindingAction::Close) Ok(BindingAction::Close)
} }
"exit" => {
no_argument()?;
Ok(BindingAction::Exit)
}
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)), "focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)), "move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
"resize" => Ok(BindingAction::Resize( "resize" => Ok(BindingAction::Resize(
@@ -1070,6 +1084,10 @@ fn parse_action(
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace( "move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(
required_argument()?, required_argument()?,
)?)), )?)),
"toggle-maximized" => {
no_argument()?;
Ok(BindingAction::ToggleMaximized)
}
"toggle-fullscreen" => { "toggle-fullscreen" => {
no_argument()?; no_argument()?;
Ok(BindingAction::ToggleFullscreen) Ok(BindingAction::ToggleFullscreen)
@@ -1133,7 +1151,8 @@ fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![ let mut bindings = vec![
binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Q", "close", None), binding("Super+Q", "close", None),
binding("Super+F", "toggle-fullscreen", None), binding("Super+F", "toggle-maximized", None),
binding("Super+Shift+F", "toggle-fullscreen", None),
binding("Super+M", "toggle-layout", None), binding("Super+M", "toggle-layout", None),
binding("Super+R", "cycle-column-width", None), binding("Super+R", "cycle-column-width", None),
binding("Super+C", "center-column", None), binding("Super+C", "center-column", None),
@@ -1141,6 +1160,7 @@ fn default_bindings() -> Vec<BindingConfig> {
binding("Super+]", "expel", None), binding("Super+]", "expel", None),
binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Shift+Space", "toggle-floating", None),
binding("Super+Tab", "toggle-overview", None), binding("Super+Tab", "toggle-overview", None),
binding("Super+Shift+E", "exit", None),
]; ];
for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] { for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] {
@@ -1283,13 +1303,17 @@ const fn default_inner_gap() -> f64 {
} }
const fn default_outer_gap() -> f64 { const fn default_outer_gap() -> f64 {
10.0 4.0
} }
fn default_border_color() -> String { fn default_border_color() -> String {
"#FFFFFF18".to_owned() "#FFFFFF18".to_owned()
} }
fn default_surface_base_color() -> String {
"#111821".to_owned()
}
fn default_accent_color() -> String { fn default_accent_color() -> String {
"#5B8CFF".to_owned() "#5B8CFF".to_owned()
} }
@@ -1574,6 +1598,7 @@ mod tests {
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]), border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]), accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
window_radius: 12.0, window_radius: 12.0,
shadow_offset_y: -2.0, shadow_offset_y: -2.0,
shadow_blur: 24.0, shadow_blur: 24.0,
@@ -1628,7 +1653,21 @@ mod tests {
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 40); assert_eq!(bindings.len(), 42);
for (shift, action) in [
(false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen),
] {
assert!(bindings.iter().any(|binding| binding.modifiers.logo
&& binding.modifiers.shift == shift
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_f)
&& binding.action == action));
}
assert!(
bindings
.iter()
.any(|binding| binding.action == BindingAction::Exit)
);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.modifiers.logo binding.modifiers.logo
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return) && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
@@ -1650,7 +1689,7 @@ mod tests {
let input = replaced.input_settings().unwrap(); let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 40); assert_eq!(bindings.len(), 42);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action binding.action
== BindingAction::Spawn(vec![ == BindingAction::Spawn(vec![
@@ -1662,7 +1701,7 @@ mod tests {
let input = unbound.input_settings().unwrap(); let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap(); let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 39); assert_eq!(bindings.len(), 41);
assert!( assert!(
!bindings !bindings
.iter() .iter()
+22
View File
@@ -198,6 +198,28 @@ impl XdgShellHandler for Ferese {
); );
} }
fn maximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.window_ids.iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
self.set_window_maximized(id, true);
}
}
fn unmaximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.window_ids.iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
.then_some(*id)
}) {
self.set_window_maximized(id, false);
}
}
fn fullscreen_request( fn fullscreen_request(
&mut self, &mut self,
surface: ToplevelSurface, surface: ToplevelSurface,
+45 -6
View File
@@ -44,6 +44,14 @@ impl Ferese {
} }
let symbol = keysym.modified_sym().raw(); let symbol = keysym.modified_sym().raw();
if overview_escape(symbol, data.overview.is_active()) {
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.set_overview_active(false);
}
return FilterResult::Intercept(());
}
if emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) { if emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
@@ -168,11 +176,29 @@ impl Ferese {
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
if self.overview.is_active() { if self.overview.is_active() {
if event.state() == ButtonState::Pressed let position = pointer.current_location();
&& let Some(window) = self.window_under_visual(pointer.current_location()) if self.layer_under(position).is_some() {
&& let Some(id) = self.window_ids.get(&window).copied() pointer.button(
{ self,
self.select_overview_window(id); &ButtonEvent {
button: event.button_code(),
state: event.state(),
serial,
time: event.time() as u32,
},
);
pointer.frame(self);
return;
}
if event.state() == ButtonState::Pressed {
if let Some(window) = self.window_under_visual(position)
&& let Some(id) = self.window_ids.get(&window).copied()
{
self.select_overview_window(id);
} else {
self.set_overview_active(false);
}
} }
return; return;
@@ -465,6 +491,7 @@ impl Ferese {
} }
} }
BindingAction::Close => self.close_focused_window(), BindingAction::Close => self.close_focused_window(),
BindingAction::Exit => self.loop_signal.stop(),
BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Focus(direction) => self.focus_direction(direction),
BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Move(direction) => self.move_direction(direction),
BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction),
@@ -475,6 +502,7 @@ impl Ferese {
self.move_focused_to_workspace(u32::from(workspace)); self.move_focused_to_workspace(u32::from(workspace));
} }
BindingAction::ToggleFullscreen => self.toggle_focused_fullscreen(), BindingAction::ToggleFullscreen => self.toggle_focused_fullscreen(),
BindingAction::ToggleMaximized => self.toggle_focused_maximized(),
BindingAction::ToggleLayout => self.toggle_layout_mode(), BindingAction::ToggleLayout => self.toggle_layout_mode(),
BindingAction::CycleColumnWidth => self.cycle_focused_column_width(), BindingAction::CycleColumnWidth => self.cycle_focused_column_width(),
BindingAction::CenterColumn => self.center_focused_column(), BindingAction::CenterColumn => self.center_focused_column(),
@@ -503,9 +531,20 @@ fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool {
ctrl && alt && symbol == keysyms::KEY_Escape ctrl && alt && symbol == keysyms::KEY_Escape
} }
fn overview_escape(symbol: u32, overview_active: bool) -> bool {
overview_active && symbol == keysyms::KEY_Escape
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{emergency_shortcut_escape, virtual_terminal}; use super::{emergency_shortcut_escape, overview_escape, virtual_terminal};
#[test]
fn escape_closes_only_an_active_overview() {
assert!(overview_escape(keysyms::KEY_Escape, true));
assert!(!overview_escape(keysyms::KEY_Escape, false));
assert!(!overview_escape(keysyms::KEY_q, true));
}
#[test] #[test]
fn emergency_escape_requires_control_alt_escape() { fn emergency_escape_requires_control_alt_escape() {
+29 -7
View File
@@ -16,7 +16,7 @@ use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use ferese_layout::Direction; use ferese_layout::Direction;
use serde_json::{Value, json}; use serde_json::{Value, json};
use smithay::reexports::calloop::{EventLoop, channel}; use smithay::reexports::calloop::{EventLoop, LoopSignal, channel};
use smithay::utils::Transform; use smithay::utils::Transform;
use crate::{Ferese, config::OutputTransform}; use crate::{Ferese, config::OutputTransform};
@@ -84,15 +84,20 @@ pub(crate) fn init(
} }
})?; })?;
let signal = event_loop.get_signal();
thread::Builder::new() thread::Builder::new()
.name("ferese-ipc-listener".to_owned()) .name("ferese-ipc-listener".to_owned())
.spawn(move || accept_connections(listener, sender))?; .spawn(move || accept_connections(listener, sender, signal))?;
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections"); tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
Ok(guard) Ok(guard)
} }
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcCall>) { fn accept_connections(
listener: UnixListener,
sender: channel::SyncSender<IpcCall>,
signal: LoopSignal,
) {
let active_connections = Arc::new(AtomicUsize::new(0)); let active_connections = Arc::new(AtomicUsize::new(0));
loop { loop {
@@ -125,11 +130,12 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcCal
continue; continue;
}; };
let sender = sender.clone(); let sender = sender.clone();
let signal = signal.clone();
if let Err(error) = thread::Builder::new() if let Err(error) = thread::Builder::new()
.name("ferese-ipc-client".to_owned()) .name("ferese-ipc-client".to_owned())
.spawn(move || { .spawn(move || {
let _permit = permit; let _permit = permit;
serve_connection(stream, sender); serve_connection(stream, sender, signal);
}) })
{ {
tracing::warn!(%error, "could not start IPC connection worker"); tracing::warn!(%error, "could not start IPC connection worker");
@@ -154,9 +160,13 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
}) })
} }
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>) { fn serve_connection(
mut stream: UnixStream,
sender: channel::SyncSender<IpcCall>,
signal: LoopSignal,
) {
loop { loop {
let request = match read_frame(&mut stream) { let request: Request = match read_frame(&mut stream) {
Ok(request) => request, Ok(request) => request,
Err(ferese_ipc::FrameError::Io(error)) Err(ferese_ipc::FrameError::Io(error))
if matches!( if matches!(
@@ -174,6 +184,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>
} }
}; };
let (response, receiver) = sync_channel(1); let (response, receiver) = sync_channel(1);
let exit_requested = request.command == "exit";
let call = IpcCall { request, response }; let call = IpcCall { request, response };
if sender.try_send(call).is_err() { if sender.try_send(call).is_err() {
tracing::warn!("disconnecting IPC client because the request queue is full"); tracing::warn!("disconnecting IPC client because the request queue is full");
@@ -182,10 +193,17 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>
let Ok(response) = receiver.recv() else { let Ok(response) = receiver.recv() else {
return; return;
}; };
let exit_accepted = exit_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) { if let Err(error) = write_frame(&mut stream, &response) {
tracing::debug!(%error, "IPC client disconnected before receiving its response"); tracing::debug!(%error, "IPC client disconnected before receiving its response");
return; return;
} }
if exit_accepted {
// Acknowledge before stopping so feresectl never races process exit.
signal.stop();
signal.wakeup();
return;
}
} }
} }
@@ -203,6 +221,7 @@ impl Ferese {
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> { fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
match command { match command {
"exit" => {} // The IPC worker stops the loop after writing the response.
"focus" => self.focus_direction(direction_arg(args)?), "focus" => self.focus_direction(direction_arg(args)?),
"move" => self.move_direction(direction_arg(args)?), "move" => self.move_direction(direction_arg(args)?),
"resize" => self.resize_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?),
@@ -210,6 +229,7 @@ impl Ferese {
"move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?), "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?),
"toggle-floating" => self.toggle_focused_floating(), "toggle-floating" => self.toggle_focused_floating(),
"toggle-fullscreen" => self.toggle_focused_fullscreen(), "toggle-fullscreen" => self.toggle_focused_fullscreen(),
"toggle-maximized" => self.toggle_focused_maximized(),
"toggle-layout" => self.toggle_layout_mode(), "toggle-layout" => self.toggle_layout_mode(),
"cycle-column-width" => self.cycle_focused_column_width(), "cycle-column-width" => self.cycle_focused_column_width(),
"center-column" => self.center_focused_column(), "center-column" => self.center_focused_column(),
@@ -608,7 +628,9 @@ mod tests {
fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() { fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() {
let (mut client, server) = UnixStream::pair().unwrap(); let (mut client, server) = UnixStream::pair().unwrap();
let (sender, _receiver) = channel::sync_channel(1); let (sender, _receiver) = channel::sync_channel(1);
let worker = thread::spawn(move || serve_connection(server, sender)); let event_loop = EventLoop::<()>::try_new().unwrap();
let signal = event_loop.get_signal();
let worker = thread::spawn(move || serve_connection(server, sender, signal));
let invalid = b"not-json"; let invalid = b"not-json";
client client
+93 -6
View File
@@ -6,7 +6,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use ferese_animation::{AnimatedValue, ClientSize, SpringConfig, WindowGeometry}; use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{ use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
WorkspaceSet, WorkspaceSet,
@@ -118,6 +118,7 @@ pub struct Ferese {
output_identity_ids: HashMap<String, OutputId>, output_identity_ids: HashMap<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
maximized_windows: HashSet<WindowId>,
window_stack: crate::stacking::WindowStack, window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>, pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
@@ -446,6 +447,7 @@ impl Ferese {
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
maximized_windows: HashSet::new(),
window_stack: crate::stacking::WindowStack::default(), window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_shadows: HashMap::new(), window_shadows: HashMap::new(),
@@ -619,6 +621,9 @@ impl Ferese {
let map = layer_map_for_output(output); let map = layer_map_for_output(output);
for requested in layers { for requested in layers {
if *requested == Layer::Top && self.output_has_fullscreen(output) {
continue;
}
for layer in map.layers_on(*requested).rev() { for layer in map.layers_on(*requested).rev() {
let geometry = map.layer_geometry(layer)?; let geometry = map.layer_geometry(layer)?;
let layer_position = output_position - geometry.loc.to_f64(); let layer_position = output_position - geometry.loc.to_f64();
@@ -901,6 +906,7 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.maximized_windows.remove(&id);
self.window_stack.remove(id); self.window_stack.remove(id);
self.window_borders.remove(&id); self.window_borders.remove(&id);
self.window_shadows.remove(&id); self.window_shadows.remove(&id);
@@ -996,8 +1002,12 @@ impl Ferese {
continue; continue;
} }
let is_maximized =
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
let rect = if workspace_fullscreen == Some(*id) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds fullscreen_bounds
} else if is_maximized {
maximized_rect(bounds, self.gap_config.outer)
} else { } else {
match self.workspaces.placement(*id) { match self.workspaces.placement(*id) {
Some(WindowPlacement::Tiled) => { Some(WindowPlacement::Tiled) => {
@@ -1017,7 +1027,7 @@ impl Ferese {
); );
visible.insert(*id); visible.insert(*id);
let scrolling = if !is_fullscreen && !is_floating { let scrolling = if !is_fullscreen && !is_maximized && !is_floating {
viewport_target viewport_target
.zip(viewport_current) .zip(viewport_current)
.map(|(target, current)| (workspace_id, rect.x + target, current)) .map(|(target, current)| (workspace_id, rect.x + target, current))
@@ -1030,6 +1040,7 @@ impl Ferese {
*id, *id,
rect, rect,
is_fullscreen, is_fullscreen,
is_maximized,
is_floating, is_floating,
scrolling, scrolling,
pending_column_width_cycles.contains(id) && viewport_target_changed, pending_column_width_cycles.contains(id) && viewport_target_changed,
@@ -1053,7 +1064,9 @@ impl Ferese {
let mut scrolling_world_x = HashMap::new(); let mut scrolling_world_x = HashMap::new();
placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id));
for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements { for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in
placements
{
// A viewport-coupled width must never override fullscreen/floating geometry. // A viewport-coupled width must never override fullscreen/floating geometry.
if scrolling.is_none() { if scrolling.is_none() {
self.viewport_coupled_widths.remove(&id); self.viewport_coupled_widths.remove(&id);
@@ -1064,7 +1077,14 @@ impl Ferese {
.window_geometry .window_geometry
.entry(id) .entry(id)
.or_insert_with(|| WindowGeometry::new(rect, committed_size)); .or_insert_with(|| WindowGeometry::new(rect, committed_size));
let mut requested_size = geometry.set_presentation_target(rect, is_fullscreen, now); let mode = if is_fullscreen {
PresentationMode::Fullscreen
} else if is_maximized {
PresentationMode::Maximized
} else {
PresentationMode::Normal
};
let mut requested_size = geometry.set_presentation_mode(rect, mode, now);
if !self.animations_enabled { if !self.animations_enabled {
geometry.advance(Duration::ZERO, self.spring_config, false); geometry.advance(Duration::ZERO, self.spring_config, false);
requested_size = geometry.presentation_size_request(now).or(requested_size); requested_size = geometry.presentation_size_request(now).or(requested_size);
@@ -1132,6 +1152,11 @@ impl Ferese {
state.states.unset(xdg_toplevel::State::Fullscreen) state.states.unset(xdg_toplevel::State::Fullscreen)
}; };
let maximized_changed = if is_maximized {
state.states.set(xdg_toplevel::State::Maximized)
} else {
state.states.unset(xdg_toplevel::State::Maximized)
};
let tiled = !is_floating && !is_fullscreen; let tiled = !is_floating && !is_fullscreen;
let tiled_changed = if tiled { let tiled_changed = if tiled {
state.states.set(xdg_toplevel::State::TiledLeft) state.states.set(xdg_toplevel::State::TiledLeft)
@@ -1145,7 +1170,7 @@ impl Ferese {
| state.states.unset(xdg_toplevel::State::TiledBottom) | state.states.unset(xdg_toplevel::State::TiledBottom)
}; };
let decoration_mode = if is_floating && !is_fullscreen { let decoration_mode = if is_floating && !is_fullscreen && !is_maximized {
DecorationMode::ClientSide DecorationMode::ClientSide
} else { } else {
DecorationMode::ServerSide DecorationMode::ServerSide
@@ -1153,7 +1178,7 @@ impl Ferese {
let decoration_changed = state.decoration_mode != Some(decoration_mode); let decoration_changed = state.decoration_mode != Some(decoration_mode);
state.decoration_mode = Some(decoration_mode); state.decoration_mode = Some(decoration_mode);
fullscreen_changed || tiled_changed || decoration_changed fullscreen_changed || maximized_changed || tiled_changed || decoration_changed
}); });
if requested_size.is_some() || state_changed { if requested_size.is_some() || state_changed {
@@ -1600,6 +1625,42 @@ impl Ferese {
self.relayout(); self.relayout();
} }
pub fn toggle_focused_maximized(&mut self) {
let Some(window) = self.focused_window else {
return;
};
// Super+F from true fullscreen enters decorated maximization.
let fullscreen = self
.workspaces
.workspace_for_window(window)
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(window));
let enabled = fullscreen || !self.maximized_windows.contains(&window);
self.set_window_maximized(window, enabled);
}
pub fn set_window_maximized(&mut self, window: WindowId, enabled: bool) {
if self.workspaces.workspace_for_window(window).is_none() {
return;
}
if enabled {
let _ = self.workspaces.set_fullscreen(window, false);
self.maximized_windows.insert(window);
} else {
self.maximized_windows.remove(&window);
}
self.relayout();
}
pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool {
!self.overview.is_presenting()
&& self
.output_id(output)
.and_then(|id| self.output_workspaces.active_workspace(id))
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen.is_some())
}
pub fn close_focused_window(&mut self) { pub fn close_focused_window(&mut self) {
let Some(focused) = self.focused_window else { let Some(focused) = self.focused_window else {
return; return;
@@ -1950,6 +2011,18 @@ impl Ferese {
} }
} }
fn maximized_rect(bounds: Rect, gap: f64) -> Rect {
let gap = gap
.max(0.0)
.min(((bounds.width.min(bounds.height) - 1.0) / 2.0).max(0.0));
Rect::new(
bounds.x + gap,
bounds.y + gap,
(bounds.width - 2.0 * gap).max(1.0),
(bounds.height - 2.0 * gap).max(1.0),
)
}
fn centered_floating_rect(bounds: Rect) -> Rect { fn centered_floating_rect(bounds: Rect) -> Rect {
let width = (bounds.width * 0.6).max(1.0); let width = (bounds.width * 0.6).max(1.0);
let height = (bounds.height * 0.6).max(1.0); let height = (bounds.height * 0.6).max(1.0);
@@ -2017,6 +2090,20 @@ impl ClientData for ClientState {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn maximization_respects_layer_exclusion_and_outer_gaps() {
assert_eq!(
maximized_rect(Rect::new(0.0, 48.0, 1280.0, 752.0), 10.0),
Rect::new(10.0, 58.0, 1260.0, 732.0)
);
assert_eq!(
maximized_rect(Rect::new(1600.0, 36.0, 1280.0, 764.0), 6.0),
Rect::new(1606.0, 42.0, 1268.0, 752.0)
);
let tiny = maximized_rect(Rect::new(0.0, 0.0, 5.0, 3.0), 10.0);
assert!(tiny.width >= 1.0 && tiny.height >= 1.0);
}
#[test] #[test]
fn transient_geometry_is_centered_and_bounded_by_its_parent() { fn transient_geometry_is_centered_and_bounded_by_its_parent() {
let parent = Rect::new(100.0, 50.0, 1_000.0, 800.0); let parent = Rect::new(100.0, 50.0, 1_000.0, 800.0);
+29 -62
View File
@@ -40,9 +40,9 @@ use smithay::{
}, },
input::pointer::{CursorImageStatus, CursorImageSurfaceData}, input::pointer::{CursorImageStatus, CursorImageSurfaceData},
output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, output::{Mode, Output, PhysicalProperties, Scale, Subpixel},
reexports::calloop::EventLoop,
reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind, reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind,
reexports::wayland_server::Resource, reexports::wayland_server::Resource,
reexports::calloop::EventLoop,
utils::{ utils::{
Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size, Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size,
Transform, Transform,
@@ -871,12 +871,12 @@ fn output_elements(
} else { } else {
Vec::new() Vec::new()
}; };
elements.extend(layer_elements( let upper_layers: &[Layer] = if state.output_has_fullscreen(output) {
state, &[Layer::Overlay]
renderer, } else {
output, &[Layer::Overlay, Layer::Top]
&[Layer::Overlay, Layer::Top], };
)); elements.extend(layer_elements(state, renderer, output, upper_layers));
let windows = state let windows = state
.space .space
.elements() .elements()
@@ -1452,30 +1452,26 @@ fn layer_elements(
let mut elements = Vec::new(); let mut elements = Vec::new();
for (geometry, layer) in layers { for (geometry, layer) in layers {
let target = geometry.to_physical_precise_round(scale); elements.extend(
let surface_elements = AsRenderElements::<GlesRenderer>::render_elements::< AsRenderElements::<GlesRenderer>::render_elements::<
WaylandSurfaceRenderElement<GlesRenderer>, WaylandSurfaceRenderElement<GlesRenderer>,
>(&layer, renderer, target.loc, scale.into(), 1.0); >(
let source_bounds = surface_elements &layer,
.iter() renderer,
.map(|element| element.geometry(scale.into())) geometry.loc.to_physical_precise_round(scale),
.reduce(Rectangle::merge); scale.into(),
let content_scale = source_bounds 1.0,
.map(|source| layer_content_scale(source.size, target.size)) )
.unwrap_or_else(|| RenderScale::from(1.0)); .into_iter()
let scale_origin = source_bounds.map_or(target.loc, |source| source.loc); .filter_map(|element| {
let crop = target.intersection(output_crop).unwrap_or_default(); let origin = Point::<i32, Physical>::default();
let element = RescaleRenderElement::from_element(element, origin, 1.0);
let element =
RelocateRenderElement::from_element(element, origin, Relocate::Relative);
elements.extend(surface_elements.into_iter().filter_map(|element| { CropRenderElement::from_element(element, scale, output_crop).map(Into::into)
let element = RescaleRenderElement::from_element(element, scale_origin, content_scale); }),
let element = RelocateRenderElement::from_element( );
element,
Point::<i32, Physical>::default(),
Relocate::Relative,
);
CropRenderElement::from_element(element, scale, crop).map(Into::into)
}));
if let Some(material) = material_element(state, renderer, output, &layer, geometry) { if let Some(material) = material_element(state, renderer, output, &layer, geometry) {
elements.push(material); elements.push(material);
@@ -1485,20 +1481,6 @@ fn layer_elements(
elements elements
} }
fn layer_content_scale(
source: Size<i32, Physical>,
target: Size<i32, Physical>,
) -> RenderScale<f64> {
if source.w <= 0 || source.h <= 0 || target.w <= 0 || target.h <= 0 {
return RenderScale::from(1.0);
}
RenderScale::from((
f64::from(target.w) / f64::from(source.w),
f64::from(target.h) / f64::from(source.h),
))
}
fn material_element( fn material_element(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
@@ -1519,7 +1501,8 @@ fn material_element(
expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical); expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical);
let sample_physical = sample_geometry.to_physical_precise_round(scale); let sample_physical = sample_geometry.to_physical_precise_round(scale);
let transform = output.current_transform().invert(); let transform = output.current_transform().invert();
let tint = [0.067, 0.094, 0.129, material.opacity]; let [red, green, blue, _] = state.theme_settings.surface_base_color.0;
let tint = [red, green, blue, material.opacity];
let parameters = MaterialParameters { let parameters = MaterialParameters {
geometry, geometry,
tint, tint,
@@ -1831,8 +1814,7 @@ mod tests {
use super::{ use super::{
color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect, color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect,
layer_content_scale, normalized_scale, rounded_visual_rect, scaled_visual_rect, normalized_scale, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
shadow_bounds,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -1885,21 +1867,6 @@ mod tests {
assert_eq!(normalized_scale(f64::INFINITY), 1.0); assert_eq!(normalized_scale(f64::INFINITY), 1.0);
} }
#[test]
fn layer_content_tracks_new_allocation_before_the_client_repaints() {
let scale = layer_content_scale((1_000, 40).into(), (1_600, 40).into());
assert_eq!(scale, Scale::from((1.6, 1.0)));
}
#[test]
fn layer_content_scale_ignores_empty_allocations() {
assert_eq!(
layer_content_scale((0, 40).into(), (1_600, 40).into()),
Scale::from(1.0)
);
}
#[test] #[test]
fn clip_rect_matches_smithay_gles_projection() { fn clip_rect_matches_smithay_gles_projection() {
let geometry = Rectangle::<i32, Physical>::new((10, 5).into(), (30, 40).into()); let geometry = Rectangle::<i32, Physical>::new((10, 5).into(), (30, 40).into());
+98 -9
View File
@@ -267,6 +267,13 @@ impl ClientGeometry {
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PresentationMode {
Normal,
Maximized,
Fullscreen,
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowGeometry { pub struct WindowGeometry {
pub logical: Rect, pub logical: Rect,
@@ -274,11 +281,11 @@ pub struct WindowGeometry {
pub client: ClientGeometry, pub client: ClientGeometry,
pub decorations: f64, pub decorations: f64,
pub presentation_changed: bool, pub presentation_changed: bool,
fullscreen: bool, mode: PresentationMode,
zoom: Option<ZoomTransition>, zoom: Option<ZoomTransition>,
} }
/// Fullscreen geometry and decorations use one clock, including on reversal. /// Maximized/fullscreen geometry and decorations share a clock, including on reversal.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
struct ZoomTransition { struct ZoomTransition {
from: Rect, from: Rect,
@@ -300,7 +307,7 @@ impl WindowGeometry {
}, },
decorations: 1.0, decorations: 1.0,
presentation_changed: true, presentation_changed: true,
fullscreen: false, mode: PresentationMode::Normal,
zoom: None, zoom: None,
} }
} }
@@ -319,9 +326,26 @@ impl WindowGeometry {
rect: Rect, rect: Rect,
fullscreen: bool, fullscreen: bool,
now: Duration, now: Duration,
) -> Option<ClientSize> {
self.set_presentation_mode(
rect,
if fullscreen {
PresentationMode::Fullscreen
} else {
PresentationMode::Normal
},
now,
)
}
pub fn set_presentation_mode(
&mut self,
rect: Rect,
mode: PresentationMode,
now: Duration,
) -> Option<ClientSize> { ) -> Option<ClientSize> {
let rect = normalized_rect(rect); let rect = normalized_rect(rect);
if self.fullscreen != fullscreen || (self.zoom.is_some() && self.logical != rect) { if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
let mut progress = AnimatedValue::new(0.0); let mut progress = AnimatedValue::new(0.0);
progress.set_target(1.0); progress.set_target(1.0);
let from = self.visual.current; let from = self.visual.current;
@@ -350,7 +374,7 @@ impl WindowGeometry {
progress, progress,
}); });
} }
self.fullscreen = fullscreen; self.mode = mode;
self.logical = rect; self.logical = rect;
self.visual.set_target(rect); self.visual.set_target(rect);
self.presentation_size_request(now) self.presentation_size_request(now)
@@ -368,14 +392,14 @@ impl WindowGeometry {
} }
pub fn is_fullscreen(&self) -> bool { pub fn is_fullscreen(&self) -> bool {
self.fullscreen self.mode == PresentationMode::Fullscreen
} }
pub fn follow_pointer(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> { pub fn follow_pointer(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> {
self.zoom = None; self.zoom = None;
let requested_size = self.set_logical_target(rect, now); let requested_size = self.set_logical_target(rect, now);
self.visual.snap(); self.visual.snap();
self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 };
self.presentation_changed = true; self.presentation_changed = true;
requested_size requested_size
} }
@@ -400,7 +424,7 @@ impl WindowGeometry {
) -> bool { ) -> bool {
if !animations_enabled { if !animations_enabled {
self.visual.snap(); self.visual.snap();
self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 };
self.zoom = None; self.zoom = None;
return false; return false;
} }
@@ -437,7 +461,11 @@ impl WindowGeometry {
}; };
self.decorations = lerp( self.decorations = lerp(
zoom.decorations_from, zoom.decorations_from,
if self.fullscreen { 0.0 } else { 1.0 }, if self.mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
},
); );
if !active { if !active {
self.zoom = None; self.zoom = None;
@@ -544,6 +572,67 @@ fn finite_or(value: f64, fallback: f64) -> f64 {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn maximized_zoom_keeps_decorations_and_restores_through_fullscreen() {
let normal = Rect::new(800.0, 50.0, 600.0, 700.0);
let maximized = Rect::new(10.0, 50.0, 1580.0, 740.0);
let fullscreen = Rect::new(0.0, 0.0, 1600.0, 800.0);
let mut geometry = WindowGeometry::new(normal, None);
for (mode, rect) in [
(PresentationMode::Maximized, maximized),
(PresentationMode::Fullscreen, fullscreen),
(PresentationMode::Maximized, maximized),
(PresentationMode::Normal, normal),
] {
let before = geometry.visual.current;
let decorations = geometry.decorations;
geometry.set_presentation_mode(rect, mode, Duration::ZERO);
assert_eq!(geometry.visual.current, before);
assert_eq!(geometry.decorations, decorations);
for _ in 0..1000 {
if !geometry.advance(Duration::from_millis(8), SpringConfig::default(), true) {
break;
}
if mode != PresentationMode::Fullscreen && decorations == 1.0 {
assert_eq!(geometry.decorations, 1.0);
}
}
assert_eq!(geometry.visual.current, rect);
assert_eq!(
geometry.decorations,
if mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
}
);
assert!(!geometry.is_zooming());
assert_eq!(
geometry.is_fullscreen(),
mode == PresentationMode::Fullscreen
);
}
}
#[test]
fn reversing_maximization_preserves_visual_position_and_decoration() {
let normal = Rect::new(800.0, 50.0, 600.0, 700.0);
let mut geometry = WindowGeometry::new(normal, None);
geometry.set_presentation_mode(
Rect::new(10.0, 50.0, 1580.0, 740.0),
PresentationMode::Maximized,
Duration::ZERO,
);
geometry.advance(Duration::from_millis(80), SpringConfig::default(), true);
let before = geometry.visual.current;
geometry.set_presentation_mode(normal, PresentationMode::Normal, Duration::from_millis(80));
assert_eq!(geometry.visual.current, before);
assert_eq!(geometry.decorations, 1.0);
geometry.advance(Duration::ZERO, SpringConfig::default(), false);
assert_eq!(geometry.visual.current, normal);
assert_eq!(geometry.decorations, 1.0);
}
#[test] #[test]
fn zoom_finishes_without_a_visible_final_edge_jump() { fn zoom_finishes_without_a_visible_final_edge_jump() {
let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0); let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0);
+1 -1
View File
@@ -1031,7 +1031,7 @@ mod tests {
assert_eq!( assert_eq!(
first.geometry[&WindowId(1)], first.geometry[&WindowId(1)],
Rect::new(10.0, 10.0, 980.0, 780.0) Rect::new(4.0, 4.0, 992.0, 792.0)
); );
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
+5 -5
View File
@@ -119,7 +119,7 @@ impl Default for GapConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
inner: 10.0, inner: 10.0,
outer: 10.0, outer: 4.0,
smart: false, smart: false,
} }
} }
@@ -1178,8 +1178,8 @@ mod tests {
let first = geometry[&WindowId(1)]; let first = geometry[&WindowId(1)];
let second = geometry[&WindowId(2)]; let second = geometry[&WindowId(2)];
assert_eq!(first.x, 10.0); assert_eq!(first.x, 4.0);
assert_eq!(second.x + second.width, 90.0); assert_eq!(second.x + second.width, 96.0);
assert_eq!(second.x - (first.x + first.width), 10.0); assert_eq!(second.x - (first.x + first.width), 10.0);
} }
@@ -1213,7 +1213,7 @@ mod tests {
.geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default()) .geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default())
.unwrap(); .unwrap();
assert_eq!(geometry[&WindowId(1)], Rect::new(10.0, 10.0, 80.0, 60.0)); assert_eq!(geometry[&WindowId(1)], Rect::new(4.0, 4.0, 92.0, 72.0));
} }
#[test] #[test]
@@ -1571,7 +1571,7 @@ mod tests {
assert_eq!( assert_eq!(
result.geometry[&WindowId(1)], result.geometry[&WindowId(1)],
Rect::new(10.0, 10.0, 60.0, 40.0) Rect::new(4.0, 4.0, 60.0, 40.0)
); );
assert_eq!(result.warnings.len(), 2); assert_eq!(result.warnings.len(), 2);
} }
+4 -4
View File
@@ -240,10 +240,10 @@ base_unit = 8.0
window_radius = 14.0 window_radius = 14.0
top_bar_height = 32.0 top_bar_height = 38.0
top_bar_margin_top = 4.0 top_bar_margin_top = 4.0
top_bar_margin_horizontal = 10.0 top_bar_margin_horizontal = 10.0
top_bar_radius = 16.0 top_bar_radius = 19.0
launcher_width = 560.0 launcher_width = 560.0
launcher_radius = 20.0 launcher_radius = 20.0
@@ -724,8 +724,8 @@ noise = 0.012
[theme.geometry] [theme.geometry]
window_radius = 14.0 window_radius = 14.0
top_bar_height = 32.0 top_bar_height = 38.0
top_bar_radius = 16.0 top_bar_radius = 19.0
launcher_width = 560.0 launcher_width = 560.0
launcher_radius = 20.0 launcher_radius = 20.0
+1 -1
View File
@@ -1127,7 +1127,7 @@ mass = 1.0
[layout] [layout]
mode = "scrolling" mode = "scrolling"
inner_gap = 10.0 inner_gap = 10.0
outer_gap = 10.0 outer_gap = 4.0
smart_gaps = false smart_gaps = false
[scrolling] [scrolling]
+53
View File
@@ -0,0 +1,53 @@
# Fedora / SDDM installation
Build and test in the checkout:
```sh
cargo test -p ferese -p feresectl -p ferese-animation -p ferese-core -p ferese-layout -p ferese-shell --locked
cargo build --release --locked -p ferese -p ferese-shell -p feresectl
sudo bash scripts/install-session.sh YOUR_RELEASE_ID
```
The installer adds **Ferese** to SDDM without changing Plasma or
the default session. It installs immutable, versioned directories in
`/usr/local/lib/ferese/releases/` and launchers in `/usr/local/bin/`.
`current` selects the installed build; `previous` records the prior selection
on upgrades. Installation refuses to replace unmanaged launchers/session files.
Keep your existing `~/.config/ferese/config.toml`. For a new configuration, copy
`/usr/local/lib/ferese/current/config.example.toml` there. The existing compositor
defaults handle connected monitors automatically; add output profiles only after
checking `feresectl get-outputs` on the actual DRM session.
Use `ferese-session --nested` for an installed-build preview. Log out of Plasma
and select Ferese in SDDM for real hardware use. `ferese-session` can also run
from a spare logged-in TTY. It must run as your normal user, never as root.
Foot and `dbus-run-session` must be installed.
Session logs are written under `${XDG_STATE_HOME:-~/.local/state}/ferese/`.
The launcher sets Wayland desktop variables, starts D-Bus if none is available,
and grants shell capabilities only to the compositor-launched shell process.
It does not enable public screencopy, privileged input methods, or shortcut
inhibition by default.
## Logout and recovery
Save your work, then press **Super+Shift+E** or run `feresectl exit` to log out.
Ferese stops its event loop and terminates its shell child during shutdown.
This is immediate logout, without a save-confirmation dialog.
If the compositor becomes unresponsive, switch to another TTY using
Ctrl+Alt+F1–F12 and terminate that specific Ferese process. Select Plasma on the
next login. Do not restart SDDM while a desktop contains unsaved work.
For a binary rollback, inspect `/usr/local/lib/ferese/previous` and select that
release with the `current` symlink while Ferese is stopped. Existing releases
are retained. To hide the login option, move
`/usr/share/wayland-sessions/ferese.desktop` out of that directory.
## First hardware session
Verify terminal/editor/browser, clipboard, fullscreen, fractional scaling,
monitor disconnect/reconnect, VT switching away/back, and logout. Suspend/resume
and session locking are not certified by the nested smoke test. Keep Plasma
available until real development sessions and these checks pass.
+61
View File
@@ -0,0 +1,61 @@
# Nested live-resize regression check
The shell enables libcosmic's `wgpu` feature. Without it, iced uses tiny-skia
to resample and rasterize the wallpaper on the CPU. In an unoptimized build,
this blocks the UI/render thread shared with the top bar for seconds. Prompt
layer configures, buffer stretching, and compositor vsync cannot fix that
client-side bottleneck. Software rendering remains available as a fallback.
## Reproduce and compare
```sh
cargo build --locked -p ferese -p ferese-shell
WAYLAND_DEBUG=1 FERESE_TRACE_PERFORMANCE=1 \
target/debug/ferese --backend nested --grant-effects --grant-shell-control -- \
target/debug/ferese-shell 2>/tmp/ferese-resize.log
```
Use a configured wallpaper. Drag the host window's edges in both directions,
then maximize/restore it repeatedly. Check that the wallpaper and bar follow
the window, including at fractional host scaling. Repeat with
`ICED_BACKEND=wgpu` to require GPU rendering (rather than silently accepting
fallback), and with `ICED_BACKEND=tiny-skia` to compare software rendering.
Use a release build if software fallback is needed for regular use.
Trace the chain, not just the compositor's frame rate:
1. Host `xdg_toplevel.configure` becomes Winit `Resized`.
2. Ferese updates the output, arranges layers, and sends
`zwlr_layer_surface_v1.configure` for the new logical dimensions.
3. The shell acknowledges configure and eventually attaches/commits a new
buffer. A fast acknowledgement does **not** imply a finished render.
4. Committed buffers change surface damage; Ferese renders the damaged output
and submits through EGL to the host compositor.
`FERESE_TRACE_PERFORMANCE` measures Ferese's rendering/submission, not shell
rasterization. Wayland traces distinguish software SHM buffers from GPU
buffers (Mesa Vulkan on the tested system).
## Measured validation (KDE Wayland, 175% scale)
With temporary instrumentation, 24 host maximize/restore requests spaced
250 ms apart generated 26 Winit resize events including initialization. The
same debug binaries were tested with each renderer forced:
| Measurement | tiny-skia | wgpu |
| --- | ---: | ---: |
| Median configure acknowledgement | 1.35 ms | 2.27 ms |
| Median Ferese render + submit | 0.82 ms | 1.10 ms |
| Wallpaper buffer interval | 5919 ms (only two buffers) | 9.85 ms median |
| Bar buffers observed | 1 | 996 |
The CPU capture ran for 14 seconds and the GPU capture for 18 seconds;
counts are diagnostic, not equal-duration throughput benchmarks. GPU maximum
buffer intervals were about 200 ms for wallpaper and 158 ms for the bar, so
the median does not imply every frame met a refresh deadline. These figures
include protocol tracing overhead. The temporary resize/timing hooks were
removed after testing; no synthetic resizing runs in production.
The fix does not alter output coordinates, layer exclusion, damage tracking,
frame scheduling, or EGL swap settings. It also applies to the same shell
running under DRM, though this validation used the nested backend.
+9 -4
View File
@@ -47,9 +47,9 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
.map_err(|_| format!("{} requires a positive workspace index", command))?; .map_err(|_| format!("{} requires a positive workspace index", command))?;
json!({ "index": index }) json!({ "index": index })
} }
"toggle-floating" | "toggle-fullscreen" | "toggle-layout" | "cycle-column-width" "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
| "center-column" | "consume" | "expel" | "close" | "get-focused-window" | "cycle-column-width" | "center-column" | "consume" | "expel" | "close"
| "get-workspaces" | "get-outputs" => { | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => {
if !positional.is_empty() { if !positional.is_empty() {
return Err(format!("{command} does not accept arguments")); return Err(format!("{command} does not accept arguments"));
} }
@@ -78,7 +78,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-fullscreen|toggle-layout>\n feresectl <cycle-column-width|center-column|consume|expel|close>\n feresectl <get-focused-window|get-workspaces|get-outputs>".to_owned() "usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
@@ -107,6 +107,11 @@ mod tests {
#[test] #[test]
fn rejects_unknown_or_missing_arguments() { fn rejects_unknown_or_missing_arguments() {
assert_eq!(
parse_args(["exit".to_owned()]).unwrap(),
("exit".to_owned(), json!({}))
);
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
assert!(parse_args(["focus".to_owned()]).is_err()); assert!(parse_args(["focus".to_owned()]).is_err());
assert!(parse_args(["reload-config".to_owned()]).is_err()); assert!(parse_args(["reload-config".to_owned()]).is_err());
} }
+14
View File
@@ -0,0 +1,14 @@
# User configuration: ~/.config/ferese/config.toml
# Unspecified monitors use automatic placement and preferred modes.
[commands]
terminal = ["foot"]
[input]
xkb_layout = "us"
focus_follows_mouse = false
[layout]
smart_gaps = false
# Super+Enter opens Foot. Super+Shift+E logs out.
# See README.md for monitor profiles and additional bindings.
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
backend=drm
if [[ ${1:-} == --nested ]]; then
backend=nested
shift
fi
if (($#)); then
echo 'usage: ferese-session [--nested]' >&2
exit 2
fi
: "${XDG_RUNTIME_DIR:?A login session with XDG_RUNTIME_DIR is required}"
# Resolve once, so upgrading current cannot mix compositor and shell versions.
release_dir=$(dirname -- "$(readlink -f -- "$0")")
state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/ferese
mkdir -p -- "$state_dir"
log_file=$state_dir/session-$(date -u +%Y%m%dT%H%M%SZ)-$$.log
echo "Ferese session log: $log_file" >&2
exec >>"$log_file" 2>&1
export XDG_CURRENT_DESKTOP=Ferese
export XDG_SESSION_DESKTOP=ferese
export XDG_SESSION_TYPE=wayland
export MOZ_ENABLE_WAYLAND=1
export QT_QPA_PLATFORM=wayland
export PATH="$release_dir:$PATH"
unset WAYLAND_SOCKET
if [[ $backend == drm ]]; then
unset DISPLAY WAYLAND_DISPLAY
fi
command=("$release_dir/ferese" --backend "$backend" --grant-effects
--grant-shell-control -- "$release_dir/ferese-shell")
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
exec dbus-run-session -- "${command[@]}"
else
exec "${command[@]}"
fi
+6
View File
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=Ferese
Comment=Animation-first scrolling Wayland desktop
Exec=/usr/local/bin/ferese-session
TryExec=/usr/local/bin/ferese-session
Type=Application
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Install already-built binaries. Run from any directory with sudo.
set -euo pipefail
repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
release_id=${1:?usage: install-session.sh RELEASE_ID}
[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2
[[ $EUID == 0 ]] || { echo 'Run this installer with sudo.' >&2; exit 1; }
install_root=/usr/local/lib/ferese
release_dir=$install_root/releases/$release_id
session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations.
for name in ferese ferese-shell feresectl ferese-session; do
link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
echo "Unmanaged path exists: $link" >&2; exit 1;
}
fi
done
for link in current previous; do
if [[ -e $install_root/$link || -L $install_root/$link ]]; then
[[ -L $install_root/$link && $(readlink -- "$install_root/$link") == releases/* ]] || {
echo "Unmanaged path exists: $install_root/$link" >&2; exit 1;
}
fi
done
if [[ -e $session_entry ]]; then
cmp -s -- "$repo_dir/packaging/ferese.desktop" "$session_entry" || {
echo "Different session entry exists: $session_entry" >&2; exit 1;
}
fi
for name in ferese ferese-shell feresectl; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions
for name in ferese ferese-shell feresectl; do
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml"
if [[ -L $install_root/current ]]; then
ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous"
fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
for name in ferese ferese-shell feresectl ferese-session; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
echo "Installed Ferese $release_id. Select Ferese in SDDM."
+3 -1
View File
@@ -8,7 +8,9 @@ rust-version = "1.93"
[dependencies] [dependencies]
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
jiff.workspace = true jiff.workspace = true
libcosmic.workspace = true # Wallpaper resampling must not block the bar on iced's shared UI thread.
# Keep tiny-skia available as libcosmic's fallback, but prefer GPU rendering.
libcosmic = { workspace = true, features = ["wgpu"] }
serde.workspace = true serde.workspace = true
toml.workspace = true toml.workspace = true
wayland-client.workspace = true wayland-client.workspace = true
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<rect x="1.4" y="5.5" width="15.4" height="9" rx="2.4" stroke="currentColor" stroke-width="1.8"/>
<path d="M18 8.1c.6.2 1 .8 1 1.5v.8c0 .7-.4 1.3-1 1.5V8.1Z" fill="currentColor"/>
<rect x="3.8" y="7.9" width="10.6" height="4.2" rx="1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<rect x="1.5" y="2" width="7.2" height="6.2" rx="1.7"/>
<rect x="11.3" y="2" width="7.2" height="9.2" rx="1.7"/>
<rect x="1.5" y="10.8" width="7.2" height="7.2" rx="1.7"/>
<rect x="11.3" y="13.8" width="7.2" height="4.2" rx="1.7"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M2 8.1v3.8c0 .8.6 1.4 1.4 1.4h2.1l4 3.3c.6.5 1.5.1 1.5-.7V4.1c0-.8-.9-1.2-1.5-.7l-4 3.3H3.4C2.6 6.7 2 7.3 2 8.1Z" fill="currentColor" stroke="none"/>
<path d="M13.4 7.2a4.1 4.1 0 0 1 0 5.6"/>
<path d="M16 4.8a7.4 7.4 0 0 1 0 10.4"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.15" stroke-linecap="round">
<path d="M2.1 7.2a12.1 12.1 0 0 1 15.8 0"/>
<path d="M5.2 10.5a7.4 7.4 0 0 1 9.6 0"/>
<path d="M8.3 13.7a2.7 2.7 0 0 1 3.4 0"/>
<circle cx="10" cy="16.6" r="1.25" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

+247 -4
View File
@@ -10,6 +10,48 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) font_family: Option<String>, pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig, pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme {
pub(crate) surface_base: [u8; 4],
pub(crate) text_primary: [u8; 4],
pub(crate) text_muted: [u8; 4],
pub(crate) accent: [u8; 4],
pub(crate) border: [u8; 4],
pub(crate) shadow: [u8; 4],
pub(crate) bar_height: f32,
pub(crate) bar_margin_top: i32,
pub(crate) bar_margin_horizontal: i32,
pub(crate) bar_radius: f32,
pub(crate) panel_padding: f32,
pub(crate) control_gap: f32,
pub(crate) shadow_offset_y: f32,
pub(crate) shadow_blur: f32,
pub(crate) shadow_opacity: f32,
}
impl Default for ShellTheme {
fn default() -> Self {
Self {
surface_base: [17, 24, 33, 255],
text_primary: [244, 247, 251, 255],
text_muted: [127, 138, 152, 255],
accent: [91, 140, 255, 255],
border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85],
bar_height: 38.0,
bar_margin_top: 4,
bar_margin_horizontal: 10,
bar_radius: 19.0,
panel_padding: 12.0,
control_gap: 8.0,
shadow_offset_y: 4.0,
shadow_blur: 18.0,
shadow_opacity: 0.20,
}
}
} }
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
@@ -35,6 +77,12 @@ struct FereseConfig {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeConfig { struct ThemeConfig {
#[serde(default)]
colors: ThemeColorsConfig,
#[serde(default)]
geometry: ThemeGeometryConfig,
#[serde(default)]
shadow: ThemeShadowConfig,
#[serde(default)] #[serde(default)]
typography: TypographyConfig, typography: TypographyConfig,
#[serde(default)] #[serde(default)]
@@ -46,6 +94,90 @@ struct TypographyConfig {
font_family: Option<String>, font_family: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct ThemeColorsConfig {
#[serde(default = "default_surface_base")]
surface_base: String,
#[serde(default = "default_text_primary")]
text_primary: String,
#[serde(default = "default_text_muted")]
text_muted: String,
#[serde(default = "default_accent")]
accent: String,
#[serde(default = "default_border")]
border: String,
#[serde(default = "default_shadow")]
shadow: String,
}
impl Default for ThemeColorsConfig {
fn default() -> Self {
Self {
surface_base: default_surface_base(),
text_primary: default_text_primary(),
text_muted: default_text_muted(),
accent: default_accent(),
border: default_border(),
shadow: default_shadow(),
}
}
}
#[derive(Debug, Deserialize)]
struct ThemeGeometryConfig {
#[serde(default = "default_bar_height")]
top_bar_height: f32,
#[serde(default = "default_bar_margin_top")]
top_bar_margin_top: i32,
#[serde(default = "default_bar_margin_horizontal")]
top_bar_margin_horizontal: i32,
#[serde(default = "default_bar_radius")]
top_bar_radius: f32,
#[serde(default = "default_panel_padding")]
panel_padding: f32,
#[serde(default = "default_control_gap")]
control_gap: f32,
}
impl Default for ThemeGeometryConfig {
fn default() -> Self {
Self {
top_bar_height: default_bar_height(),
top_bar_margin_top: default_bar_margin_top(),
top_bar_margin_horizontal: default_bar_margin_horizontal(),
top_bar_radius: default_bar_radius(),
panel_padding: default_panel_padding(),
control_gap: default_control_gap(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct ThemeShadowConfig {
#[serde(default)]
soft: SoftShadowConfig,
}
#[derive(Debug, Deserialize)]
struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")]
offset_y: f32,
#[serde(default = "default_shadow_blur")]
blur: f32,
#[serde(default = "default_shadow_opacity")]
opacity: f32,
}
impl Default for SoftShadowConfig {
fn default() -> Self {
Self {
offset_y: default_shadow_offset_y(),
blur: default_shadow_blur(),
opacity: default_shadow_opacity(),
}
}
}
pub(crate) fn load() -> ShellConfig { pub(crate) fn load() -> ShellConfig {
let Some(path) = config_path() else { let Some(path) = config_path() else {
return ShellConfig::default(); return ShellConfig::default();
@@ -55,10 +187,15 @@ pub(crate) fn load() -> ShellConfig {
}; };
match toml::from_str::<FereseConfig>(&source) { match toml::from_str::<FereseConfig>(&source) {
Ok(config) => ShellConfig { Ok(config) => {
font_family: config.theme.typography.font_family, let theme = shell_theme(&config.theme);
wallpaper: config.theme.background,
}, ShellConfig {
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
}
}
Err(error) => { Err(error) => {
eprintln!("ferese-shell: failed to read {}: {error}", path.display()); eprintln!("ferese-shell: failed to read {}: {error}", path.display());
ShellConfig::default() ShellConfig::default()
@@ -66,6 +203,112 @@ pub(crate) fn load() -> ShellConfig {
} }
} }
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
ShellTheme {
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
bar_margin_top: theme.geometry.top_bar_margin_top.max(0),
bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0),
bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius),
panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding),
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: theme.shadow.soft.opacity.clamp(0.0, 1.0),
}
}
fn parse_color(value: &str) -> Option<[u8; 4]> {
let value = value.strip_prefix('#')?;
if value.len() != 6 && value.len() != 8 {
return None;
}
let red = u8::from_str_radix(&value[0..2], 16).ok()?;
let green = u8::from_str_radix(&value[2..4], 16).ok()?;
let blue = u8::from_str_radix(&value[4..6], 16).ok()?;
let alpha = if value.len() == 8 {
u8::from_str_radix(&value[6..8], 16).ok()?
} else {
255
};
Some([red, green, blue, alpha])
}
fn positive_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() && value > 0.0 {
value
} else {
fallback
}
}
fn nonnegative_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() && value >= 0.0 {
value
} else {
fallback
}
}
fn finite_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() { value } else { fallback }
}
fn default_surface_base() -> String {
"#111821".to_owned()
}
fn default_text_primary() -> String {
"#F4F7FB".to_owned()
}
fn default_text_muted() -> String {
"#7F8A98".to_owned()
}
fn default_accent() -> String {
"#5B8CFF".to_owned()
}
fn default_border() -> String {
"#FFFFFF18".to_owned()
}
fn default_shadow() -> String {
"#00000055".to_owned()
}
const fn default_bar_height() -> f32 {
38.0
}
const fn default_bar_margin_top() -> i32 {
4
}
const fn default_bar_margin_horizontal() -> i32 {
10
}
const fn default_bar_radius() -> f32 {
19.0
}
const fn default_panel_padding() -> f32 {
12.0
}
const fn default_control_gap() -> f32 {
8.0
}
const fn default_shadow_offset_y() -> f32 {
4.0
}
const fn default_shadow_blur() -> f32 {
18.0
}
const fn default_shadow_opacity() -> f32 {
0.20
}
pub(crate) const fn default_background() -> [u8; 3] { pub(crate) const fn default_background() -> [u8; 3] {
DEFAULT_BACKGROUND DEFAULT_BACKGROUND
} }
+22 -7
View File
@@ -45,6 +45,7 @@ pub(crate) struct WindowSnapshot {
pub(crate) app_id: String, pub(crate) app_id: String,
pub(crate) title: String, pub(crate) title: String,
pub(crate) focused: bool, pub(crate) focused: bool,
pub(crate) fullscreen: bool,
} }
pub(crate) struct ShellControl { pub(crate) struct ShellControl {
@@ -56,11 +57,13 @@ pub(crate) struct ShellControl {
pub(crate) struct ControlPoll { pub(crate) struct ControlPoll {
pub(crate) snapshot: Option<ShellSnapshot>, pub(crate) snapshot: Option<ShellSnapshot>,
pub(crate) overview_active: Option<bool>,
pub(crate) disconnected: bool, pub(crate) disconnected: bool,
} }
enum ControlUpdate { enum ControlUpdate {
Snapshot(ShellSnapshot), Snapshot(ShellSnapshot),
OverviewState(bool),
Disconnected, Disconnected,
} }
@@ -93,12 +96,14 @@ impl ShellControl {
pub(crate) fn poll(&self) -> ControlPoll { pub(crate) fn poll(&self) -> ControlPoll {
let mut poll = ControlPoll { let mut poll = ControlPoll {
snapshot: None, snapshot: None,
overview_active: None,
disconnected: false, disconnected: false,
}; };
loop { loop {
match self.updates.try_recv() { match self.updates.try_recv() {
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot), Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => { Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
poll.disconnected = true; poll.disconnected = true;
return poll; return poll;
@@ -115,8 +120,12 @@ impl ShellControl {
let _ = self.connection.flush(); let _ = self.connection.flush();
} }
pub(crate) fn enter_overview(&self) { pub(crate) fn set_overview_active(&self, active: bool) {
self.shell.enter_overview(); if active {
self.shell.enter_overview();
} else {
self.shell.exit_overview();
}
let _ = self.connection.flush(); let _ = self.connection.flush();
} }
} }
@@ -214,17 +223,20 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
title, title,
state: window_state, state: window_state,
} => { } => {
let focused = matches!( let (focused, fullscreen) = match window_state {
window_state, WEnum::Value(flags) => (
WEnum::Value(flags) flags.contains(ferese_shell_v1::WindowState::Focused),
if flags.contains(ferese_shell_v1::WindowState::Focused) flags.contains(ferese_shell_v1::WindowState::Fullscreen),
); ),
WEnum::Unknown(_) => (false, false),
};
state.pending.windows.push(WindowSnapshot { state.pending.windows.push(WindowSnapshot {
workspace: join_id(workspace_hi, workspace_lo), workspace: join_id(workspace_hi, workspace_lo),
app_id, app_id,
title, title,
focused, focused,
fullscreen,
}); });
} }
ferese_shell_v1::Event::SnapshotEnd { serial } ferese_shell_v1::Event::SnapshotEnd { serial }
@@ -234,6 +246,9 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
.sender .sender
.send(ControlUpdate::Snapshot(state.pending.clone())); .send(ControlUpdate::Snapshot(state.pending.clone()));
} }
ferese_shell_v1::Event::OverviewState { active } => {
let _ = state.sender.send(ControlUpdate::OverviewState(active != 0));
}
_ => {} _ => {}
} }
} }
+248 -77
View File
@@ -9,6 +9,7 @@ use cosmic::iced::alignment;
use cosmic::iced::event::{self, PlatformSpecific, wayland}; use cosmic::iced::event::{self, PlatformSpecific, wayland};
use cosmic::iced::platform_specific::{ use cosmic::iced::platform_specific::{
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
shell::commands::layer_surface::set_input_zone,
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
}; };
use cosmic::iced::{ use cosmic::iced::{
@@ -16,7 +17,7 @@ use cosmic::iced::{
window, window,
}; };
use cosmic::theme; use cosmic::theme;
use cosmic::widget::{button, container, image, row, text}; use cosmic::widget::{button, container, icon, image, row, text};
use ferese_protocols::effects::v1::client::{ use ferese_protocols::effects::v1::client::{
ferese_effects_manager_v1::FereseEffectsManagerV1, ferese_effects_manager_v1::FereseEffectsManagerV1,
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
@@ -28,21 +29,41 @@ use wayland_client::{
protocol::{wl_registry, wl_surface}, protocol::{wl_registry, wl_surface},
}; };
use crate::config::{ShellConfig, WallpaperMode}; use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
use crate::control::{ShellControl, ShellSnapshot}; use crate::control::{ShellControl, ShellSnapshot};
const APP_ID: &str = "dev.ferese.Shell"; const APP_ID: &str = "dev.ferese.Shell";
const BAR_HEIGHT: u32 = 32;
const BAR_MARGIN: i32 = 4; #[derive(Clone, Copy)]
const EXCLUSIVE_ZONE: i32 = BAR_HEIGHT as i32; struct BarMetrics {
const BAR_TEXT_SIZE: u16 = (BAR_HEIGHT * 3 / 8) as u16; height: f32,
const BAR_ICON_SIZE: u16 = (BAR_HEIGHT / 2) as u16; text_size: u16,
const CONTROL_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.75; icon_size: u16,
const WORKSPACE_HIT_WIDTH: f32 = BAR_HEIGHT as f32 * 0.6875; control_height: f32,
const ACTIVE_MARKER_WIDTH: f32 = BAR_HEIGHT as f32 * 0.5; workspace_hit_width: f32,
const ACTIVE_MARKER_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.125; active_marker_width: f32,
const DOT_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.1875; active_marker_height: f32,
const EMPTY_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.125; dot_marker_size: f32,
empty_marker_size: f32,
}
impl From<ShellTheme> for BarMetrics {
fn from(theme: ShellTheme) -> Self {
let height = theme.bar_height;
Self {
height,
text_size: (height * 0.368).round() as u16,
icon_size: (height * 0.553).round() as u16,
control_height: height * 0.79,
workspace_hit_width: height * 0.68,
active_marker_width: height * 0.47,
active_marker_height: height * 0.13,
dot_marker_size: height * 0.18,
empty_marker_size: height * 0.13,
}
}
}
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
let config = config::load(); let config = config::load();
@@ -66,6 +87,7 @@ struct FereseShell {
config: ShellConfig, config: ShellConfig,
control: Option<ShellControl>, control: Option<ShellControl>,
snapshot: ShellSnapshot, snapshot: ShellSnapshot,
overview_active: bool,
clock: String, clock: String,
effects: Option<EffectsBinding>, effects: Option<EffectsBinding>,
} }
@@ -75,7 +97,7 @@ enum Message {
Event(Event, window::Id), Event(Event, window::Id),
Tick, Tick,
ActivateWorkspace(u64), ActivateWorkspace(u64),
EnterOverview, ToggleOverview,
} }
impl cosmic::Application for FereseShell { impl cosmic::Application for FereseShell {
@@ -96,6 +118,8 @@ impl cosmic::Application for FereseShell {
fn init(core: Core, config: Self::Flags) -> (Self, Task<Self::Message>) { fn init(core: Core, config: Self::Flags) -> (Self, Task<Self::Message>) {
let bar_surface_id = window::Id::unique(); let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique();
let shell_theme = config.theme;
let bar = BarMetrics::from(shell_theme);
let app = Self { let app = Self {
core, core,
bar_surface_id, bar_surface_id,
@@ -106,6 +130,7 @@ impl cosmic::Application for FereseShell {
}) })
.ok(), .ok(),
snapshot: ShellSnapshot::default(), snapshot: ShellSnapshot::default(),
overview_active: false,
clock: current_time(), clock: current_time(),
effects: None, effects: None,
}; };
@@ -135,14 +160,14 @@ impl cosmic::Application for FereseShell {
output: IcedOutput::Active, output: IcedOutput::Active,
namespace: "ferese-shell-top-bar".to_owned(), namespace: "ferese-shell-top-bar".to_owned(),
margin: IcedMargin { margin: IcedMargin {
top: BAR_MARGIN, top: shell_theme.bar_margin_top,
right: 10, right: shell_theme.bar_margin_horizontal,
bottom: 0, bottom: 0,
left: 10, left: shell_theme.bar_margin_horizontal,
}, },
size: Some((None, Some(BAR_HEIGHT))), size: Some((None, Some(bar.height.round() as u32))),
size_limits: Limits::NONE, size_limits: Limits::NONE,
exclusive_zone: EXCLUSIVE_ZONE, exclusive_zone: bar.height.round() as i32,
..Default::default() ..Default::default()
}, },
Some(Box::new(Self::view_layer)), Some(Box::new(Self::view_layer)),
@@ -173,8 +198,26 @@ impl cosmic::Application for FereseShell {
if poll.disconnected { if poll.disconnected {
return cosmic::iced::exit(); return cosmic::iced::exit();
} }
if let Some(active) = poll.overview_active {
self.overview_active = active;
}
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
let was_hidden = bar_hidden(&self.snapshot);
self.snapshot = snapshot; self.snapshot = snapshot;
let hidden = bar_hidden(&self.snapshot);
if hidden != was_hidden {
if let Some(effects) = &self.effects
&& let Err(error) = effects.set_visible(!hidden)
{
eprintln!("ferese-shell: could not update panel material: {error}");
}
let input_zone = if hidden { Some(Vec::new()) } else { None };
return set_input_zone(self.bar_surface_id, input_zone);
}
} }
} }
Task::none() Task::none()
@@ -185,9 +228,10 @@ impl cosmic::Application for FereseShell {
} }
Task::none() Task::none()
} }
Message::EnterOverview => { Message::ToggleOverview => {
self.overview_active = !self.overview_active;
if let Some(control) = &self.control { if let Some(control) = &self.control {
control.enter_overview(); control.set_overview_active(self.overview_active);
} }
Task::none() Task::none()
} }
@@ -234,13 +278,20 @@ impl FereseShell {
return; return;
} }
match EffectsBinding::attach(surface) { match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) {
Ok(binding) => self.effects = Some(binding), Ok(binding) => self.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
} }
} }
fn view_layer(&self) -> Element<'_, cosmic::Action<Message>> { fn view_layer(&self) -> Element<'_, cosmic::Action<Message>> {
if bar_hidden(&self.snapshot) {
return container(text(""))
.width(Length::Fill)
.height(Length::Fill)
.into();
}
let focused_output = self let focused_output = self
.snapshot .snapshot
.outputs .outputs
@@ -248,16 +299,18 @@ impl FereseShell {
.find(|output| output.focused) .find(|output| output.focused)
.or_else(|| self.snapshot.outputs.first()); .or_else(|| self.snapshot.outputs.first());
let focused_output_id = focused_output.map(|output| output.id); let focused_output_id = focused_output.map(|output| output.id);
let shell_theme = self.config.theme;
let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
.spacing(1) .spacing(1)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push( workspace_row = workspace_row.push(
button::suggested("F") button::custom(overview_control(bar, shell_theme))
.font_size(BAR_TEXT_SIZE) .height(bar.control_height)
.height(CONTROL_HEIGHT) .padding(0)
.padding([2, 9]) .class(theme::Button::Transparent)
.on_press(cosmic::Action::App(Message::EnterOverview)), .on_press(cosmic::Action::App(Message::ToggleOverview)),
); );
for workspace in self for workspace in self
.snapshot .snapshot
@@ -270,9 +323,9 @@ impl FereseShell {
.windows .windows
.iter() .iter()
.any(|window| window.workspace == workspace.id); .any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator(workspace.active, occupied); let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
let workspace_button = button::custom(indicator) let workspace_button = button::custom(indicator)
.height(CONTROL_HEIGHT) .height(bar.control_height)
.padding(0) .padding(0)
.class(theme::Button::Transparent) .class(theme::Button::Transparent)
.on_press(cosmic::Action::App(Message::ActivateWorkspace( .on_press(cosmic::Action::App(Message::ActivateWorkspace(
@@ -293,16 +346,16 @@ impl FereseShell {
}) })
.map(display_app_name) .map(display_app_name)
.unwrap_or_else(|| "Desktop".to_owned()); .unwrap_or_else(|| "Desktop".to_owned());
let left = row![workspace_row, text(app_name).size(BAR_TEXT_SIZE)] let left = row![workspace_row, text(app_name).size(bar.text_size)]
.spacing(12) .spacing(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
let center = text(&self.clock).size(BAR_TEXT_SIZE); let center = text(&self.clock).size(bar.text_size);
let right = row![ let right = row![
text("◌").size(BAR_ICON_SIZE), symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size),
text("♪").size(BAR_ICON_SIZE), symbolic_icon(include_bytes!("../assets/icons/volume.svg"), bar.icon_size),
text("●").size(BAR_ICON_SIZE), symbolic_icon(include_bytes!("../assets/icons/battery.svg"), bar.icon_size),
] ]
.spacing(10) .spacing(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
let content = row![ let content = row![
container(left) container(left)
@@ -321,8 +374,8 @@ impl FereseShell {
container(content) container(content)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.padding([0, 12]) .padding([0, shell_theme.panel_padding.round() as u16])
.class(theme::Container::custom(bar_style)) .class(theme::Container::custom(move |_| bar_style(shell_theme)))
.into() .into()
} }
@@ -347,51 +400,98 @@ impl FereseShell {
} }
} }
fn workspace_indicator(active: bool, occupied: bool) -> Element<'static, cosmic::Action<Message>> { fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon {
let (width, height, style) = if active { icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
fn overview_control(
bar: BarMetrics,
shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> {
container(symbolic_icon(
include_bytes!("../assets/icons/overview.svg"),
bar.icon_size,
))
.width(bar.control_height)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.class(theme::Container::custom(move |_| container::Style {
icon_color: Some(color(shell_theme.text_primary)),
background: Some(Background::Color(color(shell_theme.accent))),
border: Border {
color: color_with_opacity(shell_theme.text_primary, 0.14),
width: 1.0,
radius: (bar.control_height * 0.34).into(),
},
snap: true,
..container::Style::default()
}))
.into()
}
fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
let active_workspace = snapshot
.outputs
.iter()
.find(|output| output.focused)
.or_else(|| snapshot.outputs.first())
.map(|output| output.active_workspace);
active_workspace.is_some_and(|workspace| {
snapshot
.windows
.iter()
.any(|window| window.workspace == workspace && window.fullscreen)
})
}
fn workspace_indicator(
active: bool,
occupied: bool,
bar: BarMetrics,
shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> {
let (width, height, color, radius) = if active {
( (
ACTIVE_MARKER_WIDTH, bar.active_marker_width,
ACTIVE_MARKER_HEIGHT, bar.active_marker_height,
active_workspace_style as fn(&cosmic::Theme) -> container::Style, color(shell_theme.accent),
2.0,
) )
} else if occupied { } else if occupied {
( (
DOT_MARKER_SIZE, bar.dot_marker_size,
DOT_MARKER_SIZE, bar.dot_marker_size,
occupied_workspace_style as fn(&cosmic::Theme) -> container::Style, color_with_opacity(shell_theme.text_muted, 0.78),
3.0,
) )
} else { } else {
( (
EMPTY_MARKER_SIZE, bar.empty_marker_size,
EMPTY_MARKER_SIZE, bar.empty_marker_size,
empty_workspace_style as fn(&cosmic::Theme) -> container::Style, color_with_opacity(shell_theme.text_muted, 0.42),
2.0,
) )
}; };
let marker = container(text("")) let marker = container(text(""))
.width(width) .width(width)
.height(height) .height(height)
.class(theme::Container::custom(style)); .class(theme::Container::custom(move |_| {
workspace_marker_style(color, radius)
}));
container(marker) container(marker)
.width(WORKSPACE_HIT_WIDTH) .width(bar.workspace_hit_width)
.height(CONTROL_HEIGHT) .height(bar.control_height)
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.into() .into()
} }
fn active_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgb8(91, 140, 255), 2.0)
}
fn occupied_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.68), 3.0)
}
fn empty_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.25), 2.0)
}
fn workspace_marker_style(color: Color, radius: f32) -> container::Style { fn workspace_marker_style(color: Color, radius: f32) -> container::Style {
container::Style { container::Style {
background: Some(Background::Color(color)), background: Some(Background::Color(color)),
@@ -426,25 +526,37 @@ fn current_time() -> String {
Zoned::now().strftime("%H:%M").to_string() Zoned::now().strftime("%H:%M").to_string()
} }
fn bar_style(_theme: &cosmic::Theme) -> container::Style { fn bar_style(theme: ShellTheme) -> container::Style {
let mut shadow = color(theme.shadow);
shadow.a *= theme.shadow_opacity;
container::Style { container::Style {
icon_color: Some(Color::from_rgb8(235, 239, 247)), icon_color: Some(color(theme.text_primary)),
text_color: Some(Color::from_rgb8(235, 239, 247)), text_color: Some(color(theme.text_primary)),
background: Some(Background::Color(Color::from_rgba8(18, 21, 28, 0.88))), background: None,
border: Border { border: Border {
color: Color::from_rgba8(255, 255, 255, 0.14), color: color(theme.border),
width: 1.0, width: 1.0,
radius: 16.0.into(), radius: theme.bar_radius.into(),
}, },
shadow: Shadow { shadow: Shadow {
color: Color::from_rgba8(0, 0, 0, 0.32), color: shadow,
offset: Vector::new(0.0, 4.0), offset: Vector::new(0.0, theme.shadow_offset_y),
blur_radius: 18.0, blur_radius: theme.shadow_blur,
}, },
snap: true, snap: true,
} }
} }
fn color([red, green, blue, alpha]: [u8; 4]) -> Color {
Color::from_rgba8(red, green, blue, f32::from(alpha) / 255.0)
}
fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color {
value[3] = (f32::from(value[3]) * opacity).round() as u8;
color(value)
}
fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
let [red, green, blue] = config::default_background(); let [red, green, blue] = config::default_background();
@@ -455,13 +567,17 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
} }
struct EffectsBinding { struct EffectsBinding {
connection: Connection,
_manager: FereseEffectsManagerV1, _manager: FereseEffectsManagerV1,
_surface: FereseSurfaceEffectsV1, surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
} }
impl EffectsBinding { impl EffectsBinding {
fn attach(surface: &wl_surface::WlSurface) -> Result<Self, Box<dyn std::error::Error>> { fn attach(
surface: &wl_surface::WlSurface,
visible: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let backend = surface let backend = surface
.backend() .backend()
.upgrade() .upgrade()
@@ -472,15 +588,32 @@ impl EffectsBinding {
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?; let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
let effects = manager.get_surface_effects(surface, &qh, ()); let effects = manager.get_surface_effects(surface, &qh, ());
effects.set_role(ferese_surface_effects_v1::Role::Panel); if visible {
effects.set_role(ferese_surface_effects_v1::Role::Panel);
} else {
effects.clear_role();
}
connection.flush()?; connection.flush()?;
Ok(Self { Ok(Self {
connection,
_manager: manager, _manager: manager,
_surface: effects, surface: effects,
_queue: queue, _queue: queue,
}) })
} }
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
if visible {
self.surface
.set_role(ferese_surface_effects_v1::Role::Panel);
} else {
self.surface.clear_role();
}
self.connection.flush()?;
Ok(())
}
} }
struct EffectsState; struct EffectsState;
@@ -499,3 +632,41 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)]
mod tests {
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[test]
fn fullscreen_on_the_active_workspace_hides_the_bar() {
let snapshot = snapshot_with_fullscreen_window(7);
assert!(bar_hidden(&snapshot));
}
#[test]
fn fullscreen_on_an_inactive_workspace_keeps_the_bar_visible() {
let snapshot = snapshot_with_fullscreen_window(8);
assert!(!bar_hidden(&snapshot));
}
fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
ShellSnapshot {
outputs: vec![OutputSnapshot {
id: 1,
active_workspace: 7,
focused: true,
}],
workspaces: Vec::new(),
windows: vec![WindowSnapshot {
workspace,
app_id: "dev.ferese.Test".to_owned(),
title: "Test".to_owned(),
focused: true,
fullscreen: true,
}],
}
}
}