Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
add tag visibility, output scale, status socket, deferred spawn model, and fd-closing fixes
- Tags: show/hide windows on inactive tags, focus transfer on tag switch
- Output: native wlr-output-management scale setting, VT-switch-back fix
- Status: real-time status socket server (replaces pkill signal pattern)
- Spawn: replace raw nix::unistd::fork with Command+pre_exec that closes
inherited FDs 3+, preventing Wayland socket corruption from child
processes (especially fuzzel which holds the fd for seconds)
- Config: [[startup]] array with once= field, env= section, float scale
- Borders: remove swaybg/bg_color (swaybg removed)
- Tiling: add grid, vsplit, hsplit layouts; cascade color interpolation
- IPC: expand commands (view, toggle, set-tag, tag-layout, focus-next)
- Restart: env var re-injection on restart
protocol/wlr-output-management-unstable-v1.xml | 611 +++++++++++++++++
src/borders.rs | 44 +-
src/config.rs | 143 ++--
src/ipc.rs | 110 ++-
src/lib.rs | 1 +
src/main.rs | 156 ++---
src/protocol.rs | 22 +-
src/restart.rs | 45 +-
src/status.rs | 24 +-
src/status_server.rs | 335 ++++++++++
src/tiling.rs | 132 +++-
src/types.rs | 63 +-
src/wayland.rs | 892 +++++++++++++++++++++++--
src/wm.rs | 190 ++++--
start-river.sh | 8 +
15 files changed, 2407 insertions(+), 369 deletions(-)
diff --git a/protocol/wlr-output-management-unstable-v1.xml b/protocol/wlr-output-management-unstable-v1.xml
new file mode 100644
index 0000000..541284a
--- /dev/null
+++ b/protocol/wlr-output-management-unstable-v1.xml
@@ -0,0 +1,611 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="wlr_output_management_unstable_v1">
+ <copyright>
+ Copyright © 2019 Purism SPC
+
+ Permission to use, copy, modify, distribute, and sell this
+ software and its documentation for any purpose is hereby granted
+ without fee, provided that the above copyright notice appear in
+ all copies and that both that copyright notice and this permission
+ notice appear in supporting documentation, and that the name of
+ the copyright holders not be used in advertising or publicity
+ pertaining to distribution of the software without specific,
+ written prior permission. The copyright holders make no
+ representations about the suitability of this software for any
+ purpose. It is provided "as is" without express or implied
+ warranty.
+
+ THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
+ SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+ THIS SOFTWARE.
+ </copyright>
+
+ <description summary="protocol to configure output devices">
+ This protocol exposes interfaces to obtain and modify output device
+ configuration.
+
+ Warning! The protocol described in this file is experimental and
+ backward incompatible changes may be made. Backward compatible changes
+ may be added together with the corresponding interface version bump.
+ Backward incompatible changes are done by bumping the version number in
+ the protocol and interface names and resetting the interface version.
+ Once the protocol is to be declared stable, the 'z' prefix and the
+ version number in the protocol and interface names are removed and the
+ interface version number is reset.
+ </description>
+
+ <interface name="zwlr_output_manager_v1" version="4">
+ <description summary="output device configuration manager">
+ This interface is a manager that allows reading and writing the current
+ output device configuration.
+
+ Output devices that display pixels (e.g. a physical monitor or a virtual
+ output in a window) are represented as heads. Heads cannot be created nor
+ destroyed by the client, but they can be enabled or disabled and their
+ properties can be changed. Each head may have one or more available modes.
+
+ Whenever a head appears (e.g. a monitor is plugged in), it will be
+ advertised via the head event. Immediately after the output manager is
+ bound, all current heads are advertised.
+
+ Whenever a head's properties change, the relevant wlr_output_head events
+ will be sent. Not all head properties will be sent: only properties that
+ have changed need to.
+
+ Whenever a head disappears (e.g. a monitor is unplugged), a
+ wlr_output_head.finished event will be sent.
+
+ After one or more heads appear, change or disappear, the done event will
+ be sent. It carries a serial which can be used in a create_configuration
+ request to update heads properties.
+
+ The information obtained from this protocol should only be used for output
+ configuration purposes. This protocol is not designed to be a generic
+ output property advertisement protocol for regular clients. Instead,
+ protocols such as xdg-output should be used.
+ </description>
+
+ <event name="head">
+ <description summary="introduce a new head">
+ This event introduces a new head. This happens whenever a new head
+ appears (e.g. a monitor is plugged in) or after the output manager is
+ bound.
+ </description>
+ <arg name="head" type="new_id" interface="zwlr_output_head_v1"/>
+ </event>
+
+ <event name="done">
+ <description summary="sent all information about current configuration">
+ This event is sent after all information has been sent after binding to
+ the output manager object and after any subsequent changes. This applies
+ to child head and mode objects as well. In other words, this event is
+ sent whenever a head or mode is created or destroyed and whenever one of
+ their properties has been changed. Not all state is re-sent each time
+ the current configuration changes: only the actual changes are sent.
+
+ This allows changes to the output configuration to be seen as atomic,
+ even if they happen via multiple events.
+
+ A serial is sent to be used in a future create_configuration request.
+ </description>
+ <arg name="serial" type="uint" summary="current configuration serial"/>
+ </event>
+
+ <request name="create_configuration">
+ <description summary="create a new output configuration object">
+ Create a new output configuration object. This allows to update head
+ properties.
+ </description>
+ <arg name="id" type="new_id" interface="zwlr_output_configuration_v1"/>
+ <arg name="serial" type="uint"/>
+ </request>
+
+ <request name="stop">
+ <description summary="stop sending events">
+ Indicates the client no longer wishes to receive events for output
+ configuration changes. However the compositor may emit further events,
+ until the finished event is emitted.
+
+ The client must not send any more requests after this one.
+ </description>
+ </request>
+
+ <event name="finished" type="destructor">
+ <description summary="the compositor has finished with the manager">
+ This event indicates that the compositor is done sending manager events.
+ The compositor will destroy the object immediately after sending this
+ event, so it will become invalid and the client should release any
+ resources associated with it.
+ </description>
+ </event>
+ </interface>
+
+ <interface name="zwlr_output_head_v1" version="4">
+ <description summary="output device">
+ A head is an output device. The difference between a wl_output object and
+ a head is that heads are advertised even if they are turned off. A head
+ object only advertises properties and cannot be used directly to change
+ them.
+
+ A head has some read-only properties: modes, name, description and
+ physical_size. These cannot be changed by clients.
+
+ Other properties can be updated via a wlr_output_configuration object.
+
+ Properties sent via this interface are applied atomically via the
+ wlr_output_manager.done event. No guarantees are made regarding the order
+ in which properties are sent.
+ </description>
+
+ <event name="name">
+ <description summary="head name">
+ This event describes the head name.
+
+ The naming convention is compositor defined, but limited to alphanumeric
+ characters and dashes (-). Each name is unique among all wlr_output_head
+ objects, but if a wlr_output_head object is destroyed the same name may
+ be reused later. The names will also remain consistent across sessions
+ with the same hardware and software configuration.
+
+ Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do
+ not assume that the name is a reflection of an underlying DRM
+ connector, X11 connection, etc.
+
+ If this head matches a wl_output, the wl_output.name event must report
+ the same name.
+
+ The name event is sent after a wlr_output_head object is created. This
+ event is only sent once per object, and the name does not change over
+ the lifetime of the wlr_output_head object.
+ </description>
+ <arg name="name" type="string"/>
+ </event>
+
+ <event name="description">
+ <description summary="head description">
+ This event describes a human-readable description of the head.
+
+ The description is a UTF-8 string with no convention defined for its
+ contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11
+ output via :1'. However, do not assume that the name is a reflection of
+ the make, model, serial of the underlying DRM connector or the display
+ name of the underlying X11 connection, etc.
+
+ If this head matches a wl_output, the wl_output.description event must
+ report the same name.
+
+ The description event is sent after a wlr_output_head object is created.
+ This event is only sent once per object, and the description does not
+ change over the lifetime of the wlr_output_head object.
+ </description>
+ <arg name="description" type="string"/>
+ </event>
+
+ <event name="physical_size">
+ <description summary="head physical size">
+ This event describes the physical size of the head. This event is only
+ sent if the head has a physical size (e.g. is not a projector or a
+ virtual device).
+
+ The physical size event is sent after a wlr_output_head object is created. This
+ event is only sent once per object, and the physical size does not change over
+ the lifetime of the wlr_output_head object.
+ </description>
+ <arg name="width" type="int" summary="width in millimeters of the output"/>
+ <arg name="height" type="int" summary="height in millimeters of the output"/>
+ </event>
+
+ <event name="mode">
+ <description summary="introduce a mode">
+ This event introduces a mode for this head. It is sent once per
+ supported mode.
+ </description>
+ <arg name="mode" type="new_id" interface="zwlr_output_mode_v1"/>
+ </event>
+
+ <event name="enabled">
+ <description summary="head is enabled or disabled">
+ This event describes whether the head is enabled. A disabled head is not
+ mapped to a region of the global compositor space.
+
+ When a head is disabled, some properties (current_mode, position,
+ transform and scale) are irrelevant.
+ </description>
+ <arg name="enabled" type="int" summary="zero if disabled, non-zero if enabled"/>
+ </event>
+
+ <event name="current_mode">
+ <description summary="current mode">
+ This event describes the mode currently in use for this head. It is only
+ sent if the output is enabled.
+ </description>
+ <arg name="mode" type="object" interface="zwlr_output_mode_v1"/>
+ </event>
+
+ <event name="position">
+ <description summary="current position">
+ This events describes the position of the head in the global compositor
+ space. It is only sent if the output is enabled.
+ </description>
+ <arg name="x" type="int"
+ summary="x position within the global compositor space"/>
+ <arg name="y" type="int"
+ summary="y position within the global compositor space"/>
+ </event>
+
+ <event name="transform">
+ <description summary="current transformation">
+ This event describes the transformation currently applied to the head.
+ It is only sent if the output is enabled.
+ </description>
+ <arg name="transform" type="int" enum="wl_output.transform"/>
+ </event>
+
+ <event name="scale">
+ <description summary="current scale">
+ This events describes the scale of the head in the global compositor
+ space. It is only sent if the output is enabled.
+ </description>
+ <arg name="scale" type="fixed"/>
+ </event>
+
+ <event name="finished">
+ <description summary="the head has disappeared">
+ This event indicates that the head is no longer available. The head
+ object becomes inert. Clients should send a destroy request and release
+ any resources associated with it.
+ </description>
+ </event>
+
+ <!-- Version 2 additions -->
+
+ <event name="make" since="2">
+ <description summary="head manufacturer">
+ This event describes the manufacturer of the head.
+
+ Together with the model and serial_number events the purpose is to
+ allow clients to recognize heads from previous sessions and for example
+ load head-specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the make of
+ the head or the definition of a make is not sensible in the current
+ setup, for example in a virtual session. Clients can still try to
+ identify the head by available information from other events but should
+ be aware that there is an increased risk of false positives.
+
+ If sent, the make event is sent after a wlr_output_head object is
+ created and only sent once per object. The make does not change over
+ the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the make string in UI to users. For
+ that the string provided by the description event should be preferred.
+ </description>
+ <arg name="make" type="string"/>
+ </event>
+
+ <event name="model" since="2">
+ <description summary="head model">
+ This event describes the model of the head.
+
+ Together with the make and serial_number events the purpose is to
+ allow clients to recognize heads from previous sessions and for example
+ load head-specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the model of
+ the head or the definition of a model is not sensible in the current
+ setup, for example in a virtual session. Clients can still try to
+ identify the head by available information from other events but should
+ be aware that there is an increased risk of false positives.
+
+ If sent, the model event is sent after a wlr_output_head object is
+ created and only sent once per object. The model does not change over
+ the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the model string in UI to users. For
+ that the string provided by the description event should be preferred.
+ </description>
+ <arg name="model" type="string"/>
+ </event>
+
+ <event name="serial_number" since="2">
+ <description summary="head serial number">
+ This event describes the serial number of the head.
+
+ Together with the make and model events the purpose is to allow clients
+ to recognize heads from previous sessions and for example load head-
+ specific configurations back.
+
+ It is not guaranteed this event will be ever sent. A reason for that
+ can be that the compositor does not have information about the serial
+ number of the head or the definition of a serial number is not sensible
+ in the current setup. Clients can still try to identify the head by
+ available information from other events but should be aware that there
+ is an increased risk of false positives.
+
+ If sent, the serial number event is sent after a wlr_output_head object
+ is created and only sent once per object. The serial number does not
+ change over the lifetime of the wlr_output_head object.
+
+ It is not recommended to display the serial_number string in UI to
+ users. For that the string provided by the description event should be
+ preferred.
+ </description>
+ <arg name="serial_number" type="string"/>
+ </event>
+
+ <!-- Version 3 additions -->
+
+ <request name="release" type="destructor" since="3">
+ <description summary="destroy the head object">
+ This request indicates that the client will no longer use this head
+ object.
+ </description>
+ </request>
+
+ <!-- Version 4 additions -->
+
+ <enum name="adaptive_sync_state" since="4">
+ <entry name="disabled" value="0" summary="adaptive sync is disabled"/>
+ <entry name="enabled" value="1" summary="adaptive sync is enabled"/>
+ </enum>
+
+ <event name="adaptive_sync" since="4">
+ <description summary="current adaptive sync state">
+ This event describes whether adaptive sync is currently enabled for
+ the head or not. Adaptive sync is also known as Variable Refresh
+ Rate or VRR.
+ </description>
+ <arg name="state" type="uint" enum="adaptive_sync_state"/>
+ </event>
+ </interface>
+
+ <interface name="zwlr_output_mode_v1" version="3">
+ <description summary="output mode">
+ This object describes an output mode.
+
+ Some heads don't support output modes, in which case modes won't be
+ advertised.
+
+ Properties sent via this interface are applied atomically via the
+ wlr_output_manager.done event. No guarantees are made regarding the order
+ in which properties are sent.
+ </description>
+
+ <event name="size">
+ <description summary="mode size">
+ This event describes the mode size. The size is given in physical
+ hardware units of the output device. This is not necessarily the same as
+ the output size in the global compositor space. For instance, the output
+ may be scaled or transformed.
+ </description>
+ <arg name="width" type="int" summary="width of the mode in hardware units"/>
+ <arg name="height" type="int" summary="height of the mode in hardware units"/>
+ </event>
+
+ <event name="refresh">
+ <description summary="mode refresh rate">
+ This event describes the mode's fixed vertical refresh rate. It is only
+ sent if the mode has a fixed refresh rate.
+ </description>
+ <arg name="refresh" type="int" summary="vertical refresh rate in mHz"/>
+ </event>
+
+ <event name="preferred">
+ <description summary="mode is preferred">
+ This event advertises this mode as preferred.
+ </description>
+ </event>
+
+ <event name="finished">
+ <description summary="the mode has disappeared">
+ This event indicates that the mode is no longer available. The mode
+ object becomes inert. Clients should send a destroy request and release
+ any resources associated with it.
+ </description>
+ </event>
+
+ <!-- Version 3 additions -->
+
+ <request name="release" type="destructor" since="3">
+ <description summary="destroy the mode object">
+ This request indicates that the client will no longer use this mode
+ object.
+ </description>
+ </request>
+ </interface>
+
+ <interface name="zwlr_output_configuration_v1" version="4">
+ <description summary="output configuration">
+ This object is used by the client to describe a full output configuration.
+
+ First, the client needs to setup the output configuration. Each head can
+ be either enabled (and configured) or disabled. It is a protocol error to
+ send two enable_head or disable_head requests with the same head. It is a
+ protocol error to omit a head in a configuration.
+
+ Then, the client can apply or test the configuration. The compositor will
+ then reply with a succeeded, failed or cancelled event. Finally the client
+ should destroy the configuration object.
+ </description>
+
+ <enum name="error">
+ <entry name="already_configured_head" value="1"
+ summary="head has been configured twice"/>
+ <entry name="unconfigured_head" value="2"
+ summary="head has not been configured"/>
+ <entry name="already_used" value="3"
+ summary="request sent after configuration has been applied or tested"/>
+ </enum>
+
+ <request name="enable_head">
+ <description summary="enable and configure a head">
+ Enable a head. This request creates a head configuration object that can
+ be used to change the head's properties.
+ </description>
+ <arg name="id" type="new_id" interface="zwlr_output_configuration_head_v1"
+ summary="a new object to configure the head"/>
+ <arg name="head" type="object" interface="zwlr_output_head_v1"
+ summary="the head to be enabled"/>
+ </request>
+
+ <request name="disable_head">
+ <description summary="disable a head">
+ Disable a head.
+ </description>
+ <arg name="head" type="object" interface="zwlr_output_head_v1"
+ summary="the head to be disabled"/>
+ </request>
+
+ <request name="apply">
+ <description summary="apply the configuration">
+ Apply the new output configuration.
+
+ In case the configuration is successfully applied, there is no guarantee
+ that the new output state matches completely the requested
+ configuration. For instance, a compositor might round the scale if it
+ doesn't support fractional scaling.
+
+ After this request has been sent, the compositor must respond with an
+ succeeded, failed or cancelled event. Sending a request that isn't the
+ destructor is a protocol error.
+ </description>
+ </request>
+
+ <request name="test">
+ <description summary="test the configuration">
+ Test the new output configuration. The configuration won't be applied,
+ but will only be validated.
+
+ Even if the compositor succeeds to test a configuration, applying it may
+ fail.
+
+ After this request has been sent, the compositor must respond with an
+ succeeded, failed or cancelled event. Sending a request that isn't the
+ destructor is a protocol error.
+ </description>
+ </request>
+
+ <event name="succeeded">
+ <description summary="configuration changes succeeded">
+ Sent after the compositor has successfully applied the changes or
+ tested them.
+
+ Upon receiving this event, the client should destroy this object.
+
+ If the current configuration has changed, events to describe the changes
+ will be sent followed by a wlr_output_manager.done event.
+ </description>
+ </event>
+
+ <event name="failed">
+ <description summary="configuration changes failed">
+ Sent if the compositor rejects the changes or failed to apply them. The
+ compositor should revert any changes made by the apply request that
+ triggered this event.
+
+ Upon receiving this event, the client should destroy this object.
+ </description>
+ </event>
+
+ <event name="cancelled">
+ <description summary="configuration has been cancelled">
+ Sent if the compositor cancels the configuration because the state of an
+ output changed and the client has outdated information (e.g. after an
+ output has been hotplugged).
+
+ The client can create a new configuration with a newer serial and try
+ again.
+
+ Upon receiving this event, the client should destroy this object.
+ </description>
+ </event>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the output configuration">
+ Using this request a client can tell the compositor that it is not going
+ to use the configuration object anymore. Any changes to the outputs
+ that have not been applied will be discarded.
+
+ This request also destroys wlr_output_configuration_head objects created
+ via this object.
+ </description>
+ </request>
+ </interface>
+
+ <interface name="zwlr_output_configuration_head_v1" version="4">
+ <description summary="head configuration">
+ This object is used by the client to update a single head's configuration.
+
+ It is a protocol error to set the same property twice.
+ </description>
+
+ <enum name="error">
+ <entry name="already_set" value="1" summary="property has already been set"/>
+ <entry name="invalid_mode" value="2" summary="mode doesn't belong to head"/>
+ <entry name="invalid_custom_mode" value="3" summary="mode is invalid"/>
+ <entry name="invalid_transform" value="4" summary="transform value outside enum"/>
+ <entry name="invalid_scale" value="5" summary="scale negative or zero"/>
+ <entry name="invalid_adaptive_sync_state" value="6" since="4"
+ summary="invalid enum value used in the set_adaptive_sync request"/>
+ </enum>
+
+ <request name="set_mode">
+ <description summary="set the mode">
+ This request sets the head's mode.
+ </description>
+ <arg name="mode" type="object" interface="zwlr_output_mode_v1"/>
+ </request>
+
+ <request name="set_custom_mode">
+ <description summary="set a custom mode">
+ This request assigns a custom mode to the head. The size is given in
+ physical hardware units of the output device. If set to zero, the
+ refresh rate is unspecified.
+
+ It is a protocol error to set both a mode and a custom mode.
+ </description>
+ <arg name="width" type="int" summary="width of the mode in hardware units"/>
+ <arg name="height" type="int" summary="height of the mode in hardware units"/>
+ <arg name="refresh" type="int" summary="vertical refresh rate in mHz or zero"/>
+ </request>
+
+ <request name="set_position">
+ <description summary="set the position">
+ This request sets the head's position in the global compositor space.
+ </description>
+ <arg name="x" type="int" summary="x position in the global compositor space"/>
+ <arg name="y" type="int" summary="y position in the global compositor space"/>
+ </request>
+
+ <request name="set_transform">
+ <description summary="set the transform">
+ This request sets the head's transform.
+ </description>
+ <arg name="transform" type="int" enum="wl_output.transform"/>
+ </request>
+
+ <request name="set_scale">
+ <description summary="set the scale">
+ This request sets the head's scale.
+ </description>
+ <arg name="scale" type="fixed"/>
+ </request>
+
+ <!-- Version 4 additions -->
+
+ <request name="set_adaptive_sync" since="4">
+ <description summary="enable/disable adaptive sync">
+ This request enables/disables adaptive sync. Adaptive sync is also
+ known as Variable Refresh Rate or VRR.
+ </description>
+ <arg name="state" type="uint" enum="zwlr_output_head_v1.adaptive_sync_state"/>
+ </request>
+ </interface>
+</protocol>
diff --git a/src/borders.rs b/src/borders.rs
index d251912..188e79d 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -15,15 +15,6 @@ pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
(val as u32) << 24
}
-/// Write "#RRGGBB" for a given depth into a String.
-pub fn cascade_hex_color(br: u32, bg: u32, bb: u32, depth: i32) -> String {
- let depth_factor = 0.80_f64;
- let r = interp_channel(br, depth_factor, depth) >> 24;
- let g = interp_channel(bg, depth_factor, depth) >> 24;
- let b = interp_channel(bb, depth_factor, depth) >> 24;
- format!("#{:02x}{:02x}{:02x}", r, g, b)
-}
-
/// Normal border color in fixed-point format: dark gray (#3E3E3E)
pub const BORDER_COLOR_NORMAL_R: u32 = 0x3E000000;
pub const BORDER_COLOR_NORMAL_G: u32 = 0x3E000000;
@@ -49,8 +40,7 @@ pub struct WindowBorders {
}
/// Compute border colors for all visible windows.
-/// Returns a list of WindowBorders and the background color string for swaybg.
-pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Option<String>) {
+pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
let mut results = Vec::new();
let all_edges = 0b1111u32; // all edges
@@ -62,10 +52,7 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
}
}
- let mut max_cascade_depth = 0i32;
- let mut bg_color: Option<String> = None;
-
- // Second pass: assign border colors
+ // Assign border colors
for (idx, win) in state.windows.iter().enumerate() {
if (win.tags & state.active_tags) == 0 {
continue;
@@ -93,12 +80,11 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
window_idx: idx,
edges: all_edges,
width: state.layout.border_width,
- r, g, b, a: CASCADE_ALPHA,
+ r,
+ g,
+ b,
+ a: CASCADE_ALPHA,
});
-
- if depth > max_cascade_depth {
- max_cascade_depth = depth;
- }
} else {
results.push(WindowBorders {
window_idx: idx,
@@ -112,17 +98,7 @@ pub fn compute_border_colors(state: &WindowManager) -> (Vec<WindowBorders>, Opti
}
}
- // Set desktop background to the darkest cascade color
- if n_cascade > 0 {
- bg_color = Some(cascade_hex_color(
- state.layout.border_r,
- state.layout.border_g,
- state.layout.border_b,
- max_cascade_depth,
- ));
- }
-
- (results, bg_color)
+ results
}
#[cfg(test)]
@@ -142,10 +118,4 @@ mod tests {
let val = result >> 24;
assert_eq!(val, ((0x90 as f64 * 0.80) as u8) as u32);
}
-
- #[test]
- fn test_cascade_hex_color() {
- let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
- assert_eq!(color, "#5c9060");
- }
}
diff --git a/src/config.rs b/src/config.rs
index 952331c..10dd086 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -18,7 +18,9 @@ pub struct Config {
#[serde(default)]
pub repeat: RepeatConfig,
#[serde(default)]
- pub startup: StartupConfig,
+ pub startup: Vec<StartupEntryConfig>,
+ #[serde(default)]
+ pub env: HashMap<String, String>,
#[serde(default)]
pub keybind: Vec<KeybindConfig>,
#[serde(default)]
@@ -74,7 +76,7 @@ fn default_border_color() -> String {
#[derive(Debug, Deserialize, Default)]
pub struct OutputConfig {
#[serde(default)]
- pub scale: i64,
+ pub scale: f64,
}
#[derive(Debug, Deserialize, Default)]
@@ -85,14 +87,11 @@ pub struct RepeatConfig {
pub delay: i64,
}
-#[derive(Debug, Deserialize, Default)]
-pub struct StartupConfig {
- #[serde(default)]
- pub apps: Vec<String>,
- #[serde(default)]
- pub cold_start_only: Vec<String>,
+#[derive(Debug, Deserialize)]
+pub struct StartupEntryConfig {
+ pub exec: String,
#[serde(default)]
- pub env: HashMap<String, String>,
+ pub once: bool,
}
#[derive(Debug, Deserialize)]
@@ -126,7 +125,7 @@ pub struct TagLayoutConfig {
}
/// Parse the TOML config file and apply it to the WindowManager state.
-/// `cold_start` controls whether cold_start_only apps are spawned.
+/// `cold_start` controls whether `once = true` startup entries are spawned.
pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
@@ -145,6 +144,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
};
// [layout] section
+ eprintln!("[config] applying layout section...");
state.layout.gap = config.layout.gap as i32;
state.layout.offset = config.layout.offset as i32;
state.layout.bar_height = config.layout.bar_height as i32;
@@ -157,6 +157,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
}
// [[keybind]] array
+ eprintln!("[config] processing {} keybinds...", config.keybind.len());
for kb in &config.keybind {
let mods = parse_modifiers(&kb.mods);
let keysym = parse_keysym(&kb.key);
@@ -211,45 +212,52 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
}
}
- // [startup] section — spawn apps
- for app in &config.startup.apps {
- // Extract program name for skip-if-running check
- let name = extract_program_name(app);
- if process_running(&name) {
+ // [[startup]] array — queue apps for spawning inside the render callback.
+ // Spawning between blocking_dispatch calls corrupts the Wayland connection
+ // because the fork inherits the socket fd, so we defer to render time.
+ state.pending_startup_apps.clear();
+ eprintln!(
+ "[config] processing {} startup entries (cold_start={})...",
+ config.startup.len(),
+ cold_start
+ );
+ for (i, entry) in config.startup.iter().enumerate() {
+ let name = extract_program_name(&entry.exec);
+ if entry.once && !cold_start {
+ eprintln!(
+ "[config] startup[{}]: exec=\"{}\" once=true → skipped (not cold start)",
+ i, entry.exec
+ );
continue;
}
- // If waybar, kill existing before launching
- // NOTE: pkill + sleep is too slow for nested mode where River's
- // 3-second unresponsive timer is ticking. Just launch waybar
- // directly — if an existing waybar is running, the new one will
- // replace it (or the old one can be killed manually).
- // if name == "waybar" {
- // let _ = std::process::Command::new("pkill")
- // .arg("waybar")
- // .output();
- // std::thread::sleep(std::time::Duration::from_millis(100));
- // }
- spawn_command_bg(app);
- }
-
- // Cold-start-only apps
- if cold_start {
- for app in &config.startup.cold_start_only {
- spawn_command_bg(app);
+ let running = process_running(&name);
+ if running {
+ eprintln!("[config] startup[{}]: exec=\"{}\" once={} → skipped (already running, pgrep -x {})", i, entry.exec, entry.once, name);
+ } else {
+ eprintln!(
+ "[config] startup[{}]: exec=\"{}\" once={} → queued for spawn",
+ i, entry.exec, entry.once
+ );
+ state.pending_startup_apps.push(entry.exec.clone());
}
}
- // [startup.env] — set environment variables
- for (key, value) in &config.startup.env {
+ // [env] — set environment variables
+ for (key, value) in &config.env {
std::env::set_var(key, value);
state.env_vars.insert(key.clone(), value.clone());
}
- // [output] scale — handled at startup
- if config.output.scale > 0 && cold_start {
- // Scale is applied via wlr-randr; we just store the value
- // The actual wlr-randr call would happen in the Wayland integration layer
- }
+ // [output] scale — applied via wlr-output-management protocol.
+ // After storing the scale, set pending_scale_apply so that the next
+ // output_manager done event triggers the configuration. This handles
+ // both initial startup and VT-switch-back (where wlroots resets scale to 1).
+ state.output_scale = if config.output.scale > 0.0 {
+ state.pending_scale_apply = true;
+ config.output.scale
+ } else {
+ 0.0
+ };
// Signal config-done
state.config_done = true;
@@ -258,10 +266,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
/// Extract the program name (first word, basename) from a command string
fn extract_program_name(cmd: &str) -> String {
let cmd = cmd.trim_start();
- let first_word: String = cmd
- .chars()
- .take_while(|c| !c.is_whitespace())
- .collect();
+ let first_word: String = cmd.chars().take_while(|c| !c.is_whitespace()).collect();
if let Some(slash) = first_word.rfind('/') {
first_word[slash + 1..].to_string()
} else {
@@ -281,26 +286,37 @@ pub fn parse_keysym(key_str: &str) -> u32 {
xkbcommon::xkb::keysym_from_name(name, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into()
}
-/// Spawn a command in the background (double-fork style)
+/// Spawn a command in the background.
+///
+/// Closes all inherited FDs > 2 in the child via pre_exec so that
+/// spawned Wayland clients (fuzzel, foot, etc.) never accidentally
+/// read from clearwm's Wayland socket fd. Also redirects stdout/stderr
+/// to /dev/null so child output doesn't pollute clearwm's log, and
+/// calls setsid() to detach from clearwm's process group.
pub fn spawn_command_bg(cmd: &str) {
use std::os::unix::process::CommandExt;
let cmd = cmd.to_string();
- // Double-fork: first fork setsid, second fork execs
- // Safety: pre_exec is unsafe because it runs between fork and exec.
- // We only call setsid() which is async-signal-safe.
let _ = unsafe {
std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
+ .env_remove("WAYLAND_DEBUG")
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
.pre_exec(|| {
+ // Close all inherited FDs > 2 to prevent the child from
+ // accidentally reading clearwm's Wayland socket or status
+ // socket FDs. close() and setsid() are async-signal-safe.
+ let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
+ for fd in 3..max_fd {
+ libc::close(fd);
+ }
libc::setsid();
Ok(())
})
.spawn()
};
}
-
-/// Check if a process with the given name is already running
pub fn process_running(name: &str) -> bool {
match std::process::Command::new("pgrep")
.arg("-x")
@@ -337,3 +353,30 @@ mod tests {
assert_eq!(lc.border_color, "#3e3e3e");
}
}
+
+#[cfg(test)]
+mod startup_format_tests {
+ use super::*;
+
+ #[test]
+ fn test_startup_entry_format() {
+ let toml_str = r#"
+[env]
+XDG_CURRENT_DESKTOP = "river"
+
+[[startup]]
+exec = "waybar"
+
+[[startup]]
+exec = "fuzzel"
+once = true
+"#;
+ let config: Config = toml::from_str(toml_str).expect("TOML parse failed");
+ assert_eq!(config.startup.len(), 2);
+ assert_eq!(config.startup[0].exec, "waybar");
+ assert!(!config.startup[0].once);
+ assert_eq!(config.startup[1].exec, "fuzzel");
+ assert!(config.startup[1].once);
+ assert_eq!(config.env.get("XDG_CURRENT_DESKTOP").unwrap(), "river");
+ }
+}
diff --git a/src/ipc.rs b/src/ipc.rs
index 9833a6c..9892e21 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -28,16 +28,43 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
}
}
"close" => {
- // Will be handled by the WM layer - mark that close was requested
- // for the focused window
+ // Close the focused window
if let Some(seat) = state.seats.first() {
- if seat.focused_window_id.is_some() {
- // The actual river_window_v1_close() call happens in wm.rs
+ if let Some(focused_id) = seat.focused_window_id {
+ if let Some(window) = state.get_window_mut(focused_id) {
+ window.closed = true;
+ }
}
}
+ state.needs_render = true;
}
"focus-next" => {
- // Will be handled by the WM layer
+ // Focus the next visible window (wrapping) and move it to the
+ // front of the cascade stack (end of windows vector).
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ let focused_id = seat.focused_window_id;
+ let active_tags = state.active_tags;
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if visible_ids.len() > 1 {
+ if let Some(fid) = focused_id {
+ if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
+ let next_idx = (idx + 1) % visible_ids.len();
+ let next_id = visible_ids[next_idx];
+ seat.focused_window_id = Some(next_id);
+ // Move newly focused window to front of cascade stack
+ state.move_window_to_end(next_id);
+ }
+ }
+ }
+ }
+ state.needs_render = true;
+ state.needs_focus = true;
+ state.needs_status_update = true;
}
"exit" => {
// Signal exit request
@@ -53,6 +80,18 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
if let Some(tag) = tag {
if tag >= 1 && tag <= NUM_TAGS as i32 {
state.active_tags = 1 << (tag - 1);
+
+ // Reassign focus to a visible window on the new tag
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.needs_focus = true;
}
}
}
@@ -61,6 +100,30 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
if let Some(tag) = tag {
if tag >= 1 && tag <= NUM_TAGS as i32 {
state.active_tags ^= 1 << (tag - 1);
+
+ // If the focused window is no longer visible, reassign focus
+ let focused_id = state
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+ let focused_still_visible = focused_id.map_or(false, |fid| {
+ state
+ .get_window(fid)
+ .map_or(false, |w| (w.tags & state.active_tags) != 0 && !w.closed)
+ });
+ if !focused_still_visible {
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.needs_focus = true;
+ }
}
}
}
@@ -236,7 +299,11 @@ fn handle_bind_command(rest: &str, state: &mut WindowManager) {
};
let action = parse_action(action_str);
- let command = if action == Action::Spawn { command } else { None };
+ let command = if action == Action::Spawn {
+ command
+ } else {
+ None
+ };
state.pending_bindings.push(PendingXkbBinding {
mods,
@@ -287,8 +354,35 @@ fn handle_set_tag_command(rest: &str, state: &mut WindowManager) {
let tag_str = rest.trim();
if let Ok(tag) = tag_str.parse::<i32>() {
if tag >= 1 && tag <= NUM_TAGS as i32 {
- if let Some(window) = state.focused_window_mut() {
- window.tags = 1 << (tag - 1);
+ // Read focused_id before any mutable borrow
+ let focused_id = state
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+ if let Some(focused_id) = focused_id {
+ // Set the window's tag
+ let active_tags = state.active_tags;
+ let window_left_active_tag = state
+ .get_window_mut(focused_id)
+ .map_or(false, |window| {
+ window.tags = 1 << (tag - 1);
+ (window.tags & active_tags) == 0
+ });
+
+ // If the window is no longer on an active tag, shift focus
+ if window_left_active_tag {
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.needs_focus = true;
+ }
}
}
}
diff --git a/src/lib.rs b/src/lib.rs
index d69dfc7..16a5f31 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@ pub mod tiling;
pub mod ipc;
pub mod borders;
pub mod status;
+pub mod status_server;
pub mod restart;
pub mod wm;
#[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
diff --git a/src/main.rs b/src/main.rs
index d7cd851..9f41779 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,21 +1,20 @@
// clearwm — Wayland window manager for river
use clearwm::config::parse_config;
-use clearwm::ipc::handle_ipc_command;
use clearwm::restart;
-use clearwm::status::update_status_files;
+use clearwm::status_server;
use clearwm::wayland::wayland_init;
use std::env;
use std::fs;
-use std::io::Read;
-use std::os::unix::io::AsRawFd;
-use std::os::unix::net::{UnixListener, UnixStream};
const SOCKET_PATH: &str = "/tmp/clearwm.sock";
fn main() {
eprintln!("clearwm starting...");
+ // Start the status socket server thread (for waybar integration)
+ let status_sender = status_server::spawn_status_server();
+
// Set up SIGCHLD handler to reap child processes
let sa = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::Handler(sigchld_handler),
@@ -27,14 +26,8 @@ fn main() {
.expect("failed to set SIGCHLD handler");
}
- // Create IPC socket (non-blocking for polling)
- let ipc_listener = create_ipc_socket();
- if let Some(ref listener) = ipc_listener {
- listener.set_nonblocking(true).ok();
- }
-
// Connect to Wayland display and get initial state.
- let (conn, mut event_queue, mut state) = match wayland_init() {
+ let (_conn, mut event_queue, mut state) = match wayland_init() {
Ok(c) => c,
Err(e) => {
eprintln!("fatal: {}", e);
@@ -42,14 +35,8 @@ fn main() {
}
};
- // Dispatch any events buffered during init
- let _ = event_queue.dispatch_pending(&mut state);
-
- // Flush and trigger manage cycle
- let _ = conn.flush();
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ // Store the status sender in the app state so RenderStart can push updates
+ state.status_sender = Some(status_sender);
// Check if this is a restart
let cold_start = if env::var("CLEARWM_RESTARTING").as_deref() == Ok("1") {
@@ -59,28 +46,43 @@ fn main() {
true
};
- let mut need_config_load = true;
env::remove_var("WAYLAND_DEBUG");
- // Main event loop.
- // Uses blocking_dispatch() which is the Rust equivalent of the C version's
- // wl_display_dispatch() — it blocks until events are available, then reads
- // and dispatches them. This is the simplest and most reliable pattern.
+ // Load config immediately, before entering the event loop.
+ // This ensures bindings are registered before any render_start arrives.
+ // Following the tinyrwm pattern: do setup, then simple blocking_dispatch loop.
+ eprintln!(
+ "[init] about to load config, render_count={}",
+ state.render_count
+ );
+ let config_start = std::time::Instant::now();
+ if let Ok(home) = env::var("HOME") {
+ let config_path = format!("{}/.config/clearwm/config.toml", home);
+ if fs::metadata(&config_path).is_ok() {
+ parse_config(&config_path, cold_start, &mut state.wm);
+ }
+ }
+ eprintln!(
+ "[init] config loaded in {:?}, render_count={}",
+ config_start.elapsed(),
+ state.render_count
+ );
- eprintln!("[DEBUG] entering main loop");
+ // Apply output scale immediately if heads were discovered during init roundtrips.
+ if state.wm.pending_scale_apply && state.wm.output_scale > 0.0 && !state.output_heads.is_empty()
+ {
+ let qh = event_queue.handle();
+ clearwm::wayland::apply_output_scale(&mut state, &qh);
+ }
- loop {
- // Flush outgoing Wayland requests
- if let Err(e) = conn.flush() {
- eprintln!("wayland flush error: {:?}", e);
- break;
- }
+ // Flush any queued requests from config loading (bindings, etc.)
+ eprintln!("[init] flushed, entering main loop");
- eprintln!("[DEBUG] calling blocking_dispatch...");
+ // Main loop — tinyrwm pattern: just blocking_dispatch in a loop.
+ // All work (including spawning) happens inside Dispatch callbacks.
+ loop {
match event_queue.blocking_dispatch(&mut state) {
- Ok(n) => {
- eprintln!("[DEBUG] blocking_dispatch returned Ok({})", n);
- }
+ Ok(_) => {}
Err(e) => {
eprintln!("wayland dispatch error: {:?}", e);
if !state.wm.exit_requested {
@@ -89,60 +91,6 @@ fn main() {
break;
}
}
- update_status_files(&state.wm);
-
- // Handle IPC connections (non-blocking)
- if let Some(ref listener) = ipc_listener {
- while let Ok((stream, _)) = listener.accept() {
- handle_ipc_connection(stream, &mut state.wm);
- }
- update_status_files(&state.wm);
- }
-
- // Deferred config loading — must happen AFTER we've handled at least
- // one render_start/render_finish cycle. But we also need to keep
- // handling render cycles DURING config loading, because River's
- // 3-second timer expects continuous responsiveness.
- if need_config_load {
- eprintln!("[DEBUG] loading config...");
- if let Ok(home) = env::var("HOME") {
- let config_path = format!("{}/.config/clearwm/config.toml", home);
- if fs::metadata(&config_path).is_ok() {
- parse_config(&config_path, cold_start, &mut state.wm);
- }
- }
-
- // After config loading, we MUST flush and dispatch before
- // continuing — River may have sent render_start while we
- // were busy. Do a non-blocking flush+read+dispatch cycle.
- let _ = conn.flush();
- if let Some(guard) = event_queue.prepare_read() {
- // Non-blocking read using libc::recv with MSG_DONTWAIT
- let fd = guard.connection_fd().as_raw_fd();
- let mut buf = [0u8; 4096];
- let _ = unsafe {
- libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), libc::MSG_DONTWAIT)
- };
- let _ = guard.read();
- }
- let pending = event_queue.dispatch_pending(&mut state).unwrap_or(0);
- if pending > 0 {
- eprintln!("[DEBUG] dispatched {} events after config", pending);
- update_status_files(&state.wm);
- }
-
- // Trigger manage cycle for bindings
- if state.wm.config_done {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
- let _ = conn.flush();
- }
-
- need_config_load = false;
- eprintln!("[DEBUG] config loaded, continuing loop");
- }
-
if state.exit_requested || state.wm.exit_requested {
break;
}
@@ -154,34 +102,6 @@ fn main() {
eprintln!("main loop exited");
}
-fn create_ipc_socket() -> Option<UnixListener> {
- let _ = fs::remove_file(SOCKET_PATH);
- match UnixListener::bind(SOCKET_PATH) {
- Ok(listener) => {
- use std::os::unix::fs::PermissionsExt;
- let _ = fs::set_permissions(SOCKET_PATH, fs::Permissions::from_mode(0o600));
- Some(listener)
- }
- Err(e) => {
- eprintln!("failed to create IPC socket: {}", e);
- let _ = fs::remove_file(SOCKET_PATH);
- UnixListener::bind(SOCKET_PATH).ok()
- }
- }
-}
-
-fn handle_ipc_connection(mut stream: UnixStream, state: &mut clearwm::types::WindowManager) {
- let mut buf = [0u8; 4096];
- match stream.read(&mut buf) {
- Ok(n) if n > 0 => {
- let cmd = String::from_utf8_lossy(&buf[..n]);
- handle_ipc_command(cmd.trim(), state);
- state.needs_render = true;
- }
- _ => {}
- }
-}
-
extern "C" fn sigchld_handler(_sig: nix::libc::c_int) {
while nix::sys::wait::waitpid(
nix::unistd::Pid::from_raw(-1),
diff --git a/src/protocol.rs b/src/protocol.rs
index b8573aa..de21dea 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -29,16 +29,26 @@ pub mod river_window_management {
}
pub mod river_xkb_bindings {
- river_protocol!("protocol/river-xkb-bindings-v1.xml",
- [crate::protocol::river_window_management::generated]);
+ river_protocol!(
+ "protocol/river-xkb-bindings-v1.xml",
+ [crate::protocol::river_window_management::generated]
+ );
}
pub mod river_layer_shell {
- river_protocol!("protocol/river-layer-shell-v1.xml",
- [crate::protocol::river_window_management::generated]);
+ river_protocol!(
+ "protocol/river-layer-shell-v1.xml",
+ [crate::protocol::river_window_management::generated]
+ );
}
pub mod river_input_management {
- river_protocol!("protocol/river-input-management-v1.xml",
- [crate::protocol::river_window_management::generated]);
+ river_protocol!(
+ "protocol/river-input-management-v1.xml",
+ [crate::protocol::river_window_management::generated]
+ );
+}
+
+pub mod wlr_output_management {
+ river_protocol!("protocol/wlr-output-management-unstable-v1.xml", []);
}
diff --git a/src/restart.rs b/src/restart.rs
index d2284ca..733e4dd 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -5,11 +5,12 @@ use crate::types::WindowManager;
/// Restart the window manager process.
///
-/// This uses execl() to replace the current process with a fresh instance.
+/// This forks a child process that waits briefly for the parent to die
+/// (so River cleans up the old Wayland connection), then execs a fresh
+/// clearwm instance. The parent (current process) exits immediately.
+///
/// The CLEARWM_RESTARTING environment variable signals that this is a restart
/// (not a cold start), so the new process skips cold_start_only apps.
-///
-/// Ported from C wm_restart() with throttle logic.
pub fn wm_restart() {
use std::time::Instant;
@@ -41,9 +42,29 @@ pub fn wm_restart() {
let _ = std::fs::remove_file("/tmp/clearwm.sock");
// Get the current executable path
- if let Ok(exe_path) = std::env::current_exe() {
- let path_str = exe_path.to_string_lossy().to_string();
- // Use execl via libc to replace the current process
+ let Ok(exe_path) = std::env::current_exe() else {
+ std::process::exit(1);
+ };
+ let path_str = exe_path.to_string_lossy().to_string();
+
+ // Fork: child waits for parent to die, then execs fresh clearwm.
+ // Parent exits so River tears down the old Wayland connection.
+ let pid = unsafe { libc::fork() };
+ if pid < 0 {
+ // fork failed, just exit
+ std::process::exit(1);
+ } else if pid == 0 {
+ // Child: wait for parent to exit so River cleans up the old
+ // Wayland connection before we try to connect fresh.
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Close inherited Wayland FDs so we don't confuse River
+ // (close everything except stdin/stdout/stderr)
+ let max_fd = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) } as i32;
+ for fd in 3..max_fd {
+ unsafe { libc::close(fd); }
+ }
+
let ret = unsafe {
libc::execl(
path_str.as_ptr() as *const i8,
@@ -52,12 +73,13 @@ pub fn wm_restart() {
)
};
if ret < 0 {
- eprintln!("wm_restart: execl failed");
+ // If execl fails, just exit silently
+ std::process::exit(1);
}
+ } else {
+ // Parent: exit immediately so River sees the connection drop
+ std::process::exit(0);
}
-
- // If execl failed, exit
- std::process::exit(1);
}
/// Reload the configuration file.
@@ -75,6 +97,9 @@ pub fn wm_reload(state: &mut WindowManager) {
// Clear per-tag layout defaults
state.has_tag_layout = [false; crate::types::NUM_TAGS];
+ // Mark status for update after reload
+ state.needs_status_update = true;
+
// Clear mode rules
state.mode_rules.clear();
diff --git a/src/status.rs b/src/status.rs
index e00ff61..71f1866 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -6,12 +6,18 @@ use std::process::Command;
pub const NUM_TAGS: u32 = 4;
-/// Write all status files and signal waybar
-pub fn update_status_files(state: &WindowManager) {
+/// Write status files only (no pkill signaling). Safe to call inside
+/// Dispatch callbacks — no fork, no blocking, just file I/O.
+pub fn write_status_files(state: &WindowManager) {
+ use std::io::Write;
+
// /tmp/clearwm-tags: active_tags focused_tags num_tags
if let Ok(mut f) = fs::File::create("/tmp/clearwm-tags") {
- use std::io::Write;
- let _ = writeln!(f, "{} {} {}", state.active_tags, state.focused_tags, NUM_TAGS);
+ let _ = writeln!(
+ f,
+ "{} {} {}",
+ state.active_tags, state.focused_tags, NUM_TAGS
+ );
}
// /tmp/clearwm-layout: focused window's tiling mode
@@ -21,13 +27,11 @@ pub fn update_status_files(state: &WindowManager) {
.unwrap_or("none");
if let Ok(mut f) = fs::File::create("/tmp/clearwm-layout") {
- use std::io::Write;
let _ = writeln!(f, "{}", mode_str);
}
// /tmp/clearwm-windows: one line per window
if let Ok(mut f) = fs::File::create("/tmp/clearwm-windows") {
- use std::io::Write;
let focused_title = state.focused_window().map(|w| w.title.clone());
for win in &state.windows {
@@ -59,11 +63,17 @@ pub fn update_status_files(state: &WindowManager) {
// /tmp/clearwm-title
if let Some(title) = focused_title {
if let Ok(mut tf) = fs::File::create("/tmp/clearwm-title") {
- use std::io::Write;
let _ = writeln!(tf, "{}", title.as_deref().unwrap_or("(null)"));
}
}
}
+}
+
+/// Write all status files and signal waybar.
+/// WARNING: The pkill calls block the event loop. Do NOT call this
+/// inside a Dispatch callback. Use write_status_files() instead.
+pub fn update_status_files(state: &WindowManager) {
+ write_status_files(state);
// Signal waybar
let _ = Command::new("pkill").args(["-RTMIN+8", "waybar"]).output();
diff --git a/src/status_server.rs b/src/status_server.rs
new file mode 100644
index 0000000..9473cac
--- /dev/null
+++ b/src/status_server.rs
@@ -0,0 +1,335 @@
+// Status socket server for waybar integration
+//
+// Runs in a dedicated thread. Waybar custom module scripts connect to
+// /tmp/clearwm-status.sock, send a subscription line ("tags", "layout",
+// or "title"), and receive JSON lines whenever the status changes.
+//
+// The main loop sends updates through an mpsc channel — no blocking,
+// no fork, no pkill. The server thread owns the socket and handles
+// all I/O independently of the Wayland event loop.
+
+use std::io::{BufRead, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::mpsc;
+
+/// The socket path for the status server.
+pub const STATUS_SOCKET_PATH: &str = "/tmp/clearwm-status.sock";
+
+/// A status update sent from the main loop to the server thread.
+#[derive(Debug, Clone)]
+pub struct StatusUpdate {
+ /// JSON string for tags module subscribers
+ pub tags_json: String,
+ /// Plain text for layout module subscribers
+ pub layout_text: String,
+ /// Plain text for title module subscribers
+ pub title_text: String,
+}
+
+/// Subscription types that waybar scripts can request.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Subscription {
+ Tags,
+ Layout,
+ Title,
+ Unknown,
+}
+
+impl Subscription {
+ fn from_str(s: &str) -> Self {
+ match s.trim() {
+ "tags" => Subscription::Tags,
+ "layout" => Subscription::Layout,
+ "title" => Subscription::Title,
+ _ => Subscription::Unknown,
+ }
+ }
+}
+
+/// A connected client with a known subscription.
+struct Client {
+ subscription: Subscription,
+ stream: UnixStream,
+}
+
+/// Handle to the status server for sending updates from the main loop.
+#[derive(Debug)]
+pub struct StatusSender {
+ tx: mpsc::Sender<StatusUpdate>,
+}
+
+impl StatusSender {
+ pub fn send(&self, update: StatusUpdate) {
+ // If the channel is full or the receiver is gone, just drop it.
+ // Status updates are frequent; missing one is fine.
+ let _ = self.tx.send(update);
+ }
+}
+
+/// Spawn the status server thread. Returns a StatusSender for the main loop.
+pub fn spawn_status_server() -> StatusSender {
+ let (tx, rx) = mpsc::channel::<StatusUpdate>();
+
+ std::thread::Builder::new()
+ .name("clearwm-status".into())
+ .spawn(move || {
+ status_server_main(rx);
+ })
+ .expect("failed to spawn status server thread");
+
+ StatusSender { tx }
+}
+
+fn status_server_main(rx: mpsc::Receiver<StatusUpdate>) {
+ // Remove stale socket
+ let _ = std::fs::remove_file(STATUS_SOCKET_PATH);
+
+ let listener = match UnixListener::bind(STATUS_SOCKET_PATH) {
+ Ok(l) => l,
+ Err(e) => {
+ eprintln!("[status] failed to bind {}: {}", STATUS_SOCKET_PATH, e);
+ return;
+ }
+ };
+
+ // Set non-blocking so accept() doesn't hang the thread
+ if let Err(e) = listener.set_nonblocking(true) {
+ eprintln!("[status] failed to set non-blocking: {}", e);
+ return;
+ }
+
+ eprintln!("[status] listening on {}", STATUS_SOCKET_PATH);
+
+ let mut clients: Vec<Client> = Vec::new();
+ let mut latest: Option<StatusUpdate> = None;
+
+ loop {
+ // Accept new connections (non-blocking)
+ for _ in 0..5 {
+ match listener.accept() {
+ Ok((mut stream, _addr)) => {
+ if let Err(e) = stream.set_nonblocking(true) {
+ eprintln!("[status] failed to set non-blocking on client: {}", e);
+ continue;
+ }
+ // Read the subscription line
+ let subscription = read_subscription(&stream);
+ if subscription == Subscription::Unknown {
+ eprintln!("[status] client sent unknown subscription, dropping");
+ continue;
+ }
+ eprintln!("[status] new client subscribed: {:?}", subscription);
+
+ // Send current state immediately so waybar shows data on startup
+ if let Some(ref update) = latest {
+ let msg = format_for_subscription(subscription, update);
+ let _ = stream.write_all(msg.as_bytes());
+ let _ = stream.write_all(b"\n");
+ }
+
+ clients.push(Client {
+ subscription,
+ stream,
+ });
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ break; // No more pending connections
+ }
+ Err(e) => {
+ eprintln!("[status] accept error: {}", e);
+ break;
+ }
+ }
+ }
+
+ // Receive status updates from the main loop
+ // Use try_recv in a loop to drain all pending updates (only the latest matters)
+ loop {
+ match rx.try_recv() {
+ Ok(update) => {
+ latest = Some(update);
+ }
+ Err(mpsc::TryRecvError::Empty) => break,
+ Err(mpsc::TryRecvError::Disconnected) => {
+ eprintln!("[status] channel disconnected, exiting");
+ let _ = std::fs::remove_file(STATUS_SOCKET_PATH);
+ return;
+ }
+ }
+ }
+
+ // If we got an update, push it to all clients
+ if let Some(ref update) = latest {
+ let mut dead_clients = Vec::new();
+
+ for (i, client) in clients.iter_mut().enumerate() {
+ let msg = format_for_subscription(client.subscription, update);
+ match client
+ .stream
+ .write_all(msg.as_bytes())
+ .and_then(|_| client.stream.write_all(b"\n"))
+ {
+ Ok(_) => {}
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ // Client not ready to receive — skip for now
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
+ eprintln!(
+ "[status] client {:?} disconnected (broken pipe)",
+ client.subscription
+ );
+ dead_clients.push(i);
+ }
+ Err(e) => {
+ eprintln!(
+ "[status] write error to client {:?}: {}",
+ client.subscription, e
+ );
+ dead_clients.push(i);
+ }
+ }
+ }
+
+ // Remove dead clients (iterate in reverse to preserve indices)
+ for i in dead_clients.into_iter().rev() {
+ clients.remove(i);
+ }
+ }
+
+ // Small sleep to avoid busy-looping when nothing is happening
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+}
+
+/// Read the subscription line from a newly connected client.
+/// The client sends one line: "tags", "layout", or "title".
+fn read_subscription(stream: &UnixStream) -> Subscription {
+ use std::io::BufReader;
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ // Try to read with a small timeout
+ stream
+ .set_read_timeout(Some(std::time::Duration::from_millis(100)))
+ .ok();
+ match reader.read_line(&mut line) {
+ Ok(_) => Subscription::from_str(&line),
+ Err(e) => {
+ eprintln!("[status] failed to read subscription: {}", e);
+ Subscription::Unknown
+ }
+ }
+}
+
+/// Format the relevant part of a StatusUpdate for a given subscription.
+fn format_for_subscription(sub: Subscription, update: &StatusUpdate) -> String {
+ match sub {
+ Subscription::Tags => update.tags_json.clone(),
+ Subscription::Layout => update.layout_text.clone(),
+ Subscription::Title => update.title_text.clone(),
+ Subscription::Unknown => String::new(),
+ }
+}
+
+/// Build a StatusUpdate from the current WindowManager state.
+/// This is the same logic that write_status_files() uses, but produces
+/// the data for the socket instead of writing to files.
+pub fn build_status_update(wm: &crate::types::WindowManager) -> StatusUpdate {
+ // Tags: generate the same pango-marked JSON that clearwm-tags.sh produces
+ let tags_json = render_tags_json(
+ wm.active_tags,
+ wm.focused_tags,
+ crate::types::NUM_TAGS as u32,
+ );
+
+ // Layout: focused window's tiling mode
+ let layout_text = wm
+ .focused_window()
+ .map(|w| w.tiling_mode.as_str().to_string())
+ .unwrap_or_else(|| "none".to_string());
+
+ // Title: focused window's title
+ let title_text = wm
+ .focused_window()
+ .and_then(|w| w.title.clone())
+ .unwrap_or_else(|| "(none)".to_string());
+
+ StatusUpdate {
+ tags_json,
+ layout_text,
+ title_text,
+ }
+}
+
+/// Render tag state as a JSON string with pango markup, matching the format
+/// produced by the old clearwm-tags.sh script.
+///
+/// Colors:
+/// - Active + Focused: bright (#a8c0d8)
+/// - Focused only: dim (#666666)
+/// - Active only: medium (#888888)
+/// - Neither: dark (#444444)
+fn render_tags_json(active: u32, focused: u32, num_tags: u32) -> String {
+ let mut text = String::new();
+ for i in 0..num_tags {
+ let bit = 1u32 << i;
+ let label = i + 1;
+
+ let is_active = (active & bit) != 0;
+ let is_focused = (focused & bit) != 0;
+
+ let color = if is_focused && is_active {
+ "#a8c0d8"
+ } else if is_focused {
+ "#666666"
+ } else if is_active {
+ "#888888"
+ } else {
+ "#444444"
+ };
+
+ text.push_str(&format!("<span color='{}'>{}</span>", color, label));
+ }
+
+ // waybar expects JSON: {"text": "...", "tooltip": "Tags"}
+ // Need to escape the pango markup for JSON
+ let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
+ format!("{{\"text\": \"{}\", \"tooltip\": \"Tags\"}}", escaped)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_render_tags_json_single_tag() {
+ let json = render_tags_json(1, 1, 4);
+ // Tag 1 should be active+focused (#a8c0d8), tags 2-4 should be dark (#444444)
+ assert!(json.contains("#a8c0d8"), "tag 1 should be bright: {}", json);
+ assert!(
+ json.contains("#444444"),
+ "inactive tags should be dark: {}",
+ json
+ );
+ assert!(json.starts_with("{\"text\":"));
+ }
+
+ #[test]
+ fn test_render_tags_json_no_focus() {
+ let json = render_tags_json(1, 0, 4);
+ // Tag 1 is active but not focused → #888888
+ assert!(
+ json.contains("#888888"),
+ "active unfocused should be medium: {}",
+ json
+ );
+ }
+
+ #[test]
+ fn test_subscription_from_str() {
+ assert_eq!(Subscription::from_str("tags"), Subscription::Tags);
+ assert_eq!(Subscription::from_str("layout"), Subscription::Layout);
+ assert_eq!(Subscription::from_str("title"), Subscription::Title);
+ assert_eq!(Subscription::from_str("foo"), Subscription::Unknown);
+ assert_eq!(Subscription::from_str("tags\n"), Subscription::Tags);
+ }
+}
diff --git a/src/tiling.rs b/src/tiling.rs
index 88f7af8..ef0b3d1 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -72,6 +72,62 @@ pub fn tile_grid(
(x, y, width, height)
}
+/// Tile a window in vsplit mode (vertical splits — windows side by side).
+///
+/// Each window gets an equal share of the horizontal space.
+///
+/// n = total windows in vsplit
+/// width = (screen_w - (n + 1) * gap) / n - 2 * bw
+/// height = screen_h - (gap + bw) * 2 - bar_height
+/// x = gap + bw + idx * (width + 2 * bw + gap)
+/// y = bar_height + gap + bw
+pub fn tile_vsplit(
+ screen_w: i32,
+ screen_h: i32,
+ gap: i32,
+ bw: i32,
+ bar_height: i32,
+ n_vsplit: i32,
+ idx: i32,
+) -> (i32, i32, i32, i32) {
+ let n = if n_vsplit < 1 { 1 } else { n_vsplit };
+ let width = (screen_w - (n + 1) * gap) / n - 2 * bw;
+ let height = screen_h - (gap + bw) * 2 - bar_height;
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+ let x = gap + bw + idx * (width + 2 * bw + gap);
+ let y = bar_height + gap + bw;
+ (x, y, width, height)
+}
+
+/// Tile a window in hsplit mode (horizontal splits — windows stacked vertically).
+///
+/// Each window gets an equal share of the vertical space.
+///
+/// n = total windows in hsplit
+/// width = screen_w - (gap + bw) * 2
+/// height = (screen_h - bar_height - (n + 1) * gap) / n - 2 * bw
+/// x = gap + bw
+/// y = bar_height + gap + bw + idx * (height + 2 * bw + gap)
+pub fn tile_hsplit(
+ screen_w: i32,
+ screen_h: i32,
+ gap: i32,
+ bw: i32,
+ bar_height: i32,
+ n_hsplit: i32,
+ idx: i32,
+) -> (i32, i32, i32, i32) {
+ let n = if n_hsplit < 1 { 1 } else { n_hsplit };
+ let width = screen_w - (gap + bw) * 2;
+ let height = (screen_h - bar_height - (n + 1) * gap) / n - 2 * bw;
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+ let x = gap + bw;
+ let y = bar_height + gap + bw + idx * (height + 2 * bw + gap);
+ (x, y, width, height)
+}
+
/// Interpolate a fixed-point channel (0xRR000000) by factor^depth.
///
/// depth 0 returns the base color unchanged; each step darkens by factor.
@@ -112,7 +168,7 @@ mod tests {
let (x, y, w, h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 1, 0);
assert_eq!(x, 36); // gap + bw
assert_eq!(y, 64); // bar_height + gap + bw
- // w = 1920 - (18+18)*2 - 32*0 = 1920 - 72 = 1848
+ // w = 1920 - (18+18)*2 - 32*0 = 1920 - 72 = 1848
assert_eq!(w, 1848);
// h = 1080 - (18+18)*2 - 32*0 = 1080 - 72 = 1008
assert_eq!(h, 1008);
@@ -212,4 +268,78 @@ mod tests {
let color = cascade_hex_color(0x3E000000, 0x3E000000, 0x3E000000, 0);
assert_eq!(color, "#3e3e3e");
}
+
+ #[test]
+ fn test_tile_vsplit_single() {
+ // Single vsplit window fills screen minus gaps/borders/bar
+ let (x, y, w, h) = tile_vsplit(1920, 1080, 18, 18, 28, 1, 0);
+ assert_eq!(x, 36); // gap + bw
+ assert_eq!(y, 64); // bar_height + gap + bw
+ // w = (1920 - 2*18) / 1 - 2*18 = 1884 - 36 = 1848
+ assert_eq!(w, 1848);
+ // h = 1080 - (18+18)*2 - 28 = 1080 - 72 - 28 = 980
+ assert_eq!(h, 980);
+ }
+
+ #[test]
+ fn test_tile_vsplit_two() {
+ // Two vsplit windows side by side
+ let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 18, 18, 28, 2, 0);
+ let (x1, y1, w1, h1) = tile_vsplit(1920, 1080, 18, 18, 28, 2, 1);
+
+ // Same dimensions
+ assert_eq!(w0, w1);
+ assert_eq!(h0, h1);
+ // Same y (same row)
+ assert_eq!(y0, y1);
+ // Window 1 is to the right
+ assert!(x1 > x0);
+
+ // w = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+ assert_eq!(w0, 897);
+ }
+
+ #[test]
+ fn test_tile_vsplit_minimum_size() {
+ let (_, _, w, h) = tile_vsplit(100, 100, 18, 18, 28, 10, 5);
+ assert!(w >= 1);
+ assert!(h >= 1);
+ }
+
+ #[test]
+ fn test_tile_hsplit_single() {
+ // Single hsplit window fills screen minus gaps/borders/bar
+ let (x, y, w, h) = tile_hsplit(1920, 1080, 18, 18, 28, 1, 0);
+ assert_eq!(x, 36); // gap + bw
+ assert_eq!(y, 64); // bar_height + gap + bw
+ // w = 1920 - (18+18)*2 = 1920 - 72 = 1848
+ assert_eq!(w, 1848);
+ // h = (1080 - 28 - 2*18) / 1 - 2*18 = 1016 - 36 = 980
+ assert_eq!(h, 980);
+ }
+
+ #[test]
+ fn test_tile_hsplit_two() {
+ // Two hsplit windows stacked vertically
+ let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 18, 18, 28, 2, 0);
+ let (x1, y1, w1, h1) = tile_hsplit(1920, 1080, 18, 18, 28, 2, 1);
+
+ // Same dimensions
+ assert_eq!(w0, w1);
+ assert_eq!(h0, h1);
+ // Same x (same column)
+ assert_eq!(x0, x1);
+ // Window 1 is below window 0
+ assert!(y1 > y0);
+
+ // h = (1080 - 28 - 3*18) / 2 - 2*18 = (1080-28-54)/2 - 36 = 499 - 36 = 463
+ assert_eq!(h0, 463);
+ }
+
+ #[test]
+ fn test_tile_hsplit_minimum_size() {
+ let (_, _, w, h) = tile_hsplit(100, 100, 18, 18, 28, 10, 5);
+ assert!(w >= 1);
+ assert!(h >= 1);
+ }
}
diff --git a/src/types.rs b/src/types.rs
index f668380..2f40721 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -115,6 +115,7 @@ pub struct PendingPointerBinding {
/// to execute when the binding is triggered.
#[derive(Debug, Clone)]
pub struct BindingUserData {
+ pub seat_id: u64,
pub action: Action,
pub command: Option<String>,
}
@@ -193,7 +194,7 @@ impl Default for Window {
title: None,
identifier: None,
parent_id: None,
- decoration_hint: 3, // no_preference
+ decoration_hint: 3, // no_preference
presentation_hint: 0, // vsync
tiling_mode: TilingMode::Floating,
mode_locked: false,
@@ -210,6 +211,8 @@ pub struct Seat {
pub focused_window_id: Option<u64>,
pub hovered_window_id: Option<u64>,
pub interacted_window_id: Option<u64>,
+ pub pending_action: Action,
+ pub pending_command: Option<String>,
}
impl Default for Seat {
@@ -221,6 +224,8 @@ impl Default for Seat {
focused_window_id: None,
hovered_window_id: None,
interacted_window_id: None,
+ pending_action: Action::None,
+ pending_command: None,
}
}
}
@@ -240,13 +245,21 @@ pub struct WindowManager {
pub config_done: bool,
pub in_manage_sequence: bool,
pub needs_render: bool,
+ pub needs_focus: bool,
+ pub needs_status_update: bool,
pub exit_requested: bool,
pub global_layout: TilingMode,
pub tag_layouts: [TilingMode; NUM_TAGS],
pub has_tag_layout: [bool; NUM_TAGS],
pub input_devices: Vec<InputDevice>,
- pub last_bg_color: String,
pub env_vars: HashMap<String, String>,
+ pub pending_startup_apps: Vec<String>,
+ pub startup_spawned: bool,
+ pub output_scale: f64,
+ /// When true, apply configured output_scale via wlr-output-management
+ /// on the next output_manager done event. Set by config load and
+ /// by VT-switch-back (where wlroots resets scale to 1).
+ pub pending_scale_apply: bool,
}
impl Default for WindowManager {
@@ -263,14 +276,19 @@ impl Default for WindowManager {
focused_tags: 0,
config_done: false,
in_manage_sequence: false,
- needs_render: true, // render on first frame
+ needs_render: true, // render on first frame
+ needs_focus: true, // focus on first frame
+ needs_status_update: true, // update status files on first cycle
exit_requested: false,
global_layout: TilingMode::Cascade,
tag_layouts: [TilingMode::Cascade; NUM_TAGS],
has_tag_layout: [false; NUM_TAGS],
input_devices: Vec::new(),
- last_bg_color: String::new(),
env_vars: HashMap::new(),
+ pending_startup_apps: Vec::new(),
+ startup_spawned: false,
+ output_scale: 0.0,
+ pending_scale_apply: false,
}
}
}
@@ -310,6 +328,24 @@ impl WindowManager {
};
self.get_window_mut(wid)
}
+
+ /// Move a window to the end of the windows vector.
+ /// This makes it the last cascade window (front of visual stack,
+ /// rightmost/bottommost position, brightest border).
+ /// Returns true if the window was moved, false if not found or already last.
+ pub fn move_window_to_end(&mut self, id: u64) -> bool {
+ let idx = match self.windows.iter().position(|w| w.id == id) {
+ Some(i) => i,
+ None => return false,
+ };
+ // Already last?
+ if idx == self.windows.len() - 1 {
+ return false;
+ }
+ let win = self.windows.remove(idx);
+ self.windows.push(win);
+ true
+ }
}
/// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA" into
@@ -364,11 +400,16 @@ pub fn parse_action(s: &str) -> Action {
Action::Restart
} else if s == "fullscreen" {
Action::Fullscreen
- } else if s.starts_with("spawn") && (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-') {
+ } else if s.starts_with("spawn")
+ && (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-')
+ {
Action::Spawn
} else if s.starts_with("view") {
let rest = &s[4..];
- let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+ let tag_str = rest
+ .strip_prefix('-')
+ .or_else(|| rest.strip_prefix(' '))
+ .unwrap_or(rest);
if let Ok(tag) = tag_str.parse::<i32>() {
if tag >= 1 && tag <= NUM_TAGS as i32 {
return match tag {
@@ -383,7 +424,10 @@ pub fn parse_action(s: &str) -> Action {
Action::None
} else if s.starts_with("toggle") {
let rest = &s[6..];
- let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+ let tag_str = rest
+ .strip_prefix('-')
+ .or_else(|| rest.strip_prefix(' '))
+ .unwrap_or(rest);
if let Ok(tag) = tag_str.parse::<i32>() {
if tag >= 1 && tag <= NUM_TAGS as i32 {
return match tag {
@@ -398,7 +442,10 @@ pub fn parse_action(s: &str) -> Action {
Action::None
} else if s.starts_with("set-tag") {
let rest = &s[7..];
- let tag_str = rest.strip_prefix('-').or_else(|| rest.strip_prefix(' ')).unwrap_or(rest);
+ let tag_str = rest
+ .strip_prefix('-')
+ .or_else(|| rest.strip_prefix(' '))
+ .unwrap_or(rest);
if let Ok(tag) = tag_str.parse::<i32>() {
if tag >= 1 && tag <= NUM_TAGS as i32 {
return match tag {
diff --git a/src/wayland.rs b/src/wayland.rs
index b8e114c..1c08015 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -1,7 +1,8 @@
// Wayland display connection, registry, and event dispatch for clearwm
use wayland_client::{
- event_created_child, protocol::wl_registry, Connection, Dispatch, EventQueue, Proxy, QueueHandle,
+ event_created_child, protocol::wl_registry, Connection, Dispatch, EventQueue, Proxy,
+ QueueHandle,
};
use crate::protocol::river_input_management::client::{
@@ -25,14 +26,22 @@ use crate::protocol::river_xkb_bindings::client::{
river_xkb_bindings_seat_v1::{self, RiverXkbBindingsSeatV1},
river_xkb_bindings_v1::{self, RiverXkbBindingsV1},
};
+use crate::protocol::wlr_output_management::client::{
+ zwlr_output_configuration_head_v1::{self, ZwlrOutputConfigurationHeadV1},
+ zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1},
+ zwlr_output_head_v1::{self, ZwlrOutputHeadV1},
+ zwlr_output_manager_v1::{self, ZwlrOutputManagerV1},
+ zwlr_output_mode_v1::{self, ZwlrOutputModeV1},
+};
-use crate::types::{BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
+use crate::types::{Action, BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
// Interface name constants (from river protocol XML)
const IFACE_WINDOW_MANAGER: &str = "river_window_manager_v1";
const IFACE_XKB_BINDINGS: &str = "river_xkb_bindings_v1";
const IFACE_LAYER_SHELL: &str = "river_layer_shell_v1";
const IFACE_INPUT_MANAGER: &str = "river_input_manager_v1";
+const IFACE_WLR_OUTPUT_MANAGER: &str = "zwlr_output_manager_v1";
/// Wayland proxy objects stored alongside each Window, so we can
/// call protocol methods (set_position, propose_dimensions, etc.) on it.
@@ -74,11 +83,42 @@ pub struct AppState {
// River node proxies for window positioning (created via get_node request)
pub window_nodes: Vec<(u64, RiverNodeV1)>,
+ // Active binding proxies — must be held alive for bindings to remain registered.
+ // On reload, these are cleared (which destroys the old protocol objects) before
+ // new bindings are created.
+ pub xkb_binding_proxies: Vec<RiverXkbBindingV1>,
+ pub pointer_binding_proxies: Vec<RiverPointerBindingV1>,
+
// Next ID counter for new windows/outputs/seats
pub next_id: u64,
// Exit flag
pub exit_requested: bool,
+
+ // Debug: render cycle counter
+ pub render_count: u32,
+
+ // --- wlr-output-management protocol state ---
+ pub output_manager: Option<ZwlrOutputManagerV1>,
+ /// Latest serial from the output manager's done event.
+ /// Required to create a valid configuration.
+ pub output_serial: u32,
+ /// Discovered output heads: (head_proxy, name, enabled, current_scale)
+ /// The head proxy must be kept alive to reference it in configurations.
+ pub output_heads: Vec<OutputHeadInfo>,
+ /// Pending configuration (alive until succeeded/failed/cancelled)
+ pub output_config: Option<ZwlrOutputConfigurationV1>,
+
+ // --- Status socket sender for waybar ---
+ pub status_sender: Option<crate::status_server::StatusSender>,
+}
+
+/// Tracked info for a wlr-output-management head.
+pub struct OutputHeadInfo {
+ pub proxy: ZwlrOutputHeadV1,
+ pub name: String,
+ pub enabled: bool,
+ pub scale: f64,
}
impl AppState {
@@ -95,8 +135,16 @@ impl AppState {
seat_proxies: Vec::new(),
output_proxies: Vec::new(),
window_nodes: Vec::new(),
+ xkb_binding_proxies: Vec::new(),
+ pointer_binding_proxies: Vec::new(),
next_id: 1,
exit_requested: false,
+ render_count: 0,
+ output_manager: None,
+ output_serial: 0,
+ output_heads: Vec::new(),
+ output_config: None,
+ status_sender: None,
}
}
@@ -197,6 +245,11 @@ impl Dispatch<wl_registry::WlRegistry, RegistryData> for AppState {
let im: RiverInputManagerV1 =
registry.bind::<RiverInputManagerV1, _, _>(name, 1, qhandle, ());
state.input_manager = Some(im);
+ } else if interface == IFACE_WLR_OUTPUT_MANAGER {
+ eprintln!("registry: binding {}", IFACE_WLR_OUTPUT_MANAGER);
+ let om: ZwlrOutputManagerV1 =
+ registry.bind::<ZwlrOutputManagerV1, _, _>(name, 4, qhandle, ());
+ state.output_manager = Some(om);
}
}
wl_registry::Event::GlobalRemove { name: _ } => {}
@@ -219,7 +272,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
wm_proxy: &RiverWindowManagerV1,
event: river_window_manager_v1::Event,
_data: &(),
- _conn: &Connection,
+ conn: &Connection,
qhandle: &QueueHandle<Self>,
) {
match event {
@@ -240,7 +293,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
river_window_manager_v1::Event::ManageStart => {
- eprintln!("manage sequence start");
+ let ms_start = std::time::Instant::now();
state.wm.in_manage_sequence = true;
state.wm.focused_tags = 0;
state.wm.needs_render = true;
@@ -276,20 +329,169 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
}
+ // Assign tiling modes to windows based on mode_rules / tag_layouts / global_layout.
+ // Must happen before manage_windows so tiling computation uses the correct modes.
+ crate::wm::assign_window_modes(&mut state.wm);
+
+ // Window management: set position + propose dimensions.
+ // These modify window management state and can ONLY be called
+ // during a manage sequence (per River protocol spec).
+ crate::wm::manage_windows(state, qhandle);
+
+ // Focus management: focus the focused window on each seat.
+ // focus_window() modifies window management state.
+ // Only call when needs_focus is set to avoid stealing focus from
+ // layer-shell clients (like fuzzel) that trigger manage sequences.
+ if state.wm.needs_focus {
+ for seat in &state.wm.seats {
+ if seat.removed {
+ continue;
+ }
+ if let Some(focused_id) = seat.focused_window_id {
+ if let Some((_id, sp)) =
+ state.seat_proxies.iter().find(|(id, _)| *id == seat.id)
+ {
+ if let Some(wp) = state.get_window_proxy(focused_id) {
+ eprintln!("[focus] calling focus_window for id={}", focused_id);
+ sp.river_seat.focus_window(&wp.river_window);
+ }
+ }
+ } else {
+ // No focused window — clear focus
+ if let Some((_id, sp)) =
+ state.seat_proxies.iter().find(|(id, _)| *id == seat.id)
+ {
+ eprintln!("[focus] calling clear_focus");
+ sp.river_seat.clear_focus();
+ }
+ }
+ }
+ state.wm.needs_focus = false;
+ }
+
+ // Execute pending actions from key/pointer bindings (tinyrwm pattern).
+ // Like tinyrwm, we defer action execution to ManageStart so all
+ // state mutations happen during the manage sequence.
+ // Collect pending actions first to avoid borrow checker issues
+ // (execute_action borrows state mutably).
+ let pending: Vec<(Action, Option<String>)> = state
+ .wm
+ .seats
+ .iter_mut()
+ .filter_map(|seat| {
+ if seat.pending_action != Action::None {
+ let action = seat.pending_action;
+ let command = seat.pending_command.take();
+ seat.pending_action = Action::None;
+ Some((action, command))
+ } else {
+ None
+ }
+ })
+ .collect();
+ for (action, command) in pending {
+ execute_action(state, &action, command.as_deref());
+ }
+
wm_proxy.manage_finish();
state.wm.in_manage_sequence = false;
- crate::status::update_status_files(&state.wm);
+ eprintln!("[manage] ManageStart done in {:?}", ms_start.elapsed());
+ // NOTE: Do NOT call update_status_files() here — it calls
+ // pkill with .output() which blocks the event loop.
}
river_window_manager_v1::Event::RenderStart => {
- eprintln!("EVENT: RenderStart (needs_render={})", state.wm.needs_render);
+ state.render_count += 1;
+ eprintln!(
+ "[render] RenderStart #{} needs_render={}",
+ state.render_count, state.wm.needs_render
+ );
+
+ // Show/hide windows based on tag visibility.
+ // This is rendering state and must happen during a render sequence.
+ let active_tags = state.wm.active_tags;
+ for window in &state.wm.windows {
+ if window.closed {
+ continue;
+ }
+ let visible = (window.tags & active_tags) != 0;
+ if let Some(wp) = state.get_window_proxy(window.id) {
+ if visible {
+ wp.river_window.show();
+ } else {
+ wp.river_window.hide();
+ }
+ }
+ }
+
if state.wm.needs_render {
- crate::wm::render_windows(state, qhandle);
+ // Only do rendering state here: borders, place_top/place_bottom.
+ // set_position and propose_dimensions are window management state
+ // and must happen during ManageStart.
+ crate::wm::render_borders(state);
+
+ // Raise the focused window to the top of the visual stack.
+ // place_top() modifies rendering state and must be called
+ // during a render sequence.
+ if let Some(seat) = state.wm.seats.iter().find(|s| !s.removed) {
+ if let Some(focused_id) = seat.focused_window_id {
+ if let Some(node) = state
+ .window_nodes
+ .iter()
+ .find(|(id, _)| *id == focused_id)
+ .map(|(_, n)| n)
+ {
+ eprintln!(
+ "[render] place_top for focused window id={}",
+ focused_id
+ );
+ node.place_top();
+ }
+ }
+ }
+
state.wm.needs_render = false;
}
wm_proxy.render_finish();
- eprintln!("EVENT: RenderFinish sent");
+ eprintln!("[render] render_finish #{} queued", state.render_count);
+
+ // Spawn startup apps inside the callback, like tinyrwm does.
+ // Spawning between blocking_dispatch calls corrupts the Wayland
+ // connection state because the fork inherits the socket fd.
+ if !state.wm.startup_spawned {
+ let apps: Vec<String> = state.wm.pending_startup_apps.drain(..).collect();
+ for cmd in &apps {
+ eprintln!("[init] spawning startup app: {}", cmd);
+ match std::process::Command::new("sh")
+ .arg("-c")
+ .arg(cmd)
+ .env_remove("WAYLAND_DEBUG")
+ .spawn()
+ {
+ Ok(_) => eprintln!("[init] spawned ok: {}", cmd),
+ Err(e) => eprintln!("[init] failed to spawn '{}': {e}", cmd),
+ }
+ }
+ state.wm.startup_spawned = true;
+ }
+
+ // Write status files after all manage+render state is settled.
+ // Uses the deferred flag pattern: handlers set needs_status_update,
+ // we check it here inside the Dispatch callback.
+ // write_status_files() does only file I/O — no fork, no pkill.
+ if state.wm.needs_status_update {
+ crate::status::write_status_files(&state.wm);
+
+ // Push the same data through the status socket so waybar
+ // gets updates in real-time without needing signal-based pkill.
+ if let Some(ref sender) = state.status_sender {
+ let update = crate::status_server::build_status_update(&state.wm);
+ sender.send(update);
+ }
+
+ state.wm.needs_status_update = false;
+ }
}
// Window event: field is `id` (the new RiverWindowV1 proxy)
@@ -483,16 +685,30 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
}
}
- river_seat_v1::Event::WindowInteraction { window: river_window } => {
+ river_seat_v1::Event::WindowInteraction {
+ window: river_window,
+ } => {
if let Some(wid) = state.window_id_for_proxy(&river_window) {
if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+ eprintln!(
+ "[focus] WindowInteraction: seat={} focused_window_id={} -> {}",
+ sid,
+ seat.focused_window_id.unwrap_or(0),
+ wid
+ );
seat.focused_window_id = Some(wid);
+ // Move clicked window to front of cascade stack
+ state.wm.move_window_to_end(wid);
state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
}
}
}
- river_seat_v1::Event::PointerEnter { window: river_window } => {
+ river_seat_v1::Event::PointerEnter {
+ window: river_window,
+ } => {
if let Some(wid) = state.window_id_for_proxy(&river_window) {
if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
seat.hovered_window_id = Some(wid);
@@ -577,8 +793,10 @@ impl Dispatch<RiverLayerShellOutputV1, ()> for AppState {
height,
} => {
if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.id == oid) {
- if output.usable_width != width || output.usable_height != height
- || output.usable_x != x || output.usable_y != y
+ if output.usable_width != width
+ || output.usable_height != height
+ || output.usable_x != x
+ || output.usable_y != y
{
output.usable_x = x;
output.usable_y = y;
@@ -640,7 +858,10 @@ impl Dispatch<RiverInputDeviceV1, ()> for AppState {
river_input_device_v1::Event::Type { _type: dev_type } => {
if let Some(dev) = state.wm.input_devices.last_mut() {
// dev_type is WEnum<Type>; check for Keyboard variant
- dev.is_keyboard = matches!(dev_type, wayland_client::WEnum::Value(river_input_device_v1::Type::Keyboard));
+ dev.is_keyboard = matches!(
+ dev_type,
+ wayland_client::WEnum::Value(river_input_device_v1::Type::Keyboard)
+ );
}
}
river_input_device_v1::Event::Name { name } => {
@@ -665,7 +886,18 @@ impl Dispatch<RiverPointerBindingV1, BindingUserData> for AppState {
) {
match event {
river_pointer_binding_v1::Event::Pressed => {
- execute_action(state, &data.action, data.command.as_deref());
+ eprintln!(
+ "[binding] pointer pressed: action={:?} seat_id={}",
+ data.action, data.seat_id
+ );
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == data.seat_id) {
+ seat.pending_action = data.action.clone();
+ seat.pending_command = data.command.clone();
+ }
+ // Trigger a manage sequence so the pending action is processed
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
}
_ => {}
}
@@ -697,7 +929,18 @@ impl Dispatch<RiverXkbBindingV1, BindingUserData> for AppState {
) {
match event {
river_xkb_binding_v1::Event::Pressed => {
- execute_action(state, &data.action, data.command.as_deref());
+ eprintln!(
+ "[binding] xkb pressed: action={:?} command={:?} seat_id={}",
+ data.action, data.command, data.seat_id
+ );
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == data.seat_id) {
+ seat.pending_action = data.action.clone();
+ seat.pending_command = data.command.clone();
+ }
+ // Trigger a manage sequence so the pending action is processed
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
}
river_xkb_binding_v1::Event::Released => {}
river_xkb_binding_v1::Event::StopRepeat => {}
@@ -743,40 +986,64 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
Action::Spawn => {
if let Some(cmd) = command {
eprintln!("spawn: {}", cmd);
- unsafe {
- match nix::unistd::fork() {
- Ok(nix::unistd::ForkResult::Child) => {
- nix::unistd::setsid().ok();
- let cmd_c = std::ffi::CString::new(cmd).unwrap();
- nix::unistd::execvp(
- &std::ffi::CString::new("/bin/sh").unwrap(),
- &[
- std::ffi::CString::new("sh").unwrap(),
- std::ffi::CString::new("-c").unwrap(),
- cmd_c,
- ],
- )
- .ok();
- libc::_exit(127);
- }
- Ok(nix::unistd::ForkResult::Parent { .. }) => {}
- Err(_) => {}
- }
- }
+ // Close inherited FDs > 2 in the child so that spawned
+ // Wayland clients (fuzzel, etc.) never accidentally read
+ // from clearwm's Wayland socket fd. This prevents protocol
+ // corruption and the CPU spin loop that results from it.
+ use std::os::unix::process::CommandExt;
+ let _ = unsafe {
+ std::process::Command::new("sh")
+ .arg("-c")
+ .arg(cmd)
+ .env_remove("WAYLAND_DEBUG")
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .pre_exec(|| {
+ let max_fd =
+ libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
+ for fd in 3..max_fd {
+ libc::close(fd);
+ }
+ libc::setsid();
+ Ok(())
+ })
+ .spawn()
+ };
}
}
Action::Close => {
- // Close the focused window
+ // Mark the focused window for closing. The actual close() call
+ // happens during the ManageStart sequence, since close()
+ // modifies window management state and can only be called during
+ // a manage sequence.
if let Some(seat) = state.wm.seats.first() {
if let Some(focused_id) = seat.focused_window_id {
- if let Some(wp) = state.get_window_proxy(focused_id) {
- wp.river_window.close();
+ if let Some(window) = state.wm.get_window_mut(focused_id) {
+ window.closed = true;
+ }
+ // Shift focus to the next visible window (excluding the one we just closed)
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| {
+ (w.tags & state.wm.active_tags) != 0 && !w.closed && w.id != focused_id
+ })
+ .map(|w| w.id)
+ .collect();
+ let next_id = visible_ids.last().copied();
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = next_id;
}
}
}
+ state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
}
Action::FocusNext => {
- // Focus the next visible window (wrapping)
+ // Focus the next visible window (wrapping) and move it to the
+ // front of the cascade stack (end of windows vector).
if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
let focused_id = seat.focused_window_id;
let visible_ids: Vec<u64> = state
@@ -789,11 +1056,19 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
if let Some(fid) = focused_id {
if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
let next_idx = (idx + 1) % visible_ids.len();
- seat.focused_window_id = Some(visible_ids[next_idx]);
+ let next_id = visible_ids[next_idx];
+ seat.focused_window_id = Some(next_id);
+ // Move the newly focused window to the end of the
+ // windows vector so it gets the front cascade position
+ // (rightmost/bottommost) and brightest border.
+ state.wm.move_window_to_end(next_id);
state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
}
}
}
+ // Trigger a manage sequence so focus_window() is called
}
Action::Move => {
// TODO: pointer move
@@ -811,23 +1086,66 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
Action::Fullscreen => {
if let Some(seat) = state.wm.seats.first() {
if let Some(focused_id) = seat.focused_window_id {
+ // Read current state and compute target mode before mutable borrow
+ let is_fullscreen = state
+ .wm
+ .get_window(focused_id)
+ .map(|w| w.tiling_mode == TilingMode::Fullscreen)
+ .unwrap_or(false);
+
+ let resolved_mode = if is_fullscreen {
+ // Exiting fullscreen: snapshot the window info we need for mode resolution
+ let (app_id, title, tags, mode_locked) = state
+ .wm
+ .get_window(focused_id)
+ .map(|w| (w.app_id.clone(), w.title.clone(), w.tags, w.mode_locked))
+ .unwrap_or((None, None, 1, false));
+ // Build a temporary Window for mode resolution
+ let temp_win = Window {
+ id: focused_id,
+ is_new: false,
+ closed: false,
+ tags,
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ app_id,
+ title,
+ identifier: None,
+ parent_id: None,
+ decoration_hint: 3,
+ presentation_hint: 0,
+ tiling_mode: TilingMode::Fullscreen,
+ mode_locked,
+ };
+ crate::wm::get_mode_for_window(&state.wm, &temp_win)
+ .unwrap_or(state.wm.global_layout)
+ } else {
+ TilingMode::Fullscreen
+ };
+
if let Some(window) = state.wm.get_window_mut(focused_id) {
- if window.tiling_mode == TilingMode::Fullscreen {
- window.tiling_mode = TilingMode::Cascade; // TODO: get_mode_for_window
+ if is_fullscreen {
+ window.tiling_mode = resolved_mode;
+ window.mode_locked = false;
} else {
window.tiling_mode = TilingMode::Fullscreen;
+ window.mode_locked = true;
}
- window.mode_locked = true;
state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
}
}
}
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
}
Action::LayoutNext => {
- let cycle = [TilingMode::Cascade, TilingMode::Grid, TilingMode::Vsplit, TilingMode::Hsplit];
+ let cycle = [
+ TilingMode::Cascade,
+ TilingMode::Grid,
+ TilingMode::Vsplit,
+ TilingMode::Hsplit,
+ ];
let current = state.wm.global_layout;
let next = cycle
.iter()
@@ -836,10 +1154,15 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
.unwrap_or(TilingMode::Cascade);
state.wm.global_layout = next;
eprintln!("layout-next: global layout is now {}", next.as_str());
+
+ // Unlock windows that got their mode from the layout (not from mode_rules
+ // or manual set-mode) so assign_window_modes will reassign them.
+ // Windows with mode_locked=true were explicitly set by the user and stay.
+ // Windows matched by mode_rules will get reassigned to the same rule mode.
+ // Only windows that fell through to global_layout will change.
+
state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.wm.needs_status_update = true;
}
Action::Reload => {
// TODO: implement reload (re-run config)
@@ -857,10 +1180,22 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
_ => return,
};
state.wm.active_tags = 1 << (tag - 1);
- state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
+
+ // Reassign focus to a visible window on the new tag
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = visible_ids.last().copied();
}
+
+ state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
}
Action::Toggle1 | Action::Toggle2 | Action::Toggle3 | Action::Toggle4 => {
let tag = match action {
@@ -871,10 +1206,36 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
_ => return,
};
state.wm.active_tags ^= 1 << (tag - 1);
- state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
+
+ // If the focused window is no longer visible, reassign focus
+ let focused_id = state
+ .wm
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+ let focused_still_visible = focused_id.map_or(false, |fid| {
+ state
+ .wm
+ .get_window(fid)
+ .map_or(false, |w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+ });
+ if !focused_still_visible {
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.wm.needs_focus = true;
}
+
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
}
Action::SetTag1 | Action::SetTag2 | Action::SetTag3 | Action::SetTag4 => {
let tag = match action {
@@ -884,16 +1245,41 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
Action::SetTag4 => 4,
_ => return,
};
- if let Some(seat) = state.wm.seats.first() {
- if let Some(focused_id) = seat.focused_window_id {
- if let Some(window) = state.wm.get_window_mut(focused_id) {
+ // Read focused_id before any mutable borrow
+ let focused_id = state
+ .wm
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+ if let Some(focused_id) = focused_id {
+ // Set the window's tag
+ let active_tags = state.wm.active_tags;
+ let window_left_active_tag = state
+ .wm
+ .get_window_mut(focused_id)
+ .map_or(false, |window| {
window.tags = 1 << (tag - 1);
- state.wm.needs_render = true;
+ (window.tags & active_tags) == 0
+ });
+
+ // If the window is no longer on an active tag, shift focus
+ if window_left_active_tag {
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed)
+ .map(|w| w.id)
+ .collect();
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = visible_ids.last().copied();
}
+ state.wm.needs_focus = true;
}
- }
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
+
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
}
}
}
@@ -936,6 +1322,10 @@ fn u32_to_modifiers(mods: u32) -> Modifiers {
}
fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+ // Clear old binding proxies (destroying them unregisters the bindings)
+ state.xkb_binding_proxies.clear();
+ state.pointer_binding_proxies.clear();
+
// Create xkb binding seats for any seats that don't have one yet
if state.xkb_bindings.is_some() {
for (_sid, sp) in &mut state.seat_proxies {
@@ -949,18 +1339,31 @@ fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>)
// Apply xkb bindings to each seat
let bindings: Vec<_> = state.wm.pending_bindings.drain(..).collect();
+ eprintln!(
+ "[bindings] applying {} xkb bindings to {} seats",
+ bindings.len(),
+ state.seat_proxies.len()
+ );
for pb in &bindings {
- for (_sid, sp) in &state.seat_proxies {
+ for (sid, sp) in &state.seat_proxies {
if let Some(ref xb) = state.xkb_bindings {
if let Some(ref _xbs) = sp.xkb_bindings_seat {
let modifiers = u32_to_modifiers(pb.mods);
let binding_data = BindingUserData {
+ seat_id: *sid,
action: pb.action.clone(),
command: pb.command.clone(),
};
- let binding =
- xb.get_xkb_binding(&sp.river_seat, pb.keysym, modifiers, qhandle, binding_data);
+ let binding = xb.get_xkb_binding(
+ &sp.river_seat,
+ pb.keysym,
+ modifiers,
+ qhandle,
+ binding_data,
+ );
binding.enable();
+ // Store the proxy so it stays alive (binding would be unregistered on drop)
+ state.xkb_binding_proxies.push(binding);
}
}
}
@@ -969,19 +1372,354 @@ fn apply_pending_bindings(state: &mut AppState, qhandle: &QueueHandle<AppState>)
// Apply pointer bindings to each seat
let ptr_bindings: Vec<_> = state.wm.pending_pointer_bindings.drain(..).collect();
for ppb in &ptr_bindings {
- for (_sid, sp) in &state.seat_proxies {
+ for (sid, sp) in &state.seat_proxies {
let modifiers = u32_to_modifiers(ppb.mods);
let binding_data = BindingUserData {
+ seat_id: *sid,
action: ppb.action.clone(),
command: None,
};
- let _pb = sp
- .river_seat
- .get_pointer_binding(ppb.button, modifiers, qhandle, binding_data);
+ let pb =
+ sp.river_seat
+ .get_pointer_binding(ppb.button, modifiers, qhandle, binding_data);
+ // Store the proxy so it stays alive
+ state.pointer_binding_proxies.push(pb);
}
}
}
+// --- wlr-output-management dispatch implementations ---
+
+/// Helper: convert an f64 scale value to the wl_fixed_t format
+/// used by the Wayland protocol set_scale method.
+/// wayland-scanner already converts wl_fixed -> f64 for events,
+/// but set_scale takes f64 directly (the scanner handles the conversion).
+fn _f64_to_wl_fixed(v: f64) -> i32 {
+ (v * 256.0) as i32
+}
+
+impl Dispatch<ZwlrOutputManagerV1, ()> for AppState {
+ event_created_child!(AppState, ZwlrOutputManagerV1, [
+ zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
+ ]);
+
+ fn event(
+ state: &mut Self,
+ _proxy: &ZwlrOutputManagerV1,
+ event: zwlr_output_manager_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ zwlr_output_manager_v1::Event::Head { head: _ } => {
+ eprintln!("[output-mgmt] Head event (new head advertised)");
+ }
+ zwlr_output_manager_v1::Event::Done { serial } => {
+ eprintln!(
+ "[output-mgmt] Done serial={} pending_scale_apply={} output_scale={} heads={}",
+ serial,
+ state.wm.pending_scale_apply,
+ state.wm.output_scale,
+ state.output_heads.len()
+ );
+ for h in &state.output_heads {
+ eprintln!(
+ "[output-mgmt] head {} enabled={} scale={}",
+ h.name, h.enabled, h.scale
+ );
+ }
+ state.output_serial = serial;
+
+ // If config requested scale application and we have heads, apply it
+ if state.wm.pending_scale_apply && state.wm.output_scale > 0.0 {
+ apply_output_scale(state, qhandle);
+ }
+ }
+ zwlr_output_manager_v1::Event::Finished => {
+ eprintln!(
+ "[output-mgmt] manager finished, heads before clear: {}",
+ state.output_heads.len()
+ );
+ state.output_manager = None;
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputHeadV1, ()> for AppState {
+ event_created_child!(AppState, ZwlrOutputHeadV1, [
+ zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
+ ]);
+
+ fn event(
+ state: &mut Self,
+ proxy: &ZwlrOutputHeadV1,
+ event: zwlr_output_head_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ qhandle: &QueueHandle<Self>,
+ ) {
+ // Find or create the head entry by proxy ID
+ let pid = proxy.id().protocol_id();
+
+ match event {
+ zwlr_output_head_v1::Event::Name { name } => {
+ // Check if we already track this head
+ if let Some(head) = state
+ .output_heads
+ .iter_mut()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ {
+ head.name = name.clone();
+ } else {
+ state.output_heads.push(OutputHeadInfo {
+ proxy: proxy.clone(),
+ name,
+ enabled: false,
+ scale: 1.0,
+ });
+ }
+ eprintln!(
+ "[output-mgmt] head name={}",
+ state
+ .output_heads
+ .iter()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ .map(|h| h.name.as_str())
+ .unwrap_or("?")
+ );
+ }
+ zwlr_output_head_v1::Event::Description { description } => {
+ let _ = description; // not needed for scale config
+ }
+ zwlr_output_head_v1::Event::PhysicalSize { width, height } => {
+ let _ = (width, height);
+ }
+ zwlr_output_head_v1::Event::Enabled { enabled } => {
+ let head_name = state
+ .output_heads
+ .iter()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ .map(|h| h.name.as_str())
+ .unwrap_or("?");
+ eprintln!(
+ "[output-mgmt] head {} Enabled enabled={} pending_scale_apply={}",
+ head_name, enabled, state.wm.pending_scale_apply
+ );
+ if let Some(head) = state
+ .output_heads
+ .iter_mut()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ {
+ head.enabled = enabled != 0;
+ }
+ }
+ zwlr_output_head_v1::Event::Scale { scale } => {
+ let head_name = state
+ .output_heads
+ .iter()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ .map(|h| h.name.as_str())
+ .unwrap_or("?");
+ eprintln!(
+ "[output-mgmt] head {} Scale={} output_scale={} pending_scale_apply={}",
+ head_name, scale, state.wm.output_scale, state.wm.pending_scale_apply
+ );
+ // wayland-scanner converts wl_fixed to f64 automatically
+ if let Some(head) = state
+ .output_heads
+ .iter_mut()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ {
+ // Detect VT-switch-back: compositor reports a scale that
+ // differs from our configured output_scale. This covers
+ // both: (a) the old head had scale=X and compositor sends 1.0,
+ // and (b) the head was re-created after Finished (so its
+ // internal scale was reset to 1.0) and compositor sends 1.0
+ // instead of the configured scale.
+ if state.wm.output_scale > 0.0
+ && head.enabled
+ && (scale - state.wm.output_scale).abs() > 0.01
+ {
+ eprintln!(
+ "[output-mgmt] scale mismatch (got {}, configured {}), will re-apply",
+ scale, state.wm.output_scale
+ );
+ state.wm.pending_scale_apply = true;
+ }
+ head.scale = scale;
+ }
+ }
+ zwlr_output_head_v1::Event::Finished => {
+ let head_name = state
+ .output_heads
+ .iter()
+ .find(|h| h.proxy.id().protocol_id() == pid)
+ .map(|h| h.name.as_str())
+ .unwrap_or("?");
+ eprintln!(
+ "[output-mgmt] head {} Finished (removing from heads)",
+ head_name
+ );
+ state
+ .output_heads
+ .retain(|h| h.proxy.id().protocol_id() != pid);
+ }
+ zwlr_output_head_v1::Event::CurrentMode { mode: _ } => {}
+ zwlr_output_head_v1::Event::Position { x, y } => {
+ let _ = (x, y);
+ }
+ zwlr_output_head_v1::Event::Transform { transform } => {
+ let _ = transform;
+ }
+ zwlr_output_head_v1::Event::Make { make } => {
+ let _ = make;
+ }
+ zwlr_output_head_v1::Event::Model { model } => {
+ let _ = model;
+ }
+ zwlr_output_head_v1::Event::SerialNumber { serial_number } => {
+ let _ = serial_number;
+ }
+ zwlr_output_head_v1::Event::AdaptiveSync { state: _ } => {}
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputModeV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ZwlrOutputModeV1,
+ event: zwlr_output_mode_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ zwlr_output_mode_v1::Event::Size { width, height } => {
+ let _ = (width, height);
+ }
+ zwlr_output_mode_v1::Event::Refresh { refresh } => {
+ let _ = refresh;
+ }
+ zwlr_output_mode_v1::Event::Preferred => {}
+ zwlr_output_mode_v1::Event::Finished => {}
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputConfigurationV1, ()> for AppState {
+ event_created_child!(AppState, ZwlrOutputConfigurationV1, [
+ zwlr_output_configuration_v1::REQ_ENABLE_HEAD_OPCODE => (ZwlrOutputConfigurationHeadV1, ()),
+ ]);
+
+ fn event(
+ state: &mut Self,
+ _proxy: &ZwlrOutputConfigurationV1,
+ event: zwlr_output_configuration_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ zwlr_output_configuration_v1::Event::Succeeded => {
+ eprintln!("[output-mgmt] configuration succeeded");
+ state.output_config = None;
+ }
+ zwlr_output_configuration_v1::Event::Failed => {
+ eprintln!("[output-mgmt] configuration failed");
+ state.output_config = None;
+ }
+ zwlr_output_configuration_v1::Event::Cancelled => {
+ eprintln!("[output-mgmt] configuration cancelled (stale serial, will retry)");
+ state.output_config = None;
+ // Re-queue: the done event will fire again with a fresh serial
+ state.wm.pending_scale_apply = true;
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ZwlrOutputConfigurationHeadV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ZwlrOutputConfigurationHeadV1,
+ _event: zwlr_output_configuration_head_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ // zwlr_output_configuration_head_v1 has no events — it's request-only
+ }
+}
+
+/// Apply the configured output scale via the wlr-output-management protocol.
+/// Called when a `done` event arrives and `pending_scale_apply` is true.
+pub fn apply_output_scale(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+ let Some(ref om) = state.output_manager else {
+ eprintln!("[output-mgmt] no output_manager, cannot apply scale");
+ return;
+ };
+
+ if state.output_heads.is_empty() {
+ eprintln!("[output-mgmt] no heads discovered yet, deferring scale apply");
+ return;
+ }
+
+ // Check if any enabled head actually needs a scale change
+ let target_scale = state.wm.output_scale;
+ let needs_change = state
+ .output_heads
+ .iter()
+ .any(|h| h.enabled && (h.scale - target_scale).abs() > 0.01);
+ if !needs_change {
+ eprintln!(
+ "[output-mgmt] all heads already at target scale {}",
+ target_scale
+ );
+ state.wm.pending_scale_apply = false;
+ return;
+ }
+
+ eprintln!(
+ "[output-mgmt] applying scale {} to {} head(s)",
+ target_scale,
+ state.output_heads.len()
+ );
+
+ let serial = state.output_serial;
+
+ // Destroy any existing pending configuration
+ if let Some(ref config) = state.output_config {
+ config.destroy();
+ }
+ state.output_config = None;
+
+ // Create a new configuration object
+ let config = om.create_configuration(serial, qhandle, ());
+
+ // For each head: enable it and set the scale
+ for head in &state.output_heads {
+ if head.enabled {
+ let config_head = config.enable_head(&head.proxy, qhandle, ());
+ config_head.set_scale(target_scale);
+ } else {
+ config.disable_head(&head.proxy);
+ }
+ }
+
+ config.apply();
+
+ // Store the config proxy so it stays alive until succeeded/failed/cancelled
+ state.output_config = Some(config);
+ state.wm.pending_scale_apply = false;
+}
+
/// Connect to the Wayland display and set up the event queue.
/// Returns the Connection, EventQueue, and AppState after the initial registry roundtrip.
pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), String> {
@@ -995,9 +1733,16 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
// Do initial roundtrip to receive global events and bind protocols
let mut state = AppState::new();
+ eprintln!("[init] first roundtrip starting...");
+ let rt1 = std::time::Instant::now();
event_queue
.roundtrip(&mut state)
.map_err(|e| format!("initial roundtrip failed: {:?}", e))?;
+ eprintln!(
+ "[init] first roundtrip done in {:?}, render_count={}",
+ rt1.elapsed(),
+ state.render_count
+ );
// Check we got the required protocols
if !state.has_window_manager {
@@ -1008,9 +1753,16 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
}
// Second roundtrip for input device events
+ eprintln!("[init] second roundtrip starting...");
+ let rt2 = std::time::Instant::now();
event_queue
.roundtrip(&mut state)
.map_err(|e| format!("second roundtrip failed: {:?}", e))?;
+ eprintln!(
+ "[init] second roundtrip done in {:?}, render_count={}",
+ rt2.elapsed(),
+ state.render_count
+ );
eprintln!("clearwm: Wayland connection established");
diff --git a/src/wm.rs b/src/wm.rs
index 436437b..d7de94d 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -4,16 +4,81 @@
// the same work as the C version's wm_handle_render_start():
// 1. Tile visible windows (propose dimensions + set position)
// 2. Set border colors (cascade depth gradient or normal gray)
-// 3. Update desktop background via swaybg
use crate::borders::compute_border_colors;
use crate::protocol::river_window_management::client::river_node_v1::RiverNodeV1;
use crate::protocol::river_window_management::client::river_window_v1::Edges;
use crate::tiling;
-use crate::types::{TilingMode, WindowManager};
+use crate::types::{TilingMode, Window, WindowManager, NUM_TAGS};
use crate::wayland::AppState;
use wayland_client::QueueHandle;
+/// Determine the tiling mode for a window based on mode_rules, tag_layouts,
+/// and global_layout (in priority order).
+///
+/// Returns the resolved TilingMode, or None if the window's mode is locked
+/// (i.e., the user manually set it and it should not be overridden).
+pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMode> {
+ // If the user manually locked the mode (via set-mode, fullscreen toggle, etc.),
+ // don't override it.
+ if win.mode_locked {
+ return None;
+ }
+
+ // 1. Check mode_rules for a match on app_id/title
+ for rule in &wm.mode_rules {
+ let match_app = rule.app_id_pattern == "*"
+ || win
+ .app_id
+ .as_deref()
+ .map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+ let match_title = rule.title_pattern.as_deref() == Some("*")
+ || rule.title_pattern.is_none()
+ || win.title.as_deref().map_or(false, |t| {
+ t.contains(rule.title_pattern.as_deref().unwrap_or(""))
+ });
+
+ if match_app && match_title {
+ return Some(rule.mode);
+ }
+ }
+
+ // 2. Check tag_layouts for the window's active tags
+ for tag_bit in 0..NUM_TAGS {
+ let tag_mask = 1u32 << tag_bit;
+ if (win.tags & tag_mask) != 0 && wm.has_tag_layout[tag_bit] {
+ return Some(wm.tag_layouts[tag_bit]);
+ }
+ }
+
+ // 3. Fall back to global_layout
+ Some(wm.global_layout)
+}
+
+/// Assign tiling modes to all windows that aren't mode_locked.
+/// Should be called during ManageStart before compute_tiling.
+pub fn assign_window_modes(wm: &mut WindowManager) {
+ // Collect assignments first (borrow checker: can't borrow wm mutably while iterating mode_rules)
+ let assignments: Vec<(u64, TilingMode)> = wm
+ .windows
+ .iter()
+ .filter(|w| !w.closed && !w.mode_locked)
+ .filter_map(|win| get_mode_for_window(wm, win).map(|mode| (win.id, mode)))
+ .collect();
+
+ for (wid, mode) in assignments {
+ if let Some(win) = wm.get_window_mut(wid) {
+ if win.tiling_mode != mode {
+ eprintln!(
+ "[mode] window {} (app_id={:?}): {:?} -> {:?}",
+ wid, win.app_id, win.tiling_mode, mode
+ );
+ win.tiling_mode = mode;
+ }
+ }
+ }
+}
+
/// A tiling result for a single window
struct TileResult {
wid: u64,
@@ -23,24 +88,36 @@ struct TileResult {
h: i32,
}
-/// Perform a full render cycle: tile windows and set borders.
-///
-/// This is called from the `render_start` handler only when `needs_render` is true.
-/// After this, the caller always calls `render_finish()`.
-pub fn render_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+/// Perform window management during a manage sequence.
+/// This calls set_position and propose_dimensions on visible windows.
+/// These modify window management state and can ONLY be called during
+/// a manage sequence (between ManageStart and ManageFinish).
+pub fn manage_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
let screen_dims = get_screen_dimensions(&state.wm);
let (screen_w, screen_h) = screen_dims;
- // Ensure each window that needs positioning has a river_node_v1
+ eprintln!(
+ "[manage] windows={} outputs={} screen={}x{}",
+ state.wm.windows.len(),
+ state.wm.outputs.len(),
+ screen_w,
+ screen_h
+ );
+
+ // Ensure each window has a river_node_v1 proxy for positioning
ensure_window_nodes(state, qhandle);
- // Compute tiling (read-only pass over windows)
+ // Compute tiling
let tile_results = compute_tiling(&state.wm, screen_w, screen_h);
- // Apply tiling results (mutations)
+ // Apply: set_position + propose_dimensions + update internal state
apply_tiling(state, &tile_results);
+}
- // Set border colors
+/// Set border colors on all visible windows.
+/// This modifies rendering state and is called during RenderStart.
+/// Border colors are applied with the next render_finish.
+pub fn render_borders(state: &mut AppState) {
set_borders(state);
}
@@ -106,6 +183,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
// Count windows per tiling mode
let mut n_cascade = 0i32;
let mut n_grid = 0i32;
+ let mut n_vsplit = 0i32;
+ let mut n_hsplit = 0i32;
for win in &wm.windows {
if (win.tags & wm.active_tags) == 0 || win.closed {
continue;
@@ -113,6 +192,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
match win.tiling_mode {
TilingMode::Cascade => n_cascade += 1,
TilingMode::Grid => n_grid += 1,
+ TilingMode::Vsplit => n_vsplit += 1,
+ TilingMode::Hsplit => n_hsplit += 1,
_ => {}
}
}
@@ -122,9 +203,7 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
.windows
.iter()
.find(|w| {
- (w.tags & wm.active_tags) != 0
- && !w.closed
- && w.tiling_mode == TilingMode::Fullscreen
+ (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
})
.map(|w| w.id);
@@ -132,6 +211,8 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
let mut results = Vec::new();
let mut idx_cascade = 0i32;
let mut idx_grid = 0i32;
+ let mut idx_vsplit = 0i32;
+ let mut idx_hsplit = 0i32;
for win in &wm.windows {
if (win.tags & wm.active_tags) == 0 || win.closed {
@@ -169,12 +250,48 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
idx_grid += 1;
(x, y, w, h)
}
- TilingMode::Vsplit | TilingMode::Hsplit => {
- // TODO: implement vsplit/hsplit
- continue;
+ TilingMode::Vsplit => {
+ let (x, y, w, h) = tiling::tile_vsplit(
+ screen_w, screen_h, gap, bw, bar_height, n_vsplit, idx_vsplit,
+ );
+ idx_vsplit += 1;
+ (x, y, w, h)
+ }
+ TilingMode::Hsplit => {
+ let (x, y, w, h) = tiling::tile_hsplit(
+ screen_w, screen_h, gap, bw, bar_height, n_hsplit, idx_hsplit,
+ );
+ idx_hsplit += 1;
+ (x, y, w, h)
}
TilingMode::Floating => {
- continue;
+ // Floating windows: don't tile them, but still propose
+ // dimensions so they don't end up at w=0 h=0 (which
+ // triggers River's unresponsive-client detection).
+ // Use the window's existing dimensions, or a reasonable
+ // default if unset.
+ let fw = if win.width > 0 {
+ win.width
+ } else {
+ screen_w * 2 / 3
+ };
+ let fh = if win.height > 0 {
+ win.height
+ } else {
+ screen_h * 2 / 3
+ };
+ let fx = if win.x != 0 || win.y != 0 {
+ win.x
+ } else {
+ gap + bw + offset * idx_cascade
+ };
+ let fy = if win.x != 0 || win.y != 0 {
+ win.y
+ } else {
+ gap + bw + bar_height + offset * idx_cascade
+ };
+ idx_cascade += 1;
+ (fx, fy, fw, fh)
}
};
@@ -214,7 +331,7 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
/// Set border colors on all visible windows.
fn set_borders(state: &mut AppState) {
- let (border_colors, bg_color) = compute_border_colors(&state.wm);
+ let border_colors = compute_border_colors(&state.wm);
for bc in &border_colors {
let wid = match state.wm.windows.get(bc.window_idx) {
@@ -228,39 +345,4 @@ fn set_borders(state: &mut AppState) {
.set_borders(edges, bc.width, bc.r, bc.g, bc.b, bc.a);
}
}
-
- // Update desktop background if cascade color changed
- if let Some(ref color) = bg_color {
- if *color != state.wm.last_bg_color {
- state.wm.last_bg_color = color.clone();
- spawn_swaybg(color);
- }
- }
-}
-
-/// Spawn swaybg with the given color (fire-and-forget).
-fn spawn_swaybg(color: &str) {
- let cmd = format!("pkill -f swaybg 2>/dev/null; swaybg -c '{}'", color);
- unsafe {
- match nix::unistd::fork() {
- Ok(nix::unistd::ForkResult::Child) => {
- nix::unistd::close(nix::libc::STDIN_FILENO).ok();
- nix::unistd::close(nix::libc::STDOUT_FILENO).ok();
- nix::unistd::close(nix::libc::STDERR_FILENO).ok();
- nix::unistd::execvp(
- &std::ffi::CString::new("/bin/sh").unwrap(),
- &[
- std::ffi::CString::new("sh").unwrap(),
- std::ffi::CString::new("-c").unwrap(),
- std::ffi::CString::new(cmd).unwrap(),
- ],
- )
- .ok();
- // If exec fails, exit child
- libc::_exit(127);
- }
- Ok(nix::unistd::ForkResult::Parent { .. }) => {}
- Err(_) => {}
- }
- }
}
diff --git a/start-river.sh b/start-river.sh
index a2cf546..4edb36f 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -5,6 +5,14 @@
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export WAYLAND_DISPLAY=wayland-1
+# Create the River init executable (clearwm launch script)
+# This must exist before River starts, and /tmp is cleared on reboot.
+cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
+#!/bin/sh
+exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm-test.log
+LAUNCH_EOF
+chmod +x /tmp/clearwm-rs-launch.sh
+
echo "Starting river with clearwm-rs..."
echo "Log will be at /tmp/river-clearwm.log"