Add native Settings and complete live configuration reload
Add compact branded Settings with safe atomic edits, external config refresh and a draggable sidebar. Reconcile services, update connected displays and input devices, apply live layout policy and window rules, and resolve initial floating placement before tiled insertion. Package the Settings launcher and shared Ferese icon.
This commit is contained in:
1 parent
672b4e2b60
commit
23991d5bd8
27 files changed
+3083
-61
No files matched your search
@@ -55,6 +55,7 @@ pub struct DirectBackendState {
|
||||
pub active: bool,
|
||||
lid_closed: bool,
|
||||
devices: HashMap<DrmNode, DirectDevice>,
|
||||
input_devices: Vec<LibinputDevice>,
|
||||
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
|
||||
pub(crate) connected_outputs: Vec<ConnectedOutputInfo>,
|
||||
}
|
||||
@@ -177,6 +178,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
active: session_active,
|
||||
lid_closed: initial_lid_closed(),
|
||||
devices: HashMap::new(),
|
||||
input_devices: Vec::new(),
|
||||
presentation: HashMap::new(),
|
||||
connected_outputs: Vec::new(),
|
||||
});
|
||||
@@ -185,7 +187,19 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
event_loop
|
||||
.handle()
|
||||
.insert_source(libinput_backend, |event, _, state| match event {
|
||||
InputEvent::DeviceAdded { mut device } => configure_libinput_device(state, &mut device),
|
||||
InputEvent::DeviceAdded { mut device } => {
|
||||
configure_libinput_device(state, &mut device);
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.input_devices.push(device);
|
||||
}
|
||||
}
|
||||
InputEvent::DeviceRemoved { device } => {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend
|
||||
.input_devices
|
||||
.retain(|candidate| candidate != &device);
|
||||
}
|
||||
}
|
||||
event => state.process_input_event(event),
|
||||
})?;
|
||||
event_loop
|
||||
@@ -285,6 +299,47 @@ fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_input_devices(state: &mut Ferese) {
|
||||
let Some(backend) = state.direct_backend.as_ref() else {
|
||||
return;
|
||||
};
|
||||
// Device clones refer to the same live libinput objects.
|
||||
let mut devices = backend.input_devices.clone();
|
||||
for device in &mut devices {
|
||||
configure_libinput_device(state, device);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_outputs(state: &mut Ferese) {
|
||||
let nodes = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.map(|backend| backend.devices.keys().copied().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
for node in nodes {
|
||||
rescan_device(state, node);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_live_outputs(
|
||||
state: &Ferese,
|
||||
profiles: &[OutputProfile],
|
||||
) -> Result<(), String> {
|
||||
let Some(backend) = state.direct_backend.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut usable = 0;
|
||||
for device in backend.devices.values() {
|
||||
let mut scan = select_outputs(&device.drm, profiles).map_err(|e| e.to_string())?;
|
||||
apply_lid_policy(&mut scan, backend.lid_closed);
|
||||
usable += scan.selections.len();
|
||||
}
|
||||
if usable == 0 {
|
||||
return Err("display configuration would leave no usable output".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_primary_device(
|
||||
event_loop: &mut EventLoop<Ferese>,
|
||||
state: &mut Ferese,
|
||||
@@ -725,6 +780,78 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
|
||||
let mut deferred_removals = Vec::new();
|
||||
|
||||
for crtc in existing {
|
||||
if let Some(selection) = selections.get(&crtc)
|
||||
&& let Some(output) = device.outputs.get_mut(&crtc)
|
||||
&& selection.connector.handle() == output.connector
|
||||
&& (selection.mode != output.mode || selection.settings != output.settings)
|
||||
{
|
||||
// Keep the wl_output/global and workspace ownership alive. A mode
|
||||
// change is tested by DRM before updating client-visible geometry.
|
||||
let result = if selection.mode != output.mode {
|
||||
output.surface.use_mode(selection.mode)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let old_geometry = state.space.output_geometry(&output.output);
|
||||
output.mode = selection.mode;
|
||||
output.settings = selection.settings.clone();
|
||||
let position = selection.settings.position.unwrap_or_else(|| {
|
||||
state
|
||||
.space
|
||||
.output_geometry(&output.output)
|
||||
.map(|g| [g.loc.x, g.loc.y])
|
||||
.unwrap_or([0, 0])
|
||||
});
|
||||
output.output.change_current_state(
|
||||
Some(OutputMode::from(output.mode)),
|
||||
Some(output_transform(output.settings.transform)),
|
||||
Some(Scale::Fractional(output.settings.scale)),
|
||||
Some((position[0], position[1]).into()),
|
||||
);
|
||||
state
|
||||
.space
|
||||
.map_output(&output.output, (position[0], position[1]));
|
||||
if let Some(id) = state.output_id(&output.output)
|
||||
&& let Some(g) = state.space.output_geometry(&output.output)
|
||||
{
|
||||
state.output_workspaces.update_geometry(
|
||||
id,
|
||||
ferese_core::OutputGeometry::new(g.loc.x, g.loc.y, g.size.w, g.size.h),
|
||||
);
|
||||
if let Some(old) = old_geometry {
|
||||
state.reposition_output_floats(
|
||||
id,
|
||||
ferese_layout::Rect::new(
|
||||
old.loc.x as f64,
|
||||
old.loc.y as f64,
|
||||
old.size.w as f64,
|
||||
old.size.h as f64,
|
||||
),
|
||||
ferese_layout::Rect::new(
|
||||
g.loc.x as f64,
|
||||
g.loc.y as f64,
|
||||
g.size.w as f64,
|
||||
g.size.h as f64,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
output.damage_tracker = OutputDamageTracker::from_output(&output.output);
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
let clock = backend.presentation.entry((node, crtc)).or_default();
|
||||
clock.set_refresh(OutputMode::from(output.mode).refresh);
|
||||
clock.reset_timing();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(?crtc, %error, "display change failed; retaining current output")
|
||||
}
|
||||
}
|
||||
selections.remove(&crtc);
|
||||
continue;
|
||||
}
|
||||
let unchanged = device.outputs.get(&crtc).is_some_and(|output| {
|
||||
selections.get(&crtc).is_some_and(|selection| {
|
||||
selection.connector.handle() == output.connector
|
||||
@@ -827,6 +954,21 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
for output in device.outputs.values() {
|
||||
if let Some(info) = backend
|
||||
.connected_outputs
|
||||
.iter_mut()
|
||||
.find(|info| info.connector == output.output.name())
|
||||
{
|
||||
info.enabled = true;
|
||||
info.current_mode = Some(connected_mode_info(output.mode));
|
||||
info.scale = output.settings.scale;
|
||||
info.transform = output.settings.transform;
|
||||
info.configured_position = output.settings.position;
|
||||
}
|
||||
}
|
||||
}
|
||||
restore_device(state, node, device);
|
||||
state.restore_output_focus();
|
||||
state.relayout();
|
||||
|
||||
@@ -42,10 +42,12 @@ pub struct Config {
|
||||
_status: ShellStatusConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
pub(crate) struct DaemonConfig {
|
||||
pub command: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub restart: bool,
|
||||
#[serde(default)]
|
||||
pub nested: bool,
|
||||
@@ -282,6 +284,7 @@ impl Config {
|
||||
let input_settings = self.input_settings()?;
|
||||
let bindings = self.bindings(&input_settings)?;
|
||||
Ok(crate::RuntimeConfig {
|
||||
autostart: self.autostart.clone(),
|
||||
layout_mode: self.layout_mode(),
|
||||
gap_config: self.gap_config()?,
|
||||
input_settings,
|
||||
|
||||
@@ -15,11 +15,50 @@ struct Service {
|
||||
pub(crate) struct Runner(Vec<Service>);
|
||||
|
||||
impl Runner {
|
||||
pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) {
|
||||
let mut previous = std::mem::take(&mut self.0);
|
||||
for config in configs
|
||||
.iter()
|
||||
.filter(|c| c.enabled && (!nested || c.nested))
|
||||
{
|
||||
if self
|
||||
.0
|
||||
.iter()
|
||||
.any(|service| service.config.command == config.command)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(index) = previous
|
||||
.iter()
|
||||
.position(|service| service.config.command == config.command)
|
||||
{
|
||||
let mut service = previous.remove(index);
|
||||
if config.restart && !service.config.restart {
|
||||
service.finished = false;
|
||||
}
|
||||
service.config = config.clone();
|
||||
self.0.push(service);
|
||||
} else {
|
||||
self.0.push(Service {
|
||||
config: config.clone(),
|
||||
child: None,
|
||||
next_start: Instant::now(),
|
||||
finished: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// TERM/grace/reaping must not pause the compositor's event loop.
|
||||
for mut removed in previous {
|
||||
if let Some(mut child) = removed.child.take() {
|
||||
std::thread::spawn(move || crate::terminate_child(&mut child));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
|
||||
Self(
|
||||
configs
|
||||
.iter()
|
||||
.filter(|config| !nested || config.nested)
|
||||
.filter(|config| config.enabled && (!nested || config.nested))
|
||||
.map(|config| Service {
|
||||
config: config.clone(),
|
||||
child: None,
|
||||
@@ -31,6 +70,7 @@ impl Runner {
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, state: &mut Ferese) {
|
||||
self.reconcile(&state.autostart, state.direct_backend.is_none());
|
||||
let now = Instant::now();
|
||||
for service in &mut self.0 {
|
||||
if let Some(child) = &mut service.child {
|
||||
@@ -82,9 +122,44 @@ impl Drop for Runner {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn live_reconcile_keeps_existing_process_and_changes_policy_without_restart() {
|
||||
let mut config = DaemonConfig {
|
||||
command: vec!["/bin/sleep".into(), "30".into()],
|
||||
enabled: true,
|
||||
restart: false,
|
||||
nested: false,
|
||||
};
|
||||
let mut runner = Runner::new(&[config.clone()], false);
|
||||
runner.0[0].child = Some(
|
||||
std::process::Command::new("/bin/sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let pid = runner.0[0].child.as_ref().unwrap().id();
|
||||
config.restart = true;
|
||||
runner.reconcile(&[config.clone()], false);
|
||||
assert_eq!(runner.0[0].child.as_ref().unwrap().id(), pid);
|
||||
assert!(runner.0[0].config.restart);
|
||||
runner.reconcile(&[config.clone(), config.clone()], false);
|
||||
assert_eq!(runner.0.len(), 1);
|
||||
config.enabled = false;
|
||||
runner.reconcile(&[config], false);
|
||||
assert!(runner.0.is_empty());
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"removed service was not stopped/reaped"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn previews_do_not_duplicate_session_daemons() {
|
||||
let configs = vec![DaemonConfig {
|
||||
command: vec!["awari".into()],
|
||||
enabled: true,
|
||||
restart: true,
|
||||
nested: false,
|
||||
}];
|
||||
@@ -94,4 +169,16 @@ mod tests {
|
||||
direct.stop();
|
||||
assert!(direct.0[0].finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_login_items_never_start_on_either_backend() {
|
||||
let configs = vec![DaemonConfig {
|
||||
command: vec!["not-executed".into()],
|
||||
enabled: false,
|
||||
restart: true,
|
||||
nested: true,
|
||||
}];
|
||||
assert!(Runner::new(&configs, true).0.is_empty());
|
||||
assert!(Runner::new(&configs, false).0.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use smithay::{
|
||||
Seat,
|
||||
pointer::{Focus, GrabStartData},
|
||||
},
|
||||
reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode,
|
||||
reexports::wayland_protocols::xdg::{
|
||||
decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode,
|
||||
shell::server::xdg_toplevel::State as ToplevelState,
|
||||
},
|
||||
reexports::wayland_server::{
|
||||
Resource,
|
||||
protocol::{wl_seat, wl_surface::WlSurface},
|
||||
@@ -34,21 +37,11 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
fn new_toplevel(&mut self, surface: ToplevelSurface) {
|
||||
let parent = surface.parent().and_then(|parent| {
|
||||
self.window_ids.iter().find_map(|(window, id)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == &parent)
|
||||
.then_some(*id)
|
||||
})
|
||||
});
|
||||
let window = Window::new_wayland_window(surface);
|
||||
|
||||
if let Some(parent) = parent {
|
||||
self.add_transient_window(window, parent);
|
||||
} else {
|
||||
self.add_tiled_window(window);
|
||||
}
|
||||
// app_id, title and parent arrive after get_toplevel. Keep the surface
|
||||
// discoverable, but do not mutate/focus the layout until its initial
|
||||
// commit supplies the metadata needed to resolve placement rules.
|
||||
self.space.map_element(window, (0, 0), false);
|
||||
}
|
||||
|
||||
fn parent_changed(&mut self, surface: ToplevelSurface) {
|
||||
@@ -212,6 +205,8 @@ impl XdgShellHandler for Ferese {
|
||||
.then_some(*id)
|
||||
}) {
|
||||
self.set_window_maximized(id, true);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.set(ToplevelState::Maximized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +218,8 @@ impl XdgShellHandler for Ferese {
|
||||
.then_some(*id)
|
||||
}) {
|
||||
self.set_window_maximized(id, false);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.unset(ToplevelState::Maximized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +235,8 @@ impl XdgShellHandler for Ferese {
|
||||
.then_some(*id)
|
||||
}) {
|
||||
self.set_window_fullscreen(window, true);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.set(ToplevelState::Fullscreen));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +248,8 @@ impl XdgShellHandler for Ferese {
|
||||
.then_some(*id)
|
||||
}) {
|
||||
self.set_window_fullscreen(window, false);
|
||||
} else {
|
||||
surface.with_pending_state(|state| state.states.unset(ToplevelState::Fullscreen));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +406,37 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
|
||||
)
|
||||
});
|
||||
|
||||
let initial = !state.window_ids.contains_key(window);
|
||||
let requested = initial.then(|| {
|
||||
toplevel.with_pending_state(|pending| {
|
||||
(
|
||||
pending.states.contains(ToplevelState::Maximized),
|
||||
pending.states.contains(ToplevelState::Fullscreen),
|
||||
)
|
||||
})
|
||||
});
|
||||
if initial {
|
||||
let parent = toplevel.parent().and_then(|parent| {
|
||||
state.window_ids.iter().find_map(|(candidate, id)| {
|
||||
candidate
|
||||
.toplevel()
|
||||
.is_some_and(|surface| surface.wl_surface() == &parent)
|
||||
.then_some(*id)
|
||||
})
|
||||
});
|
||||
state.add_rule_placed_window(window.clone(), app_id.as_deref(), title.as_deref(), parent);
|
||||
}
|
||||
state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient);
|
||||
if let Some((maximized, fullscreen)) = requested
|
||||
&& let Some(id) = state.window_ids.get(window).copied()
|
||||
{
|
||||
if maximized {
|
||||
state.set_window_maximized(id, true);
|
||||
}
|
||||
if fullscreen {
|
||||
state.set_window_fullscreen(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
|
||||
@@ -43,6 +43,19 @@ use crate::config::Config;
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
init_logging();
|
||||
|
||||
// Settings validates a candidate before atomically replacing the user's file.
|
||||
// This mode never opens a display, input device, or compositor socket.
|
||||
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if args.first().is_some_and(|arg| arg == "--check-config") {
|
||||
if args.len() != 2 {
|
||||
return Err("usage: ferese --check-config PATH".into());
|
||||
}
|
||||
let source = std::fs::read_to_string(&args[1])?;
|
||||
Config::parse_source(&source)?.runtime_config()?;
|
||||
println!("Configuration is valid");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let launch = LaunchConfig::from_environment()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||
let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
|
||||
|
||||
+191
-21
@@ -159,6 +159,7 @@ pub struct Ferese {
|
||||
spring_config: SpringConfig,
|
||||
viewport_spring_config: SpringConfig,
|
||||
pub(crate) output_profiles: Vec<OutputProfile>,
|
||||
pub(crate) autostart: Vec<crate::config::DaemonConfig>,
|
||||
pub cursor_status: CursorImageStatus,
|
||||
// Cursor callbacks may run with Smithay's pointer mutex held. Rendering
|
||||
// reads the pointer position, so defer it until event dispatch returns.
|
||||
@@ -218,6 +219,7 @@ pub struct Ferese {
|
||||
}
|
||||
|
||||
pub struct RuntimeConfig {
|
||||
pub(crate) autostart: Vec<crate::config::DaemonConfig>,
|
||||
pub(crate) overview_font_family: String,
|
||||
pub(crate) wallpaper: crate::wallpaper::WallpaperConfig,
|
||||
pub layout_mode: LayoutMode,
|
||||
@@ -238,6 +240,9 @@ pub struct RuntimeConfig {
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> {
|
||||
if self.output_profiles != config.output_profiles {
|
||||
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
||||
}
|
||||
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|
||||
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
||||
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
||||
@@ -263,25 +268,35 @@ impl Ferese {
|
||||
);
|
||||
}
|
||||
self.advance_animations(Instant::now());
|
||||
if self.input_settings.touchpad != config.input_settings.touchpad {
|
||||
tracing::warn!(
|
||||
"touchpad changes apply when the device reconnects or the session restarts"
|
||||
);
|
||||
}
|
||||
if self.output_profiles != config.output_profiles {
|
||||
tracing::warn!(
|
||||
"output profile changes apply on output reconnect; current modes are preserved"
|
||||
);
|
||||
}
|
||||
self.workspaces.reconfigure_defaults(
|
||||
config.layout_mode,
|
||||
config.default_column_width,
|
||||
config.scrolling_focus_strategy,
|
||||
);
|
||||
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
||||
let outputs_changed = self.output_profiles != config.output_profiles;
|
||||
let bounds = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
let output = self.output_workspaces.output_for_workspace(workspace.id);
|
||||
let rect = self
|
||||
.output_ids
|
||||
.iter()
|
||||
.find(|(_, id)| Some(**id) == output)
|
||||
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||
.or_else(|| self.output_bounds())
|
||||
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
|
||||
(workspace.id, rect)
|
||||
})
|
||||
.collect();
|
||||
self.workspaces
|
||||
.reconfigure_live(
|
||||
config.layout_mode,
|
||||
config.default_column_width,
|
||||
config.scrolling_focus_strategy,
|
||||
&bounds,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
self.gap_config = config.gap_config;
|
||||
self.input_settings = config.input_settings;
|
||||
self.bindings = config.bindings;
|
||||
self.window_rules = config.window_rules;
|
||||
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
|
||||
self.theme_settings = config.theme_settings;
|
||||
self.column_width_presets = config.column_width_presets;
|
||||
self.animations_enabled = config.animations_enabled;
|
||||
@@ -289,6 +304,16 @@ impl Ferese {
|
||||
self.spring_config = config.spring_config;
|
||||
self.viewport_spring_config = config.viewport_spring_config;
|
||||
self.output_profiles = config.output_profiles;
|
||||
self.autostart = config.autostart;
|
||||
if touchpad_changed {
|
||||
crate::backends::direct::reload_input_devices(self);
|
||||
}
|
||||
if outputs_changed {
|
||||
crate::backends::direct::reload_outputs(self);
|
||||
}
|
||||
if old_rules != self.window_rules {
|
||||
self.reapply_window_rules(&old_rules);
|
||||
}
|
||||
self.overview.set_font_family(config.overview_font_family);
|
||||
self.wallpaper.reload(config.wallpaper);
|
||||
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||
@@ -376,6 +401,28 @@ impl Ferese {
|
||||
self.relayout();
|
||||
}
|
||||
|
||||
pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) {
|
||||
let floats = self
|
||||
.window_ids
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if self.output_workspaces.output_for_workspace(workspace) != Some(output) {
|
||||
return None;
|
||||
}
|
||||
match self.workspaces.placement(*id)? {
|
||||
WindowPlacement::Floating { rect } => {
|
||||
Some((*id, moved_floating_rect(rect, old, new)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (id, rect) in floats {
|
||||
let _ = self.workspaces.set_floating_rect(id, rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_focused_output(&self, output: &Output) -> bool {
|
||||
self.output_ids.get(output).copied() == self.output_workspaces.focused_output()
|
||||
}
|
||||
@@ -587,6 +634,7 @@ impl Ferese {
|
||||
spring_config: config.spring_config,
|
||||
viewport_spring_config: config.viewport_spring_config,
|
||||
output_profiles: config.output_profiles,
|
||||
autostart: config.autostart,
|
||||
cursor_status: CursorImageStatus::default_named(),
|
||||
cursor_redraw_pending: false,
|
||||
cursor_theme,
|
||||
@@ -880,6 +928,57 @@ impl Ferese {
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn add_rule_placed_window(
|
||||
&mut self,
|
||||
window: Window,
|
||||
app_id: Option<&str>,
|
||||
title: Option<&str>,
|
||||
parent: Option<WindowId>,
|
||||
) {
|
||||
let rule = resolve_window_rules(&self.window_rules, app_id, title, parent.is_some());
|
||||
let floating = rule
|
||||
.floating
|
||||
.unwrap_or(parent.is_some() || rule.width.is_some() || rule.height.is_some());
|
||||
if !floating {
|
||||
self.add_tiled_window(window);
|
||||
return;
|
||||
}
|
||||
if let Some(parent) = parent {
|
||||
self.add_transient_window(window, parent);
|
||||
return;
|
||||
}
|
||||
if let Some(pointer) = self.seat.get_pointer() {
|
||||
self.focus_output_at(pointer.current_location());
|
||||
}
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
self.add_tiled_window(window);
|
||||
return;
|
||||
};
|
||||
let id = WindowId(self.next_window_id);
|
||||
self.next_window_id += 1;
|
||||
let rect = client_size(&window)
|
||||
.map(|size| natural_floating_rect(bounds, size))
|
||||
.unwrap_or_else(|| centered_floating_rect(bounds));
|
||||
let focus = self.workspaces.active().fullscreen.is_none();
|
||||
if let Err(error) =
|
||||
self.workspaces
|
||||
.insert_floating_window(id, self.workspaces.active_id(), rect, focus)
|
||||
{
|
||||
tracing::error!(%error, ?id, "failed to insert rule-placed floating window");
|
||||
return;
|
||||
}
|
||||
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() {
|
||||
self.natural_floating_pending.insert(id);
|
||||
}
|
||||
self.window_ids.insert(window.clone(), id);
|
||||
self.window_stack.insert(id);
|
||||
if focus {
|
||||
self.focused_window = Some(id);
|
||||
}
|
||||
self.space.map_element(window, (0, 0), focus);
|
||||
// apply_initial_window_rules performs the first relayout/configure.
|
||||
}
|
||||
|
||||
pub fn add_tiled_window(&mut self, window: Window) {
|
||||
let id = WindowId(self.next_window_id);
|
||||
self.next_window_id += 1;
|
||||
@@ -952,14 +1051,58 @@ impl Ferese {
|
||||
let Some(id) = self.window_ids.get(window).copied() else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
return;
|
||||
};
|
||||
if !self.window_rules_applied.insert(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rule = resolve_window_rules(&self.window_rules, app_id, title, transient);
|
||||
self.apply_window_rule_result(window, rule);
|
||||
}
|
||||
|
||||
fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) {
|
||||
let windows = self.window_ids.keys().cloned().collect::<Vec<_>>();
|
||||
for window in windows {
|
||||
let Some(toplevel) = window.toplevel() else {
|
||||
continue;
|
||||
};
|
||||
let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| {
|
||||
let attributes = states
|
||||
.data_map
|
||||
.get::<XdgToplevelSurfaceData>()
|
||||
.unwrap()
|
||||
.lock()
|
||||
.unwrap();
|
||||
(
|
||||
attributes.app_id.clone(),
|
||||
attributes.title.clone(),
|
||||
attributes.parent.is_some(),
|
||||
)
|
||||
});
|
||||
let old =
|
||||
resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient);
|
||||
let new = resolve_window_rules(
|
||||
&self.window_rules,
|
||||
app_id.as_deref(),
|
||||
title.as_deref(),
|
||||
transient,
|
||||
);
|
||||
if let Some(new) = crate::window_rules::live_result(old, new, transient) {
|
||||
self.apply_window_rule_result(&window, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_window_rule_result(
|
||||
&mut self,
|
||||
window: &Window,
|
||||
rule: crate::window_rules::WindowRuleResult,
|
||||
) {
|
||||
let Some(id) = self.window_ids.get(window).copied() else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.floating_bounds_for_window(id) else {
|
||||
return;
|
||||
};
|
||||
if rule == Default::default() {
|
||||
return;
|
||||
}
|
||||
@@ -977,6 +1120,7 @@ impl Ferese {
|
||||
{
|
||||
tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule");
|
||||
}
|
||||
let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds);
|
||||
|
||||
let should_float = rule
|
||||
.floating
|
||||
@@ -1065,11 +1209,11 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn remove_tiled_window(&mut self, window: &Window) {
|
||||
// Uncommitted toplevels have not entered a workspace yet.
|
||||
self.space.unmap_elem(window);
|
||||
let Some(id) = self.window_ids.remove(window) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.space.unmap_elem(window);
|
||||
self.window_geometry.remove(&id);
|
||||
self.resize_transactions.remove(&id);
|
||||
self.resize_snapshots.remove(&id);
|
||||
@@ -1124,6 +1268,10 @@ impl Ferese {
|
||||
continue;
|
||||
};
|
||||
let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds);
|
||||
let scale = output.current_scale().fractional_scale();
|
||||
for layer in layer_map_for_output(&output).layers() {
|
||||
crate::handlers::set_surface_tree_scale(layer.wl_surface(), scale);
|
||||
}
|
||||
let Some(workspace) = self.workspaces.workspace(workspace_id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1181,6 +1329,9 @@ impl Ferese {
|
||||
if self.workspaces.workspace_for_window(*id) != Some(workspace_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(toplevel) = window.toplevel() {
|
||||
crate::handlers::set_surface_tree_scale(toplevel.wl_surface(), scale);
|
||||
}
|
||||
|
||||
let is_maximized =
|
||||
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
|
||||
@@ -2507,6 +2658,17 @@ fn maximized_rect(bounds: Rect, gap: f64) -> Rect {
|
||||
)
|
||||
}
|
||||
|
||||
fn moved_floating_rect(rect: Rect, old: Rect, new: Rect) -> Rect {
|
||||
let width = rect.width.min(new.width).max(1.);
|
||||
let height = rect.height.min(new.height).max(1.);
|
||||
Rect::new(
|
||||
(rect.x + new.x - old.x).clamp(new.x, new.x + (new.width - width).max(0.)),
|
||||
(rect.y + new.y - old.y).clamp(new.y, new.y + (new.height - height).max(0.)),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -2583,6 +2745,14 @@ impl ClientData for ClientState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn display_reposition_moves_floats_with_their_output_and_clamps_after_shrinking() {
|
||||
let old = ferese_layout::Rect::new(0., 0., 1920., 1080.);
|
||||
let new = ferese_layout::Rect::new(2000., 0., 1000., 700.);
|
||||
let rect =
|
||||
super::moved_floating_rect(ferese_layout::Rect::new(1500., 800., 400., 300.), old, new);
|
||||
assert_eq!(rect, ferese_layout::Rect::new(2600., 400., 400., 300.));
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,28 @@ pub struct WindowRuleResult {
|
||||
pub fullscreen: Option<bool>,
|
||||
}
|
||||
|
||||
/// Only changed matches should override an existing window's manual state.
|
||||
pub(crate) fn live_result(
|
||||
old: WindowRuleResult,
|
||||
mut new: WindowRuleResult,
|
||||
transient: bool,
|
||||
) -> Option<WindowRuleResult> {
|
||||
if old == new {
|
||||
return None;
|
||||
}
|
||||
if new.floating.is_none()
|
||||
&& new.width.is_none()
|
||||
&& new.height.is_none()
|
||||
&& (old.floating.is_some() || old.width.is_some() || old.height.is_some())
|
||||
{
|
||||
new.floating = Some(transient);
|
||||
}
|
||||
if new.fullscreen.is_none() && old.fullscreen.is_some() {
|
||||
new.fullscreen = Some(false);
|
||||
}
|
||||
Some(new)
|
||||
}
|
||||
|
||||
pub fn validate(configured: &[WindowRuleConfig]) -> Result<Vec<WindowRule>, String> {
|
||||
configured
|
||||
.iter()
|
||||
@@ -148,6 +170,29 @@ fn normalize_app_id(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() {
|
||||
let old = super::WindowRuleResult {
|
||||
floating: Some(true),
|
||||
fullscreen: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::live_result(old, old, false), None);
|
||||
let removed = super::live_result(old, Default::default(), false).unwrap();
|
||||
assert_eq!(removed.floating, Some(false));
|
||||
assert_eq!(removed.fullscreen, Some(false));
|
||||
assert_eq!(
|
||||
super::live_result(old, Default::default(), true)
|
||||
.unwrap()
|
||||
.floating,
|
||||
Some(true)
|
||||
);
|
||||
let new = super::WindowRuleResult {
|
||||
width: Some(800.),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::live_result(old, new, false).unwrap().floating, None);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn config(app_id: &str) -> WindowRuleConfig {
|
||||
|
||||
Reference in new issue
Block a user