advance shell integration and session support
This commit is contained in:
1 parent
0420242350
commit
e85fc1e032
27 files changed
+1165
-201
No files matched your search
@@ -1,12 +1,16 @@
|
||||
# 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
|
||||
optional per-workspace mode, and geometry changes use timestamp-driven
|
||||
animation. See the
|
||||
[window-manager specification](docs/ferese-window-manager-spec.md) for the
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
the `ferese::render` tracing target. Summaries include rendered frames, damaged
|
||||
pixels, average and longest render time, DRM missed-deadline totals, blur-active
|
||||
@@ -76,6 +84,7 @@ them for a lone tiled window:
|
||||
|
||||
```toml
|
||||
[layout]
|
||||
outer_gap = 4.0
|
||||
smart_gaps = true
|
||||
```
|
||||
|
||||
@@ -245,7 +254,7 @@ cargo run -p feresectl -- get-outputs
|
||||
```
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -110,6 +110,7 @@ pub struct ThemeSettings {
|
||||
pub border_color: RgbaColor,
|
||||
pub accent_color: RgbaColor,
|
||||
pub shadow_color: RgbaColor,
|
||||
pub surface_base_color: RgbaColor,
|
||||
pub window_radius: f64,
|
||||
pub shadow_offset_y: f64,
|
||||
pub shadow_blur: f64,
|
||||
@@ -146,6 +147,8 @@ struct ThemeMaterialConfig {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThemeColorsConfig {
|
||||
#[serde(default = "default_surface_base_color")]
|
||||
surface_base: String,
|
||||
#[serde(default = "default_border_color")]
|
||||
border: String,
|
||||
#[serde(default = "default_accent_color")]
|
||||
@@ -157,6 +160,7 @@ struct ThemeColorsConfig {
|
||||
impl Default for ThemeColorsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
surface_base: default_surface_base_color(),
|
||||
border: default_border_color(),
|
||||
accent: default_accent_color(),
|
||||
shadow: default_shadow_color(),
|
||||
@@ -235,12 +239,14 @@ struct BindingModifiers {
|
||||
pub enum BindingAction {
|
||||
Spawn(Vec<String>),
|
||||
Close,
|
||||
Exit,
|
||||
Focus(ferese_layout::Direction),
|
||||
Move(ferese_layout::Direction),
|
||||
Resize(ferese_layout::Direction),
|
||||
SwitchWorkspace(u8),
|
||||
MoveToWorkspace(u8),
|
||||
ToggleFullscreen,
|
||||
ToggleMaximized,
|
||||
ToggleLayout,
|
||||
CycleColumnWidth,
|
||||
CenterColumn,
|
||||
@@ -803,6 +809,10 @@ impl Config {
|
||||
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
||||
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
|
||||
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,
|
||||
shadow_offset_y,
|
||||
shadow_blur,
|
||||
@@ -1059,6 +1069,10 @@ fn parse_action(
|
||||
no_argument()?;
|
||||
Ok(BindingAction::Close)
|
||||
}
|
||||
"exit" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::Exit)
|
||||
}
|
||||
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
|
||||
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
|
||||
"resize" => Ok(BindingAction::Resize(
|
||||
@@ -1070,6 +1084,10 @@ fn parse_action(
|
||||
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(
|
||||
required_argument()?,
|
||||
)?)),
|
||||
"toggle-maximized" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleMaximized)
|
||||
}
|
||||
"toggle-fullscreen" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::ToggleFullscreen)
|
||||
@@ -1133,7 +1151,8 @@ fn default_bindings() -> Vec<BindingConfig> {
|
||||
let mut bindings = vec![
|
||||
binding("Super+Enter", "spawn", Some("terminal")),
|
||||
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+R", "cycle-column-width", None),
|
||||
binding("Super+C", "center-column", None),
|
||||
@@ -1141,6 +1160,7 @@ fn default_bindings() -> Vec<BindingConfig> {
|
||||
binding("Super+]", "expel", None),
|
||||
binding("Super+Shift+Space", "toggle-floating", 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")] {
|
||||
@@ -1283,13 +1303,17 @@ const fn default_inner_gap() -> f64 {
|
||||
}
|
||||
|
||||
const fn default_outer_gap() -> f64 {
|
||||
10.0
|
||||
4.0
|
||||
}
|
||||
|
||||
fn default_border_color() -> String {
|
||||
"#FFFFFF18".to_owned()
|
||||
}
|
||||
|
||||
fn default_surface_base_color() -> String {
|
||||
"#111821".to_owned()
|
||||
}
|
||||
|
||||
fn default_accent_color() -> String {
|
||||
"#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,]),
|
||||
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]),
|
||||
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||
window_radius: 12.0,
|
||||
shadow_offset_y: -2.0,
|
||||
shadow_blur: 24.0,
|
||||
@@ -1628,7 +1653,21 @@ mod tests {
|
||||
let input = config.input_settings().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| {
|
||||
binding.modifiers.logo
|
||||
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
|
||||
@@ -1650,7 +1689,7 @@ mod tests {
|
||||
|
||||
let input = replaced.input_settings().unwrap();
|
||||
let bindings = replaced.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 40);
|
||||
assert_eq!(bindings.len(), 42);
|
||||
assert!(bindings.iter().any(|binding| {
|
||||
binding.action
|
||||
== BindingAction::Spawn(vec![
|
||||
@@ -1662,7 +1701,7 @@ mod tests {
|
||||
|
||||
let input = unbound.input_settings().unwrap();
|
||||
let bindings = unbound.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 39);
|
||||
assert_eq!(bindings.len(), 41);
|
||||
assert!(
|
||||
!bindings
|
||||
.iter()
|
||||
|
||||
@@ -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(
|
||||
&mut self,
|
||||
surface: ToplevelSurface,
|
||||
|
||||
+45
-6
@@ -44,6 +44,14 @@ impl Ferese {
|
||||
}
|
||||
|
||||
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 state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
@@ -168,11 +176,29 @@ impl Ferese {
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
|
||||
if self.overview.is_active() {
|
||||
if event.state() == ButtonState::Pressed
|
||||
&& let Some(window) = self.window_under_visual(pointer.current_location())
|
||||
&& let Some(id) = self.window_ids.get(&window).copied()
|
||||
{
|
||||
self.select_overview_window(id);
|
||||
let position = pointer.current_location();
|
||||
if self.layer_under(position).is_some() {
|
||||
pointer.button(
|
||||
self,
|
||||
&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;
|
||||
@@ -465,6 +491,7 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
BindingAction::Close => self.close_focused_window(),
|
||||
BindingAction::Exit => self.loop_signal.stop(),
|
||||
BindingAction::Focus(direction) => self.focus_direction(direction),
|
||||
BindingAction::Move(direction) => self.move_direction(direction),
|
||||
BindingAction::Resize(direction) => self.resize_direction(direction),
|
||||
@@ -475,6 +502,7 @@ impl Ferese {
|
||||
self.move_focused_to_workspace(u32::from(workspace));
|
||||
}
|
||||
BindingAction::ToggleFullscreen => self.toggle_focused_fullscreen(),
|
||||
BindingAction::ToggleMaximized => self.toggle_focused_maximized(),
|
||||
BindingAction::ToggleLayout => self.toggle_layout_mode(),
|
||||
BindingAction::CycleColumnWidth => self.cycle_focused_column_width(),
|
||||
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
|
||||
}
|
||||
|
||||
fn overview_escape(symbol: u32, overview_active: bool) -> bool {
|
||||
overview_active && symbol == keysyms::KEY_Escape
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn emergency_escape_requires_control_alt_escape() {
|
||||
|
||||
+29
-7
@@ -16,7 +16,7 @@ use ferese_core::LayoutMode;
|
||||
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use ferese_layout::Direction;
|
||||
use serde_json::{Value, json};
|
||||
use smithay::reexports::calloop::{EventLoop, channel};
|
||||
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel};
|
||||
use smithay::utils::Transform;
|
||||
|
||||
use crate::{Ferese, config::OutputTransform};
|
||||
@@ -84,15 +84,20 @@ pub(crate) fn init(
|
||||
}
|
||||
})?;
|
||||
|
||||
let signal = event_loop.get_signal();
|
||||
thread::Builder::new()
|
||||
.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");
|
||||
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));
|
||||
|
||||
loop {
|
||||
@@ -125,11 +130,12 @@ fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcCal
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let signal = signal.clone();
|
||||
if let Err(error) = thread::Builder::new()
|
||||
.name("ferese-ipc-client".to_owned())
|
||||
.spawn(move || {
|
||||
let _permit = permit;
|
||||
serve_connection(stream, sender);
|
||||
serve_connection(stream, sender, signal);
|
||||
})
|
||||
{
|
||||
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 {
|
||||
let request = match read_frame(&mut stream) {
|
||||
let request: Request = match read_frame(&mut stream) {
|
||||
Ok(request) => request,
|
||||
Err(ferese_ipc::FrameError::Io(error))
|
||||
if matches!(
|
||||
@@ -174,6 +184,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>
|
||||
}
|
||||
};
|
||||
let (response, receiver) = sync_channel(1);
|
||||
let exit_requested = request.command == "exit";
|
||||
let call = IpcCall { request, response };
|
||||
if sender.try_send(call).is_err() {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
let exit_accepted = exit_requested && response.error.is_none();
|
||||
if let Err(error) = write_frame(&mut stream, &response) {
|
||||
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
||||
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> {
|
||||
match command {
|
||||
"exit" => {} // The IPC worker stops the loop after writing the response.
|
||||
"focus" => self.focus_direction(direction_arg(args)?),
|
||||
"move" => self.move_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)?),
|
||||
"toggle-floating" => self.toggle_focused_floating(),
|
||||
"toggle-fullscreen" => self.toggle_focused_fullscreen(),
|
||||
"toggle-maximized" => self.toggle_focused_maximized(),
|
||||
"toggle-layout" => self.toggle_layout_mode(),
|
||||
"cycle-column-width" => self.cycle_focused_column_width(),
|
||||
"center-column" => self.center_focused_column(),
|
||||
@@ -608,7 +628,9 @@ mod tests {
|
||||
fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() {
|
||||
let (mut client, server) = UnixStream::pair().unwrap();
|
||||
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";
|
||||
|
||||
client
|
||||
|
||||
+93
-6
@@ -6,7 +6,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ferese_animation::{AnimatedValue, ClientSize, SpringConfig, WindowGeometry};
|
||||
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
|
||||
use ferese_core::{
|
||||
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
|
||||
WorkspaceSet,
|
||||
@@ -118,6 +118,7 @@ pub struct Ferese {
|
||||
output_identity_ids: HashMap<String, OutputId>,
|
||||
pub window_ids: HashMap<Window, WindowId>,
|
||||
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
||||
maximized_windows: HashSet<WindowId>,
|
||||
window_stack: crate::stacking::WindowStack,
|
||||
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
||||
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
|
||||
@@ -446,6 +447,7 @@ impl Ferese {
|
||||
output_identity_ids: HashMap::new(),
|
||||
window_ids: HashMap::new(),
|
||||
window_geometry: HashMap::new(),
|
||||
maximized_windows: HashSet::new(),
|
||||
window_stack: crate::stacking::WindowStack::default(),
|
||||
window_borders: HashMap::new(),
|
||||
window_shadows: HashMap::new(),
|
||||
@@ -619,6 +621,9 @@ impl Ferese {
|
||||
let map = layer_map_for_output(output);
|
||||
|
||||
for requested in layers {
|
||||
if *requested == Layer::Top && self.output_has_fullscreen(output) {
|
||||
continue;
|
||||
}
|
||||
for layer in map.layers_on(*requested).rev() {
|
||||
let geometry = map.layer_geometry(layer)?;
|
||||
let layer_position = output_position - geometry.loc.to_f64();
|
||||
@@ -901,6 +906,7 @@ impl Ferese {
|
||||
|
||||
self.space.unmap_elem(window);
|
||||
self.window_geometry.remove(&id);
|
||||
self.maximized_windows.remove(&id);
|
||||
self.window_stack.remove(id);
|
||||
self.window_borders.remove(&id);
|
||||
self.window_shadows.remove(&id);
|
||||
@@ -996,8 +1002,12 @@ impl Ferese {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_maximized =
|
||||
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
|
||||
let rect = if workspace_fullscreen == Some(*id) {
|
||||
fullscreen_bounds
|
||||
} else if is_maximized {
|
||||
maximized_rect(bounds, self.gap_config.outer)
|
||||
} else {
|
||||
match self.workspaces.placement(*id) {
|
||||
Some(WindowPlacement::Tiled) => {
|
||||
@@ -1017,7 +1027,7 @@ impl Ferese {
|
||||
);
|
||||
|
||||
visible.insert(*id);
|
||||
let scrolling = if !is_fullscreen && !is_floating {
|
||||
let scrolling = if !is_fullscreen && !is_maximized && !is_floating {
|
||||
viewport_target
|
||||
.zip(viewport_current)
|
||||
.map(|(target, current)| (workspace_id, rect.x + target, current))
|
||||
@@ -1030,6 +1040,7 @@ impl Ferese {
|
||||
*id,
|
||||
rect,
|
||||
is_fullscreen,
|
||||
is_maximized,
|
||||
is_floating,
|
||||
scrolling,
|
||||
pending_column_width_cycles.contains(id) && viewport_target_changed,
|
||||
@@ -1053,7 +1064,9 @@ impl Ferese {
|
||||
let mut scrolling_world_x = HashMap::new();
|
||||
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.
|
||||
if scrolling.is_none() {
|
||||
self.viewport_coupled_widths.remove(&id);
|
||||
@@ -1064,7 +1077,14 @@ impl Ferese {
|
||||
.window_geometry
|
||||
.entry(id)
|
||||
.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 {
|
||||
geometry.advance(Duration::ZERO, self.spring_config, false);
|
||||
requested_size = geometry.presentation_size_request(now).or(requested_size);
|
||||
@@ -1132,6 +1152,11 @@ impl Ferese {
|
||||
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_changed = if tiled {
|
||||
state.states.set(xdg_toplevel::State::TiledLeft)
|
||||
@@ -1145,7 +1170,7 @@ impl Ferese {
|
||||
| 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
|
||||
} else {
|
||||
DecorationMode::ServerSide
|
||||
@@ -1153,7 +1178,7 @@ impl Ferese {
|
||||
let decoration_changed = 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 {
|
||||
@@ -1600,6 +1625,42 @@ impl Ferese {
|
||||
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) {
|
||||
let Some(focused) = self.focused_window else {
|
||||
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 {
|
||||
let width = (bounds.width * 0.6).max(1.0);
|
||||
let height = (bounds.height * 0.6).max(1.0);
|
||||
@@ -2017,6 +2090,20 @@ impl ClientData for ClientState {
|
||||
mod tests {
|
||||
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]
|
||||
fn transient_geometry_is_centered_and_bounded_by_its_parent() {
|
||||
let parent = Rect::new(100.0, 50.0, 1_000.0, 800.0);
|
||||
|
||||
+29
-62
@@ -40,9 +40,9 @@ use smithay::{
|
||||
},
|
||||
input::pointer::{CursorImageStatus, CursorImageSurfaceData},
|
||||
output::{Mode, Output, PhysicalProperties, Scale, Subpixel},
|
||||
reexports::calloop::EventLoop,
|
||||
reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind,
|
||||
reexports::wayland_server::Resource,
|
||||
reexports::calloop::EventLoop,
|
||||
utils::{
|
||||
Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size,
|
||||
Transform,
|
||||
@@ -871,12 +871,12 @@ fn output_elements(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
elements.extend(layer_elements(
|
||||
state,
|
||||
renderer,
|
||||
output,
|
||||
&[Layer::Overlay, Layer::Top],
|
||||
));
|
||||
let upper_layers: &[Layer] = if state.output_has_fullscreen(output) {
|
||||
&[Layer::Overlay]
|
||||
} else {
|
||||
&[Layer::Overlay, Layer::Top]
|
||||
};
|
||||
elements.extend(layer_elements(state, renderer, output, upper_layers));
|
||||
let windows = state
|
||||
.space
|
||||
.elements()
|
||||
@@ -1452,30 +1452,26 @@ fn layer_elements(
|
||||
|
||||
let mut elements = Vec::new();
|
||||
for (geometry, layer) in layers {
|
||||
let target = geometry.to_physical_precise_round(scale);
|
||||
let surface_elements = AsRenderElements::<GlesRenderer>::render_elements::<
|
||||
WaylandSurfaceRenderElement<GlesRenderer>,
|
||||
>(&layer, renderer, target.loc, scale.into(), 1.0);
|
||||
let source_bounds = surface_elements
|
||||
.iter()
|
||||
.map(|element| element.geometry(scale.into()))
|
||||
.reduce(Rectangle::merge);
|
||||
let content_scale = source_bounds
|
||||
.map(|source| layer_content_scale(source.size, target.size))
|
||||
.unwrap_or_else(|| RenderScale::from(1.0));
|
||||
let scale_origin = source_bounds.map_or(target.loc, |source| source.loc);
|
||||
let crop = target.intersection(output_crop).unwrap_or_default();
|
||||
elements.extend(
|
||||
AsRenderElements::<GlesRenderer>::render_elements::<
|
||||
WaylandSurfaceRenderElement<GlesRenderer>,
|
||||
>(
|
||||
&layer,
|
||||
renderer,
|
||||
geometry.loc.to_physical_precise_round(scale),
|
||||
scale.into(),
|
||||
1.0,
|
||||
)
|
||||
.into_iter()
|
||||
.filter_map(|element| {
|
||||
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| {
|
||||
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)
|
||||
}));
|
||||
CropRenderElement::from_element(element, scale, output_crop).map(Into::into)
|
||||
}),
|
||||
);
|
||||
|
||||
if let Some(material) = material_element(state, renderer, output, &layer, geometry) {
|
||||
elements.push(material);
|
||||
@@ -1485,20 +1481,6 @@ fn layer_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(
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
@@ -1519,7 +1501,8 @@ fn material_element(
|
||||
expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical);
|
||||
let sample_physical = sample_geometry.to_physical_precise_round(scale);
|
||||
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 {
|
||||
geometry,
|
||||
tint,
|
||||
@@ -1831,8 +1814,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect,
|
||||
layer_content_scale, normalized_scale, rounded_visual_rect, scaled_visual_rect,
|
||||
shadow_bounds,
|
||||
normalized_scale, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1885,21 +1867,6 @@ mod tests {
|
||||
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]
|
||||
fn clip_rect_matches_smithay_gles_projection() {
|
||||
let geometry = Rectangle::<i32, Physical>::new((10, 5).into(), (30, 40).into());
|
||||
|
||||
@@ -267,6 +267,13 @@ impl ClientGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PresentationMode {
|
||||
Normal,
|
||||
Maximized,
|
||||
Fullscreen,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WindowGeometry {
|
||||
pub logical: Rect,
|
||||
@@ -274,11 +281,11 @@ pub struct WindowGeometry {
|
||||
pub client: ClientGeometry,
|
||||
pub decorations: f64,
|
||||
pub presentation_changed: bool,
|
||||
fullscreen: bool,
|
||||
mode: PresentationMode,
|
||||
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)]
|
||||
struct ZoomTransition {
|
||||
from: Rect,
|
||||
@@ -300,7 +307,7 @@ impl WindowGeometry {
|
||||
},
|
||||
decorations: 1.0,
|
||||
presentation_changed: true,
|
||||
fullscreen: false,
|
||||
mode: PresentationMode::Normal,
|
||||
zoom: None,
|
||||
}
|
||||
}
|
||||
@@ -319,9 +326,26 @@ impl WindowGeometry {
|
||||
rect: Rect,
|
||||
fullscreen: bool,
|
||||
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> {
|
||||
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);
|
||||
progress.set_target(1.0);
|
||||
let from = self.visual.current;
|
||||
@@ -350,7 +374,7 @@ impl WindowGeometry {
|
||||
progress,
|
||||
});
|
||||
}
|
||||
self.fullscreen = fullscreen;
|
||||
self.mode = mode;
|
||||
self.logical = rect;
|
||||
self.visual.set_target(rect);
|
||||
self.presentation_size_request(now)
|
||||
@@ -368,14 +392,14 @@ impl WindowGeometry {
|
||||
}
|
||||
|
||||
pub fn is_fullscreen(&self) -> bool {
|
||||
self.fullscreen
|
||||
self.mode == PresentationMode::Fullscreen
|
||||
}
|
||||
|
||||
pub fn follow_pointer(&mut self, rect: Rect, now: Duration) -> Option<ClientSize> {
|
||||
self.zoom = None;
|
||||
let requested_size = self.set_logical_target(rect, now);
|
||||
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;
|
||||
requested_size
|
||||
}
|
||||
@@ -400,7 +424,7 @@ impl WindowGeometry {
|
||||
) -> bool {
|
||||
if !animations_enabled {
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
@@ -437,7 +461,11 @@ impl WindowGeometry {
|
||||
};
|
||||
self.decorations = lerp(
|
||||
zoom.decorations_from,
|
||||
if self.fullscreen { 0.0 } else { 1.0 },
|
||||
if self.mode == PresentationMode::Fullscreen {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
);
|
||||
if !active {
|
||||
self.zoom = None;
|
||||
@@ -544,6 +572,67 @@ fn finite_or(value: f64, fallback: f64) -> f64 {
|
||||
mod tests {
|
||||
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]
|
||||
fn zoom_finishes_without_a_visible_final_edge_jump() {
|
||||
let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0);
|
||||
|
||||
@@ -1031,7 +1031,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
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();
|
||||
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Default for GapConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: 10.0,
|
||||
outer: 10.0,
|
||||
outer: 4.0,
|
||||
smart: false,
|
||||
}
|
||||
}
|
||||
@@ -1178,8 +1178,8 @@ mod tests {
|
||||
let first = geometry[&WindowId(1)];
|
||||
let second = geometry[&WindowId(2)];
|
||||
|
||||
assert_eq!(first.x, 10.0);
|
||||
assert_eq!(second.x + second.width, 90.0);
|
||||
assert_eq!(first.x, 4.0);
|
||||
assert_eq!(second.x + second.width, 96.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())
|
||||
.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]
|
||||
@@ -1571,7 +1571,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -240,10 +240,10 @@ base_unit = 8.0
|
||||
|
||||
window_radius = 14.0
|
||||
|
||||
top_bar_height = 32.0
|
||||
top_bar_height = 38.0
|
||||
top_bar_margin_top = 4.0
|
||||
top_bar_margin_horizontal = 10.0
|
||||
top_bar_radius = 16.0
|
||||
top_bar_radius = 19.0
|
||||
|
||||
launcher_width = 560.0
|
||||
launcher_radius = 20.0
|
||||
@@ -724,8 +724,8 @@ noise = 0.012
|
||||
|
||||
[theme.geometry]
|
||||
window_radius = 14.0
|
||||
top_bar_height = 32.0
|
||||
top_bar_radius = 16.0
|
||||
top_bar_height = 38.0
|
||||
top_bar_radius = 19.0
|
||||
launcher_width = 560.0
|
||||
launcher_radius = 20.0
|
||||
|
||||
|
||||
@@ -1127,7 +1127,7 @@ mass = 1.0
|
||||
[layout]
|
||||
mode = "scrolling"
|
||||
inner_gap = 10.0
|
||||
outer_gap = 10.0
|
||||
outer_gap = 4.0
|
||||
smart_gaps = false
|
||||
|
||||
[scrolling]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -47,9 +47,9 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
||||
.map_err(|_| format!("{} requires a positive workspace index", command))?;
|
||||
json!({ "index": index })
|
||||
}
|
||||
"toggle-floating" | "toggle-fullscreen" | "toggle-layout" | "cycle-column-width"
|
||||
| "center-column" | "consume" | "expel" | "close" | "get-focused-window"
|
||||
| "get-workspaces" | "get-outputs" => {
|
||||
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
|
||||
| "cycle-column-width" | "center-column" | "consume" | "expel" | "close"
|
||||
| "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => {
|
||||
if !positional.is_empty() {
|
||||
return Err(format!("{command} does not accept arguments"));
|
||||
}
|
||||
@@ -78,7 +78,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -107,6 +107,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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(["reload-config".to_owned()]).is_err());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -8,7 +8,9 @@ rust-version = "1.93"
|
||||
[dependencies]
|
||||
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
|
||||
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
|
||||
toml.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 |
@@ -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 |
@@ -10,6 +10,48 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
||||
pub(crate) struct ShellConfig {
|
||||
pub(crate) font_family: Option<String>,
|
||||
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)]
|
||||
@@ -35,6 +77,12 @@ struct FereseConfig {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
colors: ThemeColorsConfig,
|
||||
#[serde(default)]
|
||||
geometry: ThemeGeometryConfig,
|
||||
#[serde(default)]
|
||||
shadow: ThemeShadowConfig,
|
||||
#[serde(default)]
|
||||
typography: TypographyConfig,
|
||||
#[serde(default)]
|
||||
@@ -46,6 +94,90 @@ struct TypographyConfig {
|
||||
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 {
|
||||
let Some(path) = config_path() else {
|
||||
return ShellConfig::default();
|
||||
@@ -55,10 +187,15 @@ pub(crate) fn load() -> ShellConfig {
|
||||
};
|
||||
|
||||
match toml::from_str::<FereseConfig>(&source) {
|
||||
Ok(config) => ShellConfig {
|
||||
font_family: config.theme.typography.font_family,
|
||||
wallpaper: config.theme.background,
|
||||
},
|
||||
Ok(config) => {
|
||||
let theme = shell_theme(&config.theme);
|
||||
|
||||
ShellConfig {
|
||||
font_family: config.theme.typography.font_family,
|
||||
wallpaper: config.theme.background,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
|
||||
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] {
|
||||
DEFAULT_BACKGROUND
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) struct WindowSnapshot {
|
||||
pub(crate) app_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) focused: bool,
|
||||
pub(crate) fullscreen: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ShellControl {
|
||||
@@ -56,11 +57,13 @@ pub(crate) struct ShellControl {
|
||||
|
||||
pub(crate) struct ControlPoll {
|
||||
pub(crate) snapshot: Option<ShellSnapshot>,
|
||||
pub(crate) overview_active: Option<bool>,
|
||||
pub(crate) disconnected: bool,
|
||||
}
|
||||
|
||||
enum ControlUpdate {
|
||||
Snapshot(ShellSnapshot),
|
||||
OverviewState(bool),
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
@@ -93,12 +96,14 @@ impl ShellControl {
|
||||
pub(crate) fn poll(&self) -> ControlPoll {
|
||||
let mut poll = ControlPoll {
|
||||
snapshot: None,
|
||||
overview_active: None,
|
||||
disconnected: false,
|
||||
};
|
||||
|
||||
loop {
|
||||
match self.updates.try_recv() {
|
||||
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
|
||||
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
|
||||
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
|
||||
poll.disconnected = true;
|
||||
return poll;
|
||||
@@ -115,8 +120,12 @@ impl ShellControl {
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
|
||||
pub(crate) fn enter_overview(&self) {
|
||||
self.shell.enter_overview();
|
||||
pub(crate) fn set_overview_active(&self, active: bool) {
|
||||
if active {
|
||||
self.shell.enter_overview();
|
||||
} else {
|
||||
self.shell.exit_overview();
|
||||
}
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
}
|
||||
@@ -214,17 +223,20 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
title,
|
||||
state: window_state,
|
||||
} => {
|
||||
let focused = matches!(
|
||||
window_state,
|
||||
WEnum::Value(flags)
|
||||
if flags.contains(ferese_shell_v1::WindowState::Focused)
|
||||
);
|
||||
let (focused, fullscreen) = match window_state {
|
||||
WEnum::Value(flags) => (
|
||||
flags.contains(ferese_shell_v1::WindowState::Focused),
|
||||
flags.contains(ferese_shell_v1::WindowState::Fullscreen),
|
||||
),
|
||||
WEnum::Unknown(_) => (false, false),
|
||||
};
|
||||
|
||||
state.pending.windows.push(WindowSnapshot {
|
||||
workspace: join_id(workspace_hi, workspace_lo),
|
||||
app_id,
|
||||
title,
|
||||
focused,
|
||||
fullscreen,
|
||||
});
|
||||
}
|
||||
ferese_shell_v1::Event::SnapshotEnd { serial }
|
||||
@@ -234,6 +246,9 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
||||
.sender
|
||||
.send(ControlUpdate::Snapshot(state.pending.clone()));
|
||||
}
|
||||
ferese_shell_v1::Event::OverviewState { active } => {
|
||||
let _ = state.sender.send(ControlUpdate::OverviewState(active != 0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+248
-77
@@ -9,6 +9,7 @@ use cosmic::iced::alignment;
|
||||
use cosmic::iced::event::{self, PlatformSpecific, wayland};
|
||||
use cosmic::iced::platform_specific::{
|
||||
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
|
||||
shell::commands::layer_surface::set_input_zone,
|
||||
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
|
||||
};
|
||||
use cosmic::iced::{
|
||||
@@ -16,7 +17,7 @@ use cosmic::iced::{
|
||||
window,
|
||||
};
|
||||
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::{
|
||||
ferese_effects_manager_v1::FereseEffectsManagerV1,
|
||||
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
|
||||
@@ -28,21 +29,41 @@ use wayland_client::{
|
||||
protocol::{wl_registry, wl_surface},
|
||||
};
|
||||
|
||||
use crate::config::{ShellConfig, WallpaperMode};
|
||||
use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
|
||||
use crate::control::{ShellControl, ShellSnapshot};
|
||||
|
||||
const APP_ID: &str = "dev.ferese.Shell";
|
||||
const BAR_HEIGHT: u32 = 32;
|
||||
const BAR_MARGIN: i32 = 4;
|
||||
const EXCLUSIVE_ZONE: i32 = BAR_HEIGHT as i32;
|
||||
const BAR_TEXT_SIZE: u16 = (BAR_HEIGHT * 3 / 8) as u16;
|
||||
const BAR_ICON_SIZE: u16 = (BAR_HEIGHT / 2) as u16;
|
||||
const CONTROL_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.75;
|
||||
const WORKSPACE_HIT_WIDTH: f32 = BAR_HEIGHT as f32 * 0.6875;
|
||||
const ACTIVE_MARKER_WIDTH: f32 = BAR_HEIGHT as f32 * 0.5;
|
||||
const ACTIVE_MARKER_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.125;
|
||||
const DOT_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.1875;
|
||||
const EMPTY_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.125;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct BarMetrics {
|
||||
height: f32,
|
||||
text_size: u16,
|
||||
icon_size: u16,
|
||||
control_height: f32,
|
||||
workspace_hit_width: f32,
|
||||
active_marker_width: f32,
|
||||
active_marker_height: f32,
|
||||
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 {
|
||||
let config = config::load();
|
||||
@@ -66,6 +87,7 @@ struct FereseShell {
|
||||
config: ShellConfig,
|
||||
control: Option<ShellControl>,
|
||||
snapshot: ShellSnapshot,
|
||||
overview_active: bool,
|
||||
clock: String,
|
||||
effects: Option<EffectsBinding>,
|
||||
}
|
||||
@@ -75,7 +97,7 @@ enum Message {
|
||||
Event(Event, window::Id),
|
||||
Tick,
|
||||
ActivateWorkspace(u64),
|
||||
EnterOverview,
|
||||
ToggleOverview,
|
||||
}
|
||||
|
||||
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>) {
|
||||
let bar_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 {
|
||||
core,
|
||||
bar_surface_id,
|
||||
@@ -106,6 +130,7 @@ impl cosmic::Application for FereseShell {
|
||||
})
|
||||
.ok(),
|
||||
snapshot: ShellSnapshot::default(),
|
||||
overview_active: false,
|
||||
clock: current_time(),
|
||||
effects: None,
|
||||
};
|
||||
@@ -135,14 +160,14 @@ impl cosmic::Application for FereseShell {
|
||||
output: IcedOutput::Active,
|
||||
namespace: "ferese-shell-top-bar".to_owned(),
|
||||
margin: IcedMargin {
|
||||
top: BAR_MARGIN,
|
||||
right: 10,
|
||||
top: shell_theme.bar_margin_top,
|
||||
right: shell_theme.bar_margin_horizontal,
|
||||
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,
|
||||
exclusive_zone: EXCLUSIVE_ZONE,
|
||||
exclusive_zone: bar.height.round() as i32,
|
||||
..Default::default()
|
||||
},
|
||||
Some(Box::new(Self::view_layer)),
|
||||
@@ -173,8 +198,26 @@ impl cosmic::Application for FereseShell {
|
||||
if poll.disconnected {
|
||||
return cosmic::iced::exit();
|
||||
}
|
||||
if let Some(active) = poll.overview_active {
|
||||
self.overview_active = active;
|
||||
}
|
||||
if let Some(snapshot) = poll.snapshot {
|
||||
let was_hidden = bar_hidden(&self.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()
|
||||
@@ -185,9 +228,10 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::EnterOverview => {
|
||||
Message::ToggleOverview => {
|
||||
self.overview_active = !self.overview_active;
|
||||
if let Some(control) = &self.control {
|
||||
control.enter_overview();
|
||||
control.set_overview_active(self.overview_active);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -234,13 +278,20 @@ impl FereseShell {
|
||||
return;
|
||||
}
|
||||
|
||||
match EffectsBinding::attach(surface) {
|
||||
match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) {
|
||||
Ok(binding) => self.effects = Some(binding),
|
||||
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.snapshot
|
||||
.outputs
|
||||
@@ -248,16 +299,18 @@ impl FereseShell {
|
||||
.find(|output| output.focused)
|
||||
.or_else(|| self.snapshot.outputs.first());
|
||||
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)
|
||||
.spacing(1)
|
||||
.align_y(cosmic::iced::Alignment::Center);
|
||||
|
||||
workspace_row = workspace_row.push(
|
||||
button::suggested("F")
|
||||
.font_size(BAR_TEXT_SIZE)
|
||||
.height(CONTROL_HEIGHT)
|
||||
.padding([2, 9])
|
||||
.on_press(cosmic::Action::App(Message::EnterOverview)),
|
||||
button::custom(overview_control(bar, shell_theme))
|
||||
.height(bar.control_height)
|
||||
.padding(0)
|
||||
.class(theme::Button::Transparent)
|
||||
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
||||
);
|
||||
for workspace in self
|
||||
.snapshot
|
||||
@@ -270,9 +323,9 @@ impl FereseShell {
|
||||
.windows
|
||||
.iter()
|
||||
.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)
|
||||
.height(CONTROL_HEIGHT)
|
||||
.height(bar.control_height)
|
||||
.padding(0)
|
||||
.class(theme::Button::Transparent)
|
||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
|
||||
@@ -293,16 +346,16 @@ impl FereseShell {
|
||||
})
|
||||
.map(display_app_name)
|
||||
.unwrap_or_else(|| "Desktop".to_owned());
|
||||
let left = row![workspace_row, text(app_name).size(BAR_TEXT_SIZE)]
|
||||
.spacing(12)
|
||||
let left = row![workspace_row, text(app_name).size(bar.text_size)]
|
||||
.spacing(shell_theme.control_gap)
|
||||
.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![
|
||||
text("◌").size(BAR_ICON_SIZE),
|
||||
text("♪").size(BAR_ICON_SIZE),
|
||||
text("●").size(BAR_ICON_SIZE),
|
||||
symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size),
|
||||
symbolic_icon(include_bytes!("../assets/icons/volume.svg"), 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);
|
||||
let content = row![
|
||||
container(left)
|
||||
@@ -321,8 +374,8 @@ impl FereseShell {
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding([0, 12])
|
||||
.class(theme::Container::custom(bar_style))
|
||||
.padding([0, shell_theme.panel_padding.round() as u16])
|
||||
.class(theme::Container::custom(move |_| bar_style(shell_theme)))
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -347,51 +400,98 @@ impl FereseShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_indicator(active: bool, occupied: bool) -> Element<'static, cosmic::Action<Message>> {
|
||||
let (width, height, style) = if active {
|
||||
fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon {
|
||||
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,
|
||||
ACTIVE_MARKER_HEIGHT,
|
||||
active_workspace_style as fn(&cosmic::Theme) -> container::Style,
|
||||
bar.active_marker_width,
|
||||
bar.active_marker_height,
|
||||
color(shell_theme.accent),
|
||||
2.0,
|
||||
)
|
||||
} else if occupied {
|
||||
(
|
||||
DOT_MARKER_SIZE,
|
||||
DOT_MARKER_SIZE,
|
||||
occupied_workspace_style as fn(&cosmic::Theme) -> container::Style,
|
||||
bar.dot_marker_size,
|
||||
bar.dot_marker_size,
|
||||
color_with_opacity(shell_theme.text_muted, 0.78),
|
||||
3.0,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
EMPTY_MARKER_SIZE,
|
||||
EMPTY_MARKER_SIZE,
|
||||
empty_workspace_style as fn(&cosmic::Theme) -> container::Style,
|
||||
bar.empty_marker_size,
|
||||
bar.empty_marker_size,
|
||||
color_with_opacity(shell_theme.text_muted, 0.42),
|
||||
2.0,
|
||||
)
|
||||
};
|
||||
let marker = container(text(""))
|
||||
.width(width)
|
||||
.height(height)
|
||||
.class(theme::Container::custom(style));
|
||||
.class(theme::Container::custom(move |_| {
|
||||
workspace_marker_style(color, radius)
|
||||
}));
|
||||
|
||||
container(marker)
|
||||
.width(WORKSPACE_HIT_WIDTH)
|
||||
.height(CONTROL_HEIGHT)
|
||||
.width(bar.workspace_hit_width)
|
||||
.height(bar.control_height)
|
||||
.align_x(alignment::Horizontal::Center)
|
||||
.align_y(alignment::Vertical::Center)
|
||||
.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 {
|
||||
container::Style {
|
||||
background: Some(Background::Color(color)),
|
||||
@@ -426,25 +526,37 @@ fn current_time() -> 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 {
|
||||
icon_color: Some(Color::from_rgb8(235, 239, 247)),
|
||||
text_color: Some(Color::from_rgb8(235, 239, 247)),
|
||||
background: Some(Background::Color(Color::from_rgba8(18, 21, 28, 0.88))),
|
||||
icon_color: Some(color(theme.text_primary)),
|
||||
text_color: Some(color(theme.text_primary)),
|
||||
background: None,
|
||||
border: Border {
|
||||
color: Color::from_rgba8(255, 255, 255, 0.14),
|
||||
color: color(theme.border),
|
||||
width: 1.0,
|
||||
radius: 16.0.into(),
|
||||
radius: theme.bar_radius.into(),
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color::from_rgba8(0, 0, 0, 0.32),
|
||||
offset: Vector::new(0.0, 4.0),
|
||||
blur_radius: 18.0,
|
||||
color: shadow,
|
||||
offset: Vector::new(0.0, theme.shadow_offset_y),
|
||||
blur_radius: theme.shadow_blur,
|
||||
},
|
||||
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 {
|
||||
let [red, green, blue] = config::default_background();
|
||||
|
||||
@@ -455,13 +567,17 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
|
||||
}
|
||||
|
||||
struct EffectsBinding {
|
||||
connection: Connection,
|
||||
_manager: FereseEffectsManagerV1,
|
||||
_surface: FereseSurfaceEffectsV1,
|
||||
surface: FereseSurfaceEffectsV1,
|
||||
_queue: EventQueue<EffectsState>,
|
||||
}
|
||||
|
||||
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
|
||||
.backend()
|
||||
.upgrade()
|
||||
@@ -472,15 +588,32 @@ impl EffectsBinding {
|
||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
|
||||
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()?;
|
||||
|
||||
Ok(Self {
|
||||
connection,
|
||||
_manager: manager,
|
||||
_surface: effects,
|
||||
surface: effects,
|
||||
_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;
|
||||
@@ -499,3 +632,41 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
||||
|
||||
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
|
||||
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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user