Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
add state persistence, xprop parent detection, libinput tap-to-click, per-tag layouts, and window management improvements
- Add state persistence (~/.cache/clearwm_state) for restart recovery of tags, layouts, active tags
- Implement XWayland parent detection via async xprop (handles dialog/utility windows)
- Add libinput tap-to-click support with RiverLibinputConfigV1 protocol
- Per-tag layout support: layout-next cycles layout per active tag, not just global
- Add ModeNext action to cycle individual window tiling modes
- Auto-focus new windows on current tags after manage_windows
- Flush connection after manage_finish/render_finish to prevent River timeout (3s)
- Use protocol close() instead of just marking window.closed=true
- Fullscreen mode respects gaps, per-mode border widths, and bar height
- Focus-based fullscreen detection (prefer focused window for fullscreen)
- SSD (use_ssd) enforcement for consistent window decorations
- Track window metadata: pid, dimensions_hint, parent_id, fullscreen/maximize/minimize requests
- Clean up proxy destruction on window/output/seat removal
- Retry state restore across ManageStart cycles for delayed metadata arrival
- Add river-libinput-config-v1.xml protocol spec
protocol/river-libinput-config-v1.xml | 891 ++++++++++++++++++++++++++++++++++
src/borders.rs | 151 +++---
src/clearctl.rs | 2 +-
src/config.rs | 222 ++++++++-
src/ipc.rs | 127 ++++-
src/lib.rs | 13 +-
src/main.rs | 122 ++++-
src/protocol.rs | 7 +
src/restart.rs | 141 +++++-
src/state.rs | 456 +++++++++++++++++
src/status.rs | 3 +-
src/tiling.rs | 339 +++++++++----
src/types.rs | 114 ++++-
src/wayland.rs | 769 +++++++++++++++++++++++++++--
src/wm.rs | 69 ++-
start-river.sh | 28 +-
test_proto | Bin 0 -> 439040 bytes
17 files changed, 3169 insertions(+), 285 deletions(-)
diff --git a/protocol/river-libinput-config-v1.xml b/protocol/river-libinput-config-v1.xml
new file mode 100644
index 0000000..5e38b08
--- /dev/null
+++ b/protocol/river-libinput-config-v1.xml
@@ -0,0 +1,891 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="river_libinput_config_v1">
+ <copyright>
+ SPDX-FileCopyrightText: © 2025 Isaac Freund
+ SPDX-License-Identifier: MIT
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+ </copyright>
+
+ <description summary="configure libinput devices">
+ This protocol exposes libinput device configuration APIs. The libinput
+ documentation should be referred to for detailed information on libinput's
+ behavior.
+
+ Note that the compositor will not be able to expose libinput devices through
+ this protocol when it does not have access to the hardware, for example when
+ running nested in another Wayland compositor or X11 session.
+
+ This protocol is designed so that (hopefully) any backwards compatible
+ change to libinput's API can be matched with a backwards compatible change
+ to this protocol.
+
+ Note: the libinput API uses floating point types (float and double in C)
+ which are not (yet?) natively supported by the Wayland protocol. However,
+ the Wayland protocol does support sending arbitrary bytes through the array
+ argument type. This protocol uses e.g. type="array" summary="double" to
+ indicate a native-endian IEEE-754 64-bit double value.
+
+ The key words "must", "must not", "required", "shall", "shall not",
+ "should", "should not", "recommended", "may", and "optional" in this
+ document are to be interpreted as described in IETF RFC 2119.
+ </description>
+
+ <interface name="river_libinput_config_v1" version="1">
+ <description summary="libinput config global interface">
+ Global interface for configuring libinput devices. This global should
+ only be advertised if river_input_manager_v1 is advertised as well.
+ </description>
+
+ <enum name="error">
+ <entry name="invalid_arg" value="0"
+ summary="invalid enum value or similar"/>
+ <entry name="invalid_destroy" value="1"/>
+ </enum>
+
+ <request name="stop">
+ <description summary="stop sending events">
+ This request indicates that the client no longer wishes to receive
+ events on this object.
+
+ The Wayland protocol is asynchronous, which means the server may send
+ further events until the stop request is processed. The client must wait
+ for a river_libinput_config_v1.finished event before destroying this
+ object.
+ </description>
+ </request>
+
+ <event name="finished">
+ <description summary="the server has finished with the object">
+ This event indicates that the server will send no further events on this
+ object. The client should destroy the object. See
+ river_libinput_config_v1.destroy for more information.
+ </description>
+ </event>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the river_libinput_config_v1 object">
+ This request should be called after the finished event has been received
+ to complete destruction of the object.
+
+ It is a protocol error to make this request before the finished event
+ has been received.
+
+ If a client wishes to destroy this object it should send a
+ river_libinput_config_v1.stop request and wait for a
+ river_libinput_config_v1.finished event. Once the finished event is
+ received it is safe to destroy this object and any other objects created
+ through this interface.
+ </description>
+ </request>
+
+ <event name="libinput_device">
+ <description summary="new libinput device">
+ A new libinput device has been created. Not every river_input_device_v1
+ is necessarily a libinput device as well.
+ </description>
+ <arg name="id" type="new_id" interface="river_libinput_device_v1"/>
+ </event>
+
+ <request name="create_accel_config">
+ <description summary="create a acceleration config">
+ Create a acceleration config which can be applied
+ with river_libinput_device_v1.apply_accel_config.
+ </description>
+ <arg name="id" type="new_id"
+ interface="river_libinput_accel_config_v1"/>
+ <arg name="profile" type="uint"
+ enum="river_libinput_device_v1.accel_profile"/>
+ </request>
+ </interface>
+
+ <interface name="river_libinput_device_v1" version="1">
+ <description summary="a libinput device">
+ In general, *_support events will be sent exactly once directly after the
+ river_libinput_device_v1 is created. *_default events will be sent after
+ *_support events if the config option is supported, and *_current events
+ willl be sent after the *_default events and again whenever the config
+ option is changed.
+ </description>
+
+ <enum name="error">
+ <entry name="invalid_arg" value="0"
+ summary="invalid enum value or similar"/>
+ </enum>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the libinput device object">
+ This request indicates that the client will no longer use the input
+ device object and that it may be safely destroyed.
+ </description>
+ </request>
+
+ <event name="removed">
+ <description summary="the libinput device is removed">
+ This event indicates that the libinput device has been removed.
+
+ The server will send no further events on this object and ignore any
+ request (other than river_libinput_device_v1.destroy) made after this
+ event is sent. The client should destroy this object with the
+ river_libinput_device_v1.destroy request to free up resources.
+ </description>
+ </event>
+
+ <event name="input_device">
+ <description summary="corresponding river input device">
+ The river_input_device_v1 corresponding to this libinput device.
+ This event will always be the first event sent on the
+ river_libinput_device_v1 object, and it will be sent exactly once.
+ </description>
+ <arg name="device" type="object" interface="river_input_device_v1"/>
+ </event>
+
+ <enum name="send_events_modes" bitfield="true">
+ <entry name="enabled" value="0"/>
+ <entry name="disabled" value="1"/>
+ <entry name="disabled_on_external_mouse" value="2"/>
+ </enum>
+
+ <event name="send_events_support">
+ <description summary="supported send events modes">
+ Supported send events modes.
+ </description>
+ <arg name="modes" type="uint" enum="send_events_modes"/>
+ </event>
+
+ <event name="send_events_default">
+ <description summary="default send events mode">
+ Default send events mode.
+ </description>
+ <arg name="mode" type="uint" enum="send_events_modes"/>
+ </event>
+
+ <event name="send_events_current">
+ <description summary="current send events mode">
+ Current send events mode.
+ </description>
+ <arg name="mode" type="uint" enum="send_events_modes"/>
+ </event>
+
+ <request name="set_send_events">
+ <description summary="set send events mode">
+ Set the send events mode for the device.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="mode" type="uint" enum="send_events_modes"/>
+ </request>
+
+ <enum name="tap_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="tap_support">
+ <description summary="tap-to-click/drag support">
+ The number of fingers supported for tap-to-click/drag.
+ If finger_count is 0, tap-to-click and drag are unsupported.
+ </description>
+ <arg name="finger_count" type="int"/>
+ </event>
+
+ <event name="tap_default">
+ <description summary="default tap-to-click state">
+ Default tap-to-click state.
+ </description>
+ <arg name="state" type="uint" enum="tap_state"/>
+ </event>
+
+ <event name="tap_current">
+ <description summary="current tap-to-click state">
+ Current tap-to-click state.
+ </description>
+ <arg name="state" type="uint" enum="tap_state"/>
+ </event>
+
+ <request name="set_tap">
+ <description summary="enable/disable tap-to-click">
+ Configure tap-to-click on this device, with a default mapping of
+ 1, 2, 3 finger tap mapping to left, right, middle click, respectively.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="tap_state"/>
+ </request>
+
+ <enum name="tap_button_map">
+ <entry name="lrm" value="0"
+ summary="1/2/3 finger tap maps to left/right/middle"/>
+ <entry name="lmr" value="1"
+ summary="1/2/3 finger tap maps to left/middle/right"/>
+ </enum>
+
+ <event name="tap_button_map_default">
+ <description summary="default tap-to-click button map">
+ Default tap-to-click button map.
+ </description>
+ <arg name="button_map" type="uint" enum="tap_button_map"/>
+ </event>
+
+ <event name="tap_button_map_current">
+ <description summary="current tap-to-click button map">
+ Current tap-to-click button map.
+ </description>
+ <arg name="button_map" type="uint" enum="tap_button_map"/>
+ </event>
+
+ <request name="set_tap_button_map">
+ <description summary="set tap-to-click button map">
+ Set the finger number to button number mapping for tap-to-click. The
+ default mapping on most devices is to have a 1, 2 and 3 finger tap to
+ map to the left, right and middle button, respectively.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="button_map" type="uint" enum="tap_button_map"/>
+ </request>
+
+ <enum name="drag_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="drag_default">
+ <description summary="default tap-and-drag state">
+ Default tap-and-drag state.
+ </description>
+ <arg name="state" type="uint" enum="drag_state"/>
+ </event>
+
+ <event name="drag_current">
+ <description summary="current tap-and-drag state">
+ Current tap-and-drag state.
+ </description>
+ <arg name="state" type="uint" enum="drag_state"/>
+ </event>
+
+ <request name="set_drag">
+ <description summary="set tap-and-drag state">
+ Configure tap-and-drag functionality on the device.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="drag_state"/>
+ </request>
+
+ <enum name="drag_lock_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled_timeout" value="1"/>
+ <entry name="enabled_sticky" value="2"/>
+ </enum>
+
+ <event name="drag_lock_default">
+ <description summary="default drag lock state">
+ Default drag lock state.
+ </description>
+ <arg name="state" type="uint" enum="drag_lock_state"/>
+ </event>
+
+ <event name="drag_lock_current">
+ <description summary="current drag lock state">
+ Current drag lock state.
+ </description>
+ <arg name="state" type="uint" enum="drag_lock_state"/>
+ </event>
+
+ <request name="set_drag_lock">
+ <description summary="set drag lock state">
+ Configure drag-lock during tapping on this device. When enabled, a
+ finger may be lifted and put back on the touchpad and the drag process
+ continues. A timeout for lifting the finger is optional. When disabled,
+ lifting the finger during a tap-and-drag will immediately stop the drag.
+ See the libinput documentation for more details.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="drag_lock_state"/>
+ </request>
+
+ <event name="three_finger_drag_support">
+ <description summary="three finger drag support">
+ The number of fingers supported for three/four finger drag.
+ If finger_count is less than 3, three finger drag is unsupported.
+ </description>
+ <arg name="finger_count" type="int"/>
+ </event>
+
+ <enum name="three_finger_drag_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled_3fg" value="1"/>
+ <entry name="enabled_4fg" value="2"/>
+ </enum>
+
+ <event name="three_finger_drag_default">
+ <description summary="default three finger drag state">
+ Default three finger drag state.
+ </description>
+ <arg name="state" type="uint" enum="three_finger_drag_state"/>
+ </event>
+
+ <event name="three_finger_drag_current">
+ <description summary="current three finger drag state">
+ Current three finger drag state.
+ </description>
+ <arg name="state" type="uint" enum="three_finger_drag_state"/>
+ </event>
+
+ <request name="set_three_finger_drag">
+ <description summary="set three finger drag state">
+ Configure three finger drag functionality for the device.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="three_finger_drag_state"/>
+ </request>
+
+ <event name="calibration_matrix_support">
+ <description summary="support for a calibration matrix">
+ A calibration matrix is supported if the supported argument is non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <event name="calibration_matrix_default">
+ <description summary="default calibration matrix">
+ Default calibration matrix.
+ </description>
+ <arg name="matrix" type="array" summary="array of 6 floats"/>
+ </event>
+
+ <event name="calibration_matrix_current">
+ <description summary="current calibration matrix">
+ Current calibration matrix.
+ </description>
+ <arg name="matrix" type="array" summary="array of 6 floats"/>
+ </event>
+
+ <request name="set_calibration_matrix">
+ <description summary="set calibration matrix">
+ Set calibration matrix.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="matrix" type="array" summary="array of 6 floats"/>
+ </request>
+
+ <enum name="accel_profile">
+ <entry name="none" value="0"/>
+ <entry name="flat" value="1"/>
+ <entry name="adaptive" value="2"/>
+ <entry name="custom" value="4"/>
+ </enum>
+
+ <enum name="accel_profiles" bitfield="true">
+ <entry name="none" value="0"/>
+ <entry name="flat" value="1"/>
+ <entry name="adaptive" value="2"/>
+ <entry name="custom" value="4"/>
+ </enum>
+
+ <event name="accel_profiles_support">
+ <description summary="supported acceleration profiles">
+ Supported acceleration profiles.
+ </description>
+ <arg name="profiles" type="uint" enum="accel_profiles"/>
+ </event>
+
+ <event name="accel_profile_default">
+ <description summary="default acceleration profile">
+ Default acceleration profile.
+ </description>
+ <arg name="profile" type="uint" enum="accel_profile"/>
+ </event>
+
+ <event name="accel_profile_current">
+ <description summary="current send events mode">
+ Current acceleration profile.
+ </description>
+ <arg name="profile" type="uint" enum="accel_profile"/>
+ </event>
+
+ <request name="set_accel_profile">
+ <description summary="set send events mode">
+ Set the acceleration profile.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="profile" type="uint" enum="accel_profile"/>
+ </request>
+
+ <event name="accel_speed_default">
+ <description summary="default acceleration speed">
+ Default acceleration speed.
+ </description>
+ <arg name="speed" type="array" summary="double"/>
+ </event>
+
+ <event name="accel_speed_current">
+ <description summary="current acceleration speed">
+ Current acceleration speed.
+ </description>
+ <arg name="speed" type="array" summary="double"/>
+ </event>
+
+ <request name="set_accel_speed">
+ <description summary="set acceleration speed">
+ Set the acceleration speed within a range of [-1, 1], where 0 is
+ the default acceleration for this device, -1 is the slowest acceleration
+ and 1 is the maximum acceleration available on this device.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="speed" type="array" summary="double"/>
+ </request>
+
+ <request name="apply_accel_config">
+ <description summary="apply acceleration config">
+ Apply a pointer accleration config.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="config" type="object" interface="river_libinput_accel_config_v1"/>
+ </request>
+
+ <event name="natural_scroll_support">
+ <description summary="support for natural scroll">
+ Natural scroll is supported if the supported argument is non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <enum name="natural_scroll_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="natural_scroll_default">
+ <description summary="default natural scroll">
+ Default natural scroll.
+ </description>
+ <arg name="state" type="uint" enum="natural_scroll_state"/>
+ </event>
+
+ <event name="natural_scroll_current">
+ <description summary="current natural scroll state">
+ Current natural scroll.
+ </description>
+ <arg name="state" type="uint" enum="natural_scroll_state"/>
+ </event>
+
+ <request name="set_natural_scroll">
+ <description summary="set natural scroll state">
+ Set natural scroll state.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="natural_scroll_state"/>
+ </request>
+
+ <event name="left_handed_support">
+ <description summary="support for left-handed mode">
+ Left-handed mode is supported if the supported argument is non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <enum name="left_handed_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="left_handed_default">
+ <description summary="default left-handed mode">
+ Default left-handed mode.
+ </description>
+ <arg name="state" type="uint" enum="left_handed_state"/>
+ </event>
+
+ <event name="left_handed_current">
+ <description summary="current left-handed mode state">
+ Current left-handed mode.
+ </description>
+ <arg name="state" type="uint" enum="left_handed_state"/>
+ </event>
+
+ <request name="set_left_handed">
+ <description summary="set left-handed mode state">
+ Set left-handed mode state.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="left_handed_state"/>
+ </request>
+
+ <enum name="click_method">
+ <entry name="none" value="0"/>
+ <entry name="button_areas" value="1"/>
+ <entry name="clickfinger" value="2"/>
+ </enum>
+
+ <enum name="click_methods" bitfield="true">
+ <entry name="none" value="0"/>
+ <entry name="button_areas" value="1"/>
+ <entry name="clickfinger" value="2"/>
+ </enum>
+
+ <event name="click_method_support">
+ <description summary="supported click methods">
+ The click methods supported by the device.
+ </description>
+ <arg name="methods" type="uint" enum="click_methods"/>
+ </event>
+
+ <event name="click_method_default">
+ <description summary="default click method">
+ Default click method.
+ </description>
+ <arg name="method" type="uint" enum="click_method"/>
+ </event>
+
+ <event name="click_method_current">
+ <description summary="current click method">
+ Current click method.
+ </description>
+ <arg name="method" type="uint" enum="click_method"/>
+ </event>
+
+ <request name="set_click_method">
+ <description summary="set click method">
+ Set click method.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="method" type="uint" enum="click_method"/>
+ </request>
+
+ <enum name="clickfinger_button_map">
+ <entry name="lrm" value="0"/>
+ <entry name="lmr" value="1"/>
+ </enum>
+
+ <event name="clickfinger_button_map_default">
+ <description summary="default clickfinger button map">
+ Default clickfinger button map.
+ Supported if click_methods.clickfinger is supported.
+ </description>
+ <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+ </event>
+
+ <event name="clickfinger_button_map_current">
+ <description summary="current clickfinger button map">
+ Current clickfinger button map.
+ Supported if click_methods.clickfinger is supported.
+ </description>
+ <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+ </event>
+
+ <request name="set_clickfinger_button_map">
+ <description summary="set clickfinger button map">
+ Set clickfinger button map.
+ Supported if click_methods.clickfinger is supported.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="button_map" type="uint" enum="clickfinger_button_map"/>
+ </request>
+
+ <event name="middle_emulation_support">
+ <description summary="support for middle mouse button emulation">
+ Middle mouse button emulation is supported if the supported argument is
+ non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <enum name="middle_emulation_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="middle_emulation_default">
+ <description summary="default middle mouse button emulation">
+ Default middle mouse button emulation.
+ </description>
+ <arg name="state" type="uint" enum="middle_emulation_state"/>
+ </event>
+
+ <event name="middle_emulation_current">
+ <description summary="current middle mouse button emulation state">
+ Current middle mouse button emulation.
+ </description>
+ <arg name="state" type="uint" enum="middle_emulation_state"/>
+ </event>
+
+ <request name="set_middle_emulation">
+ <description summary="set middle mouse button emulation state">
+ Set middle mouse button emulation state.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="middle_emulation_state"/>
+ </request>
+
+ <enum name="scroll_method">
+ <entry name="no_scroll" value="0"/>
+ <entry name="two_finger" value="1"/>
+ <entry name="edge" value="2"/>
+ <entry name="on_button_down" value="4"/>
+ </enum>
+
+ <enum name="scroll_methods" bitfield="true">
+ <entry name="no_scroll" value="0"/>
+ <entry name="two_finger" value="1"/>
+ <entry name="edge" value="2"/>
+ <entry name="on_button_down" value="4"/>
+ </enum>
+
+ <event name="scroll_method_support">
+ <description summary="supported scroll methods">
+ The scroll methods supported by the device.
+ </description>
+ <arg name="methods" type="uint" enum="scroll_methods"/>
+ </event>
+
+ <event name="scroll_method_default">
+ <description summary="default scroll method">
+ Default scroll method.
+ </description>
+ <arg name="method" type="uint" enum="scroll_method"/>
+ </event>
+
+ <event name="scroll_method_current">
+ <description summary="current scroll method">
+ Current scroll method.
+ </description>
+ <arg name="method" type="uint" enum="scroll_method"/>
+ </event>
+
+ <request name="set_scroll_method">
+ <description summary="set scroll method">
+ Set scroll method.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="method" type="uint" enum="scroll_method"/>
+ </request>
+
+ <event name="scroll_button_default">
+ <description summary="default scroll button">
+ Default scroll button.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="button" type="uint"/>
+ </event>
+
+ <event name="scroll_button_current">
+ <description summary="current scroll button">
+ Current scroll button.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="button" type="uint"/>
+ </event>
+
+ <request name="set_scroll_button">
+ <description summary="set scroll button">
+ Set scroll button.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="button" type="uint"/>
+ </request>
+
+ <enum name="scroll_button_lock_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="scroll_button_lock_default">
+ <description summary="default scroll button lock state">
+ Default scroll button lock state.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+ </event>
+
+ <event name="scroll_button_lock_current">
+ <description summary="current scroll button lock state">
+ Current scroll button lock state.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+ </event>
+
+ <request name="set_scroll_button_lock">
+ <description summary="set scroll button lock state">
+ Set scroll button lock state.
+ Supported if scroll_methods.on_button_down is supported.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="scroll_button_lock_state"/>
+ </request>
+
+ <event name="dwt_support">
+ <description summary="support for disable-while-typing">
+ Disable-while-typing is supported if the supported argument is
+ non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <enum name="dwt_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="dwt_default">
+ <description summary="default disable-while-typing state">
+ Default disable-while-typing state.
+ </description>
+ <arg name="state" type="uint" enum="dwt_state"/>
+ </event>
+
+ <event name="dwt_current">
+ <description summary="current disable-while-typing state">
+ Current disable-while-typing state.
+ </description>
+ <arg name="state" type="uint" enum="dwt_state"/>
+ </event>
+
+ <request name="set_dwt">
+ <description summary="set disable-while-typing state">
+ Set disable-while-typing state.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="dwt_state"/>
+ </request>
+
+ <event name="dwtp_support">
+ <description summary="support for disable-while-trackpointing">
+ Disable-while-trackpointing is supported if the supported argument is
+ non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <enum name="dwtp_state">
+ <entry name="disabled" value="0"/>
+ <entry name="enabled" value="1"/>
+ </enum>
+
+ <event name="dwtp_default">
+ <description summary="default disable-while-trackpointing state">
+ Default disable-while-trackpointing state.
+ </description>
+ <arg name="state" type="uint" enum="dwtp_state"/>
+ </event>
+
+ <event name="dwtp_current">
+ <description summary="current disable-while-trackpointing state">
+ Current disable-while-trackpointing state.
+ </description>
+ <arg name="state" type="uint" enum="dwtp_state"/>
+ </event>
+
+ <request name="set_dwtp">
+ <description summary="set disable-while-trackpointing state">
+ Set disable-while-trackpointing state.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="state" type="uint" enum="dwtp_state"/>
+ </request>
+
+ <event name="rotation_support">
+ <description summary="support for rotation">
+ Rotation is supported if the supported argument is non-zero.
+ </description>
+ <arg name="supported" type="int" summary="boolean"/>
+ </event>
+
+ <event name="rotation_default">
+ <description summary="default rotation angle">
+ Default rotation angle.
+ </description>
+ <arg name="angle" type="uint"/>
+ </event>
+
+ <event name="rotation_current">
+ <description summary="current rotation angle">
+ Current rotation angle.
+ </description>
+ <arg name="angle" type="uint"/>
+ </event>
+
+ <request name="set_rotation">
+ <description summary="set rotation angle">
+ Set rotation angle in degrees clockwise off the logical neutral
+ position. Angle must be in the range [0-360).
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="angle" type="uint"/>
+ </request>
+ </interface>
+
+ <interface name="river_libinput_accel_config_v1" version="1">
+ <description summary="acceleration config">
+ The result returned by libinput on setting configuration for a device.
+ </description>
+
+ <enum name="error">
+ <entry name="invalid_arg" value="0"
+ summary="invalid enum value or similar"/>
+ </enum>
+
+ <request name="destroy" type="destructor">
+ <description summary="destroy the accel object">
+ This request indicates that the client will no longer use the accel
+ config object and that it may be safely destroyed.
+ </description>
+ </request>
+
+ <enum name="accel_type">
+ <entry name="fallback" value="0"/>
+ <entry name="motion" value="1"/>
+ <entry name="scroll" value="2"/>
+ </enum>
+
+ <request name="set_points">
+ <description summary="define custom acceleration function">
+ Defines the acceleration function for a given movement type
+ in an acceleration configuration with custom accel profile.
+ </description>
+ <arg name="result" type="new_id" interface="river_libinput_result_v1"/>
+ <arg name="type" type="uint" enum="accel_type"/>
+ <arg name="step" type="array" summary="double"/>
+ <arg name="points" type="array" summary="array of doubles"/>
+ </request>
+ </interface>
+
+ <interface name="river_libinput_result_v1" version="1">
+ <description summary="config application result">
+ The result returned by libinput on setting configuration for a device.
+ </description>
+
+ <event name="success" type="destructor">
+ <description summary="config success">
+ The configuration was successfully applied to the device.
+ </description>
+ </event>
+
+ <event name="unsupported" type="destructor">
+ <description summary="config unsupported">
+ The configuration is unsupported by the device and was ignored.
+ </description>
+ </event>
+
+ <event name="invalid" type="destructor">
+ <description summary="config invalid">
+ The configuration is invalid and was ignored.
+ </description>
+ </event>
+ </interface>
+</protocol>
diff --git a/src/borders.rs b/src/borders.rs
index 188e79d..6647ada 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -3,29 +3,35 @@
use crate::types::{TilingMode, WindowManager};
/// Interpolate a single 8-bit channel toward black by (factor ^ depth).
-/// fp_channel is in fixed-point format (0xRR000000).
-/// Returns the value in 32-bit fixed-point (channel in high byte).
+/// Returns a 32-bit value with the byte replicated across all 4 bytes,
+/// which is the format River expects (it divides by maxInt(u32) to get a float).
pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
- let base = (fp_channel >> 24) as u8;
+ let base = (fp_channel & 0xFF) as u8;
let mut f = 1.0_f64;
for _ in 0..depth {
f *= factor;
}
let val = (base as f64 * f) as u8;
- (val as u32) << 24
+ // Replicate byte across all 4 bytes: 0xVV -> 0xVVVVVVVV
+ val as u32 * 0x01010101
}
-/// 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;
-pub const BORDER_COLOR_NORMAL_B: u32 = 0x3E000000;
-pub const BORDER_COLOR_NORMAL_A: u32 = 0x000000FF;
+/// Blend between two byte-replicated 32-bit channel values.
+/// factor=0.0 → pure bg, factor=1.0 → pure fg.
+fn blend_channel(bg_channel: u32, fg_channel: u32, factor: f64) -> u32 {
+ let bg = (bg_channel & 0xFF) as u8 as f64;
+ let fg = (fg_channel & 0xFF) as u8 as f64;
+ let val = (bg + (fg - bg) * factor) as u8;
+ val as u32 * 0x01010101
+}
-/// Cascade alpha: full alpha in low byte
-pub const CASCADE_ALPHA: u32 = 0x000000FF;
+/// Alpha for borders: fully opaque, byte-replicated
+pub const ALPHA: u32 = 0xFFFFFFFF;
-/// Cascade depth darkening factor
-pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
+/// Unfocused depth factor: each step away from the focused window reduces
+/// the blend factor by this multiplier, making the border color approach
+/// the background color.
+pub const UNFOCUSED_DEPTH_FACTOR: f64 = 0.70;
/// Result of border color computation for a single window
#[derive(Debug, Clone)]
@@ -40,62 +46,77 @@ pub struct WindowBorders {
}
/// Compute border colors for all visible windows.
+/// The focused window gets the configured border_color.
+/// Unfocused windows get a color interpolated between the desktop
+/// background_color and border_color based on stack depth.
pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
let mut results = Vec::new();
- let all_edges = 0b1111u32; // all edges
-
- // Count cascade windows
- let mut n_cascade = 0usize;
- for win in &state.windows {
- if (win.tags & state.active_tags) != 0 && win.tiling_mode == TilingMode::Cascade {
- n_cascade += 1;
- }
- }
+ let all_edges = 0b1111u32;
+
+ // Find the focused window ID from the first non-removed seat
+ let focused_id = state
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+
+ // Collect visible window indices (ordered from bottom to top of stack)
+ let visible: Vec<usize> = state
+ .windows
+ .iter()
+ .enumerate()
+ .filter(|(_, w)| (w.tags & state.active_tags) != 0 && !w.closed)
+ .map(|(i, _)| i)
+ .collect();
+ let n_visible = visible.len();
- // Assign border colors
for (idx, win) in state.windows.iter().enumerate() {
if (win.tags & state.active_tags) == 0 {
continue;
}
- if win.tiling_mode == TilingMode::Cascade && n_cascade > 0 {
- // Compute cascade depth: count how many cascade windows come before this one
- let mut cascade_idx = 0usize;
- for (i, w) in state.windows.iter().enumerate() {
- if i >= idx {
- break;
- }
- if (w.tags & state.active_tags) != 0 && w.tiling_mode == TilingMode::Cascade {
- cascade_idx += 1;
- }
- }
- // depth: 0 for front (focused/last), n_cascade-1 for back
- let depth = (n_cascade - 1 - cascade_idx) as i32;
-
- let r = interp_channel(state.layout.border_r, CASCADE_DEPTH_FACTOR, depth);
- let g = interp_channel(state.layout.border_g, CASCADE_DEPTH_FACTOR, depth);
- let b = interp_channel(state.layout.border_b, CASCADE_DEPTH_FACTOR, depth);
-
- results.push(WindowBorders {
- window_idx: idx,
- edges: all_edges,
- width: state.layout.border_width,
- r,
- g,
- b,
- a: CASCADE_ALPHA,
- });
+ let is_focused = focused_id.map_or(false, |fid| win.id == fid);
+
+ let (r, g, b, a) = if is_focused {
+ // Focused window: pure border color
+ (
+ state.layout.border_r,
+ state.layout.border_g,
+ state.layout.border_b,
+ state.layout.border_a,
+ )
} else {
- results.push(WindowBorders {
- window_idx: idx,
- edges: all_edges,
- width: state.layout.border_width,
- r: BORDER_COLOR_NORMAL_R,
- g: BORDER_COLOR_NORMAL_G,
- b: BORDER_COLOR_NORMAL_B,
- a: BORDER_COLOR_NORMAL_A,
- });
- }
+ // Unfocused window: blend background → border based on depth
+ let pos = visible.iter().position(|&i| i == idx).unwrap_or(0);
+ let depth = (n_visible - 1 - pos) as i32;
+ let mut factor = 1.0_f64;
+ for _ in 0..depth {
+ factor *= UNFOCUSED_DEPTH_FACTOR;
+ }
+ let r = blend_channel(state.layout.background_r, state.layout.border_r, factor);
+ let g = blend_channel(state.layout.background_g, state.layout.border_g, factor);
+ let b = blend_channel(state.layout.background_b, state.layout.border_b, factor);
+ (r, g, b, ALPHA)
+ };
+
+ let width = match win.tiling_mode {
+ TilingMode::Cascade => state.layout.cascade_border_width,
+ TilingMode::Fullscreen => state.layout.fullscreen_border_width,
+ TilingMode::Grid => state.layout.grid_border_width,
+ TilingMode::Vsplit => state.layout.vsplit_border_width,
+ TilingMode::Hsplit => state.layout.hsplit_border_width,
+ TilingMode::Floating => state.layout.floating_border_width,
+ };
+
+ results.push(WindowBorders {
+ window_idx: idx,
+ edges: all_edges,
+ width,
+ r,
+ g,
+ b,
+ a,
+ });
}
results
@@ -108,14 +129,14 @@ mod tests {
#[test]
fn test_interp_channel_depth0() {
// Depth 0 should return the base color unchanged
- let result = interp_channel(0x5C000000, 0.80, 0);
- assert_eq!(result >> 24, 0x5C);
+ let result = interp_channel(0x5C5C5C5C, 0.80, 0);
+ assert_eq!(result, 0x5C5C5C5C);
}
#[test]
fn test_interp_channel_depth1() {
- let result = interp_channel(0x90000000, 0.80, 1);
- let val = result >> 24;
- assert_eq!(val, ((0x90 as f64 * 0.80) as u8) as u32);
+ let result = interp_channel(0x90909090, 0.80, 1);
+ let expected = ((0x90 as f64 * 0.80) as u8) as u32 * 0x01010101;
+ assert_eq!(result, expected);
}
}
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 21732c3..3af57e1 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -12,7 +12,7 @@ fn usage(name: &str) {
eprintln!("usage: {} <command> [args...]", name);
eprintln!();
eprintln!("commands:");
- eprintln!(" layout <gap|offset|bar_height|border_width|border_color> <value>");
+ eprintln!(" layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
eprintln!(" view <1-4>");
eprintln!(" toggle <1-4>");
eprintln!(" close");
diff --git a/src/config.rs b/src/config.rs
index 10dd086..77f8494 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,6 +16,8 @@ pub struct Config {
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
+ pub input: InputConfig,
+ #[serde(default)]
pub repeat: RepeatConfig,
#[serde(default)]
pub startup: Vec<StartupEntryConfig>,
@@ -35,24 +37,57 @@ pub struct Config {
pub struct LayoutConfig {
#[serde(default = "default_gap")]
pub gap: i64,
- #[serde(default = "default_offset")]
- pub offset: i64,
+ #[serde(default = "default_gap_top")]
+ pub gap_top: i64,
+ #[serde(default = "default_gap_left")]
+ pub gap_left: i64,
+ #[serde(default = "default_gap_right")]
+ pub gap_right: i64,
+ #[serde(default = "default_gap_bottom")]
+ pub gap_bottom: i64,
+ #[serde(default = "default_cascade_offset")]
+ pub cascade_offset: i64,
#[serde(default = "default_bar_height")]
pub bar_height: i64,
#[serde(default = "default_border_width")]
pub border_width: i64,
+ #[serde(default = "default_fullscreen_border_width")]
+ pub fullscreen_border_width: i64,
+ #[serde(default = "default_cascade_border_width")]
+ pub cascade_border_width: i64,
+ #[serde(default = "default_grid_border_width")]
+ pub grid_border_width: i64,
+ #[serde(default = "default_vsplit_border_width")]
+ pub vsplit_border_width: i64,
+ #[serde(default = "default_hsplit_border_width")]
+ pub hsplit_border_width: i64,
+ #[serde(default = "default_floating_border_width")]
+ pub floating_border_width: i64,
#[serde(default = "default_border_color")]
pub border_color: String,
+ #[serde(default = "default_background_color")]
+ pub background_color: String,
}
impl Default for LayoutConfig {
fn default() -> Self {
LayoutConfig {
gap: default_gap(),
- offset: default_offset(),
+ gap_top: default_gap_top(),
+ gap_left: default_gap_left(),
+ gap_right: default_gap_right(),
+ gap_bottom: default_gap_bottom(),
+ cascade_offset: default_cascade_offset(),
bar_height: default_bar_height(),
border_width: default_border_width(),
+ fullscreen_border_width: default_fullscreen_border_width(),
+ cascade_border_width: default_cascade_border_width(),
+ grid_border_width: default_grid_border_width(),
+ vsplit_border_width: default_vsplit_border_width(),
+ hsplit_border_width: default_hsplit_border_width(),
+ floating_border_width: default_floating_border_width(),
border_color: default_border_color(),
+ background_color: default_background_color(),
}
}
}
@@ -60,7 +95,19 @@ impl Default for LayoutConfig {
fn default_gap() -> i64 {
48
}
-fn default_offset() -> i64 {
+fn default_gap_top() -> i64 {
+ 48
+}
+fn default_gap_left() -> i64 {
+ 48
+}
+fn default_gap_right() -> i64 {
+ 48
+}
+fn default_gap_bottom() -> i64 {
+ 48
+}
+fn default_cascade_offset() -> i64 {
20
}
fn default_bar_height() -> i64 {
@@ -69,9 +116,30 @@ fn default_bar_height() -> i64 {
fn default_border_width() -> i64 {
6
}
+fn default_fullscreen_border_width() -> i64 {
+ 0
+}
+fn default_cascade_border_width() -> i64 {
+ 6
+}
+fn default_grid_border_width() -> i64 {
+ 6
+}
+fn default_vsplit_border_width() -> i64 {
+ 6
+}
+fn default_hsplit_border_width() -> i64 {
+ 6
+}
+fn default_floating_border_width() -> i64 {
+ 6
+}
fn default_border_color() -> String {
"#3e3e3e".to_string()
}
+fn default_background_color() -> String {
+ "#0a1a0e".to_string()
+}
#[derive(Debug, Deserialize, Default)]
pub struct OutputConfig {
@@ -87,6 +155,12 @@ pub struct RepeatConfig {
pub delay: i64,
}
+#[derive(Debug, Deserialize, Default)]
+pub struct InputConfig {
+ #[serde(default)]
+ pub tap_to_click: bool,
+}
+
#[derive(Debug, Deserialize)]
pub struct StartupEntryConfig {
pub exec: String,
@@ -146,15 +220,31 @@ 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.gap_top = config.layout.gap_top as i32;
+ state.layout.gap_left = config.layout.gap_left as i32;
+ state.layout.gap_right = config.layout.gap_right as i32;
+ state.layout.gap_bottom = config.layout.gap_bottom as i32;
+ state.layout.cascade_offset = config.layout.cascade_offset as i32;
state.layout.bar_height = config.layout.bar_height as i32;
state.layout.border_width = config.layout.border_width as i32;
+ state.layout.fullscreen_border_width = config.layout.fullscreen_border_width as i32;
+ state.layout.cascade_border_width = config.layout.cascade_border_width as i32;
+ state.layout.grid_border_width = config.layout.grid_border_width as i32;
+ state.layout.vsplit_border_width = config.layout.vsplit_border_width as i32;
+ state.layout.hsplit_border_width = config.layout.hsplit_border_width as i32;
+ state.layout.floating_border_width = config.layout.floating_border_width as i32;
if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
state.layout.border_r = r;
state.layout.border_g = g;
state.layout.border_b = b;
state.layout.border_a = a;
}
+ if let Some((r, g, b, a)) = parse_hex_color(&config.layout.background_color) {
+ state.layout.background_r = r;
+ state.layout.background_g = g;
+ state.layout.background_b = b;
+ state.layout.background_a = a;
+ }
// [[keybind]] array
eprintln!("[config] processing {} keybinds...", config.keybind.len());
@@ -212,6 +302,15 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
}
}
+ // [env] — set environment variables BEFORE startup apps are processed,
+ // so that PATH and other vars are available when spawn_command_bg runs.
+ // Supports $VAR and ${VAR} expansion using the current environment.
+ for (key, value) in &config.env {
+ let expanded = expand_env_vars(value);
+ std::env::set_var(key, &expanded);
+ state.env_vars.insert(key.clone(), expanded);
+ }
+
// [[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.
@@ -242,12 +341,6 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
}
}
- // [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 — 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
@@ -259,10 +352,59 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
0.0
};
+ // [input] tap_to_click — applied via river-libinput-config protocol.
+ // The tap config will be applied when libinput devices are discovered
+ // (in the RiverLibinputDeviceV1 TapSupport event handler).
+ state.tap_to_click = config.input.tap_to_click;
+
// Signal config-done
state.config_done = true;
}
+/// Expand $VAR and ${VAR} references in a string using the current environment.
+/// Unset variables expand to empty strings. $$ is not expanded (not a shell).
+fn expand_env_vars(s: &str) -> String {
+ let mut result = String::with_capacity(s.len());
+ let chars: Vec<char> = s.chars().collect();
+ let mut i = 0;
+ while i < chars.len() {
+ if chars[i] == '$' && i + 1 < chars.len() {
+ if chars[i + 1] == '{' {
+ // ${VAR} form
+ if let Some(end) = chars[i + 2..].iter().position(|c| *c == '}') {
+ let var_name: String = chars[i + 2..i + 2 + end].iter().collect();
+ let val = std::env::var(&var_name).unwrap_or_default();
+ result.push_str(&val);
+ i = i + 2 + end + 1; // skip ${VAR}
+ } else {
+ result.push(chars[i]);
+ i += 1;
+ }
+ } else if chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_' {
+ // $VAR form — name is [A-Za-z_][A-Za-z0-9_]*
+ let start = i + 1;
+ let mut end = start;
+ while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
+ {
+ end += 1;
+ }
+ let var_name: String = chars[start..end].iter().collect();
+ let val = std::env::var(&var_name).unwrap_or_default();
+ result.push_str(&val);
+ i = end;
+ } else {
+ // $ followed by non-identifier char (e.g. $$, $:, $@) — keep as-is
+ result.push(chars[i]);
+ i += 1;
+ }
+ } else {
+ result.push(chars[i]);
+ i += 1;
+ }
+ }
+ result
+}
+
/// Extract the program name (first word, basename) from a command string
fn extract_program_name(cmd: &str) -> String {
let cmd = cmd.trim_start();
@@ -347,11 +489,67 @@ mod tests {
fn test_config_layout_defaults() {
let lc = LayoutConfig::default();
assert_eq!(lc.gap, 48);
- assert_eq!(lc.offset, 20);
+ assert_eq!(lc.gap_top, 48);
+ assert_eq!(lc.gap_left, 48);
+ assert_eq!(lc.gap_right, 48);
+ assert_eq!(lc.gap_bottom, 48);
+ assert_eq!(lc.cascade_offset, 20);
assert_eq!(lc.bar_height, 24);
assert_eq!(lc.border_width, 6);
+ assert_eq!(lc.fullscreen_border_width, 0);
assert_eq!(lc.border_color, "#3e3e3e");
}
+
+ #[test]
+ fn test_expand_env_vars_simple() {
+ std::env::set_var("CLEARWM_TEST_VAR", "hello");
+ assert_eq!(expand_env_vars("$CLEARWM_TEST_VAR"), "hello");
+ std::env::remove_var("CLEARWM_TEST_VAR");
+ }
+
+ #[test]
+ fn test_expand_env_vars_braces() {
+ std::env::set_var("CLEARWM_TEST_VAR", "world");
+ assert_eq!(expand_env_vars("${CLEARWM_TEST_VAR}!"), "world!");
+ std::env::remove_var("CLEARWM_TEST_VAR");
+ }
+
+ #[test]
+ fn test_expand_env_vars_mid_string() {
+ std::env::set_var("CLEARWM_TEST_HOME", "/home/user");
+ assert_eq!(
+ expand_env_vars("$CLEARWM_TEST_HOME/.local/bin:$CLEARWM_TEST_HOME/bin"),
+ "/home/user/.local/bin:/home/user/bin"
+ );
+ std::env::remove_var("CLEARWM_TEST_HOME");
+ }
+
+ #[test]
+ fn test_expand_env_vars_unset() {
+ assert_eq!(expand_env_vars("$CLEARWM_NONEXISTENT_VAR"), "");
+ }
+
+ #[test]
+ fn test_expand_env_vars_no_vars() {
+ assert_eq!(expand_env_vars("plain string"), "plain string");
+ }
+
+ #[test]
+ fn test_expand_env_vars_dollar_non_identifier() {
+ // $$ and $: should be kept as-is (not a shell)
+ assert_eq!(expand_env_vars("$$"), "$$");
+ assert_eq!(expand_env_vars("$:"), "$:");
+ }
+
+ #[test]
+ fn test_expand_env_vars_path_append() {
+ // Simulates the real use case: PATH = "$HOME/.local/bin:$PATH"
+ let orig_path = std::env::var("PATH").unwrap_or_default();
+ let home = std::env::var("HOME").unwrap_or_default();
+ let expanded = expand_env_vars("$HOME/.local/bin:$PATH");
+ assert!(expanded.starts_with(&format!("{}/.local/bin:", home)));
+ assert!(expanded.contains(&orig_path));
+ }
}
#[cfg(test)]
diff --git a/src/ipc.rs b/src/ipc.rs
index 9892e21..d9d210c 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -158,6 +158,9 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
"repeat" => {
handle_repeat_command(rest, state);
}
+ "input" => {
+ handle_input_command(rest, state);
+ }
_ => {
// Unknown command, ignore
}
@@ -194,9 +197,29 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
state.layout.gap = value;
}
}
- "offset" => {
+ "gap_top" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.gap_top = value;
+ }
+ }
+ "gap_left" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.gap_left = value;
+ }
+ }
+ "gap_right" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.gap_right = value;
+ }
+ }
+ "gap_bottom" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.gap_bottom = value;
+ }
+ }
+ "cascade_offset" => {
if let Ok(value) = value_str.parse::<i32>() {
- state.layout.offset = value;
+ state.layout.cascade_offset = value;
}
}
"bar_height" => {
@@ -209,6 +232,36 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
state.layout.border_width = value;
}
}
+ "fullscreen_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.fullscreen_border_width = value;
+ }
+ }
+ "cascade_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.cascade_border_width = value;
+ }
+ }
+ "grid_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.grid_border_width = value;
+ }
+ }
+ "vsplit_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.vsplit_border_width = value;
+ }
+ }
+ "hsplit_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.hsplit_border_width = value;
+ }
+ }
+ "floating_border_width" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.floating_border_width = value;
+ }
+ }
"border_color" => {
if let Some((r, g, b, a)) = parse_hex_color(value_str) {
state.layout.border_r = r;
@@ -217,6 +270,14 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
state.layout.border_a = a;
}
}
+ "background_color" => {
+ if let Some((r, g, b, a)) = parse_hex_color(value_str) {
+ state.layout.background_r = r;
+ state.layout.background_g = g;
+ state.layout.background_b = b;
+ state.layout.background_a = a;
+ }
+ }
_ => {}
}
}
@@ -363,9 +424,8 @@ fn handle_set_tag_command(rest: &str, state: &mut WindowManager) {
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| {
+ 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
});
@@ -396,6 +456,33 @@ fn handle_repeat_command(_rest: &str, _state: &mut WindowManager) {
// river_input_device_v1_set_repeat_info()
}
+fn handle_input_command(rest: &str, state: &mut WindowManager) {
+ let tokens: Vec<&str> = rest.splitn(2, ' ').collect();
+ let param = tokens[0];
+ let value_str = if tokens.len() > 1 { tokens[1] } else { "" };
+
+ match param {
+ "tap-to-click" | "tap_to_click" => {
+ match value_str {
+ "true" | "1" | "enabled" => {
+ state.tap_to_click = true;
+ state.tap_config_applied = false; // re-apply
+ }
+ "false" | "0" | "disabled" => {
+ state.tap_to_click = false;
+ state.tap_config_applied = false; // re-apply
+ }
+ "toggle" => {
+ state.tap_to_click = !state.tap_to_click;
+ state.tap_config_applied = false; // re-apply
+ }
+ _ => {}
+ }
+ }
+ _ => {}
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -443,13 +530,31 @@ mod tests {
assert_eq!(state.layout.gap, 18);
}
+ #[test]
+ fn test_ipc_layout_gap_sides() {
+ let mut state = WindowManager::default();
+ assert_eq!(state.layout.gap_top, 48);
+ assert_eq!(state.layout.gap_left, 48);
+ assert_eq!(state.layout.gap_right, 48);
+ assert_eq!(state.layout.gap_bottom, 48);
+
+ handle_ipc_command("layout gap_top 10", &mut state);
+ handle_ipc_command("layout gap_left 20", &mut state);
+ handle_ipc_command("layout gap_right 30", &mut state);
+ handle_ipc_command("layout gap_bottom 40", &mut state);
+ assert_eq!(state.layout.gap_top, 10);
+ assert_eq!(state.layout.gap_left, 20);
+ assert_eq!(state.layout.gap_right, 30);
+ assert_eq!(state.layout.gap_bottom, 40);
+ }
+
#[test]
fn test_ipc_layout_border_color() {
let mut state = WindowManager::default();
handle_ipc_command("layout border_color #5c9060", &mut state);
- assert_eq!(state.layout.border_r, 0x5C000000);
- assert_eq!(state.layout.border_g, 0x90000000);
- assert_eq!(state.layout.border_b, 0x60000000);
+ assert_eq!(state.layout.border_r, 0x5C5C5C5C);
+ assert_eq!(state.layout.border_g, 0x90909090);
+ assert_eq!(state.layout.border_b, 0x60606060);
}
#[test]
@@ -532,10 +637,10 @@ mod tests {
}
#[test]
- fn test_ipc_layout_offset() {
+ fn test_ipc_layout_cascade_offset() {
let mut state = WindowManager::default();
- handle_ipc_command("layout offset 32", &mut state);
- assert_eq!(state.layout.offset, 32);
+ handle_ipc_command("layout cascade_offset 32", &mut state);
+ assert_eq!(state.layout.cascade_offset, 32);
}
#[test]
diff --git a/src/lib.rs b/src/lib.rs
index 16a5f31..6f49b62 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,14 +1,15 @@
// clearwm — Wayland window manager for river, written in Rust
-pub mod protocol;
-pub mod types;
+pub mod borders;
pub mod config;
-pub mod tiling;
pub mod ipc;
-pub mod borders;
+pub mod protocol;
+pub mod restart;
+pub mod state;
pub mod status;
pub mod status_server;
-pub mod restart;
-pub mod wm;
+pub mod tiling;
+pub mod types;
#[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
pub mod wayland;
+pub mod wm;
diff --git a/src/main.rs b/src/main.rs
index 9f41779..2f667b9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,12 +9,49 @@ use std::fs;
const SOCKET_PATH: &str = "/tmp/clearwm.sock";
+/// Write a crash/exit trace to /tmp/clearwm-death.log so we can diagnose
+/// why clearwm dies even when the normal log gets overwritten on restart.
+fn log_death(msg: &str) {
+ use std::io::Write;
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open("/tmp/clearwm-death.log")
+ {
+ let _ = writeln!(f, "{}", msg);
+ }
+ eprintln!("{}", msg);
+}
+
fn main() {
+ // Install a panic hook that writes to a separate log file before aborting.
+ std::panic::set_hook(Box::new(|info| {
+ let msg = format!("[PANIC] {}", info);
+ eprintln!("{}", msg);
+ use std::io::Write;
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open("/tmp/clearwm-death.log")
+ {
+ let _ = writeln!(f, "{}", msg);
+ }
+ }));
+
eprintln!("clearwm starting...");
// Start the status socket server thread (for waybar integration)
let status_sender = status_server::spawn_status_server();
+ // Ignore SIGPIPE — when the Wayland compositor closes the connection
+ // (protocol error, unresponsive timeout, etc.), writes to the socket
+ // generate SIGPIPE. Without ignoring it, the process is killed before
+ // conn.flush() or blocking_dispatch() can return an error, masking the
+ // real cause of the disconnect.
+ unsafe {
+ nix::libc::signal(nix::libc::SIGPIPE, nix::libc::SIG_IGN);
+ }
+
// Set up SIGCHLD handler to reap child processes
let sa = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::Handler(sigchld_handler),
@@ -27,10 +64,23 @@ fn main() {
}
// Connect to Wayland display and get initial state.
+ // SIGUSR2 handler: dump backtrace to /tmp/clearwm-bt.txt for debugging busy loops
+ unsafe {
+ nix::sys::signal::sigaction(
+ nix::sys::signal::SIGUSR2,
+ &nix::sys::signal::SigAction::new(
+ nix::sys::signal::SigHandler::Handler(dump_backtrace),
+ nix::sys::signal::SaFlags::empty(),
+ nix::sys::signal::SigSet::empty(),
+ ),
+ )
+ .expect("failed to set SIGUSR2 handler");
+ }
+
let (_conn, mut event_queue, mut state) = match wayland_init() {
Ok(c) => c,
Err(e) => {
- eprintln!("fatal: {}", e);
+ log_death(&format!("fatal: {}", e));
std::process::exit(1);
}
};
@@ -46,7 +96,8 @@ fn main() {
true
};
- env::remove_var("WAYLAND_DEBUG");
+ // Don't remove WAYLAND_DEBUG — we need protocol debug logging to diagnose crashes
+ // env::remove_var("WAYLAND_DEBUG");
// Load config immediately, before entering the event loop.
// This ensures bindings are registered before any render_start arrives.
@@ -80,11 +131,40 @@ fn main() {
// Main loop — tinyrwm pattern: just blocking_dispatch in a loop.
// All work (including spawning) happens inside Dispatch callbacks.
+ let mut loop_count: u64 = 0;
loop {
+ loop_count += 1;
+ if loop_count % 10000 == 0 {
+ eprintln!("[main] loop iteration {}", loop_count);
+ }
match event_queue.blocking_dispatch(&mut state) {
- Ok(_) => {}
+ Ok(n) => {
+ eprintln!(
+ "[main] blocking_dispatch returned Ok({}), about to flush",
+ n
+ );
+ // Explicitly flush after every dispatch cycle.
+ // blocking_dispatch only flushes when dispatched==0 (before blocking read),
+ // so if events were dispatched, pending requests like manage_finish
+ // and render_finish stay in the buffer until the next cycle.
+ // Flushing here ensures River receives our responses promptly.
+ if let Err(e) = event_queue.flush() {
+ log_death(&format!("flush error after dispatch: {:?}", e));
+ let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
+ if !state.wm.exit_requested {
+ restart::wm_restart();
+ }
+ break;
+ }
+ eprintln!("[main] flush ok, looping");
+ }
Err(e) => {
- eprintln!("wayland dispatch error: {:?}", e);
+ log_death(&format!(
+ "wayland dispatch error: {:?}\n exit_requested={} wm.exit_requested={}",
+ e, state.exit_requested, state.wm.exit_requested
+ ));
+ // Save log before restart overwrites it
+ let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
if !state.wm.exit_requested {
restart::wm_restart();
}
@@ -92,21 +172,45 @@ fn main() {
}
}
if state.exit_requested || state.wm.exit_requested {
+ log_death(&format!(
+ "main loop exit: exit_requested={} wm.exit_requested={}",
+ state.exit_requested, state.wm.exit_requested
+ ));
break;
}
}
let _ = fs::remove_file(SOCKET_PATH);
if !state.wm.exit_requested {
+ // Save log before restart overwrites it
+ let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
restart::wm_restart();
}
- eprintln!("main loop exited");
+ log_death("main loop exited cleanly");
}
extern "C" fn sigchld_handler(_sig: nix::libc::c_int) {
- while nix::sys::wait::waitpid(
+ use nix::sys::wait::WaitStatus;
+ while let Ok(status) = nix::sys::wait::waitpid(
nix::unistd::Pid::from_raw(-1),
Some(nix::sys::wait::WaitPidFlag::WNOHANG),
- )
- .is_ok()
- {}
+ ) {
+ match status {
+ WaitStatus::StillAlive => break, // no more children to reap
+ _ => continue, // reaped a child, check for more
+ }
+ }
+}
+
+extern "C" fn dump_backtrace(_sig: nix::libc::c_int) {
+ use std::io::Write;
+ let bt = std::backtrace::Backtrace::capture();
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open("/tmp/clearwm-bt.txt")
+ {
+ let _ = writeln!(f, "SIGUSR2 backtrace:\n{:?}", bt);
+ let _ = f.sync_all();
+ }
}
diff --git a/src/protocol.rs b/src/protocol.rs
index de21dea..6049a23 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -49,6 +49,13 @@ pub mod river_input_management {
);
}
+pub mod river_libinput_config {
+ river_protocol!(
+ "protocol/river-libinput-config-v1.xml",
+ [crate::protocol::river_input_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 733e4dd..dba8c56 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -11,9 +11,29 @@ use crate::types::WindowManager;
///
/// The CLEARWM_RESTARTING environment variable signals that this is a restart
/// (not a cold start), so the new process skips cold_start_only apps.
+///
+/// ## Why setsid() is required
+///
+/// clearwm is typically a session leader (PID == SID, started by River's
+/// `-c` launch script). When a session leader exits, the kernel sends SIGHUP
+/// to all processes in that session — including the forked child. Without
+/// `setsid()`, the child dies from SIGHUP before it can exec, and clearwm
+/// never comes back.
pub fn wm_restart() {
use std::time::Instant;
+ // Write to death log before fork — this is the last chance to capture
+ // why we're restarting, since fork+process::exit(0) silently kills the parent.
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open("/tmp/clearwm-death.log")
+ {
+ use std::io::Write;
+ let _ = writeln!(f, "wm_restart() called — about to fork+exit");
+ let _ = f.sync_all(); // ensure it hits disk before process::exit(0)
+ }
+
static mut LAST_RESTART: Option<Instant> = None;
// Throttle restarts (minimum 2 seconds between restarts)
@@ -38,14 +58,40 @@ pub fn wm_restart() {
// Signal that this is a restart, not a cold start
std::env::set_var("CLEARWM_RESTARTING", "1");
+ // Persist state to ~/.cache/clearwm_state for the new instance to restore.
+ // Note: we can't pass &WindowManager here since wm_restart() has no access
+ // to it. The state file is kept fresh by RenderStart's needs_status_update
+ // path, so it should be reasonably up-to-date already.
+
+ // Save the current log before River's launch script truncates it on restart.
+ // This preserves the crash/reason for the restart.
+ let _ = std::fs::copy("/tmp/clearwm.log", "/tmp/clearwm-prev.log");
+
// Remove the IPC socket
let _ = std::fs::remove_file("/tmp/clearwm.sock");
- // Get the current executable path
- let Ok(exe_path) = std::env::current_exe() else {
- std::process::exit(1);
+ // Get the current executable path.
+ // CString is required because execl() needs a null-terminated C string;
+ // Rust's String::as_ptr() is NOT guaranteed to be null-terminated.
+ //
+ // We prefer the symlink path (~/.local/bin/clearwm) over current_exe()
+ // because current_exe() resolves through /proc/self/exe to the real path,
+ // which may be on a sync filesystem (Dropbox) that temporarily moves files.
+ // The symlink is on the root filesystem and always available.
+ let home = std::env::var("HOME").unwrap_or_default();
+ let exe_path = if !home.is_empty() {
+ let symlink = format!("{}/.local/bin/clearwm", home);
+ if std::path::Path::new(&symlink).exists() {
+ std::path::PathBuf::from(symlink)
+ } else {
+ std::env::current_exe().unwrap_or_else(|_| std::process::exit(1))
+ }
+ } else {
+ std::env::current_exe().unwrap_or_else(|_| std::process::exit(1))
};
- let path_str = exe_path.to_string_lossy().to_string();
+ eprintln!("wm_restart: exe_path={}", exe_path.display());
+ let path_cstr = std::ffi::CString::new(exe_path.to_string_lossy().into_owned())
+ .unwrap_or_else(|_| std::process::exit(1));
// Fork: child waits for parent to die, then execs fresh clearwm.
// Parent exits so River tears down the old Wayland connection.
@@ -54,30 +100,87 @@ pub fn wm_restart() {
// 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.
+ // ── Child process ──
+
+ // Create a new session IMMEDIATELY. Without this, the child stays
+ // in the parent's session. When the parent (session leader) calls
+ // process::exit(0), the kernel sends SIGHUP to every process in that
+ // session — killing the child before it can exec.
+ unsafe {
+ libc::setsid();
+ libc::signal(libc::SIGHUP, libc::SIG_IGN);
+ }
+
+ // Wait for parent to exit so River cleans up the old Wayland
+ // connection before we try to connect fresh.
+ let parent_pid = unsafe { libc::getppid() };
std::thread::sleep(std::time::Duration::from_millis(500));
- // Close inherited Wayland FDs so we don't confuse River
+ // If parent is somehow still alive, wait a bit more
+ if unsafe { libc::kill(parent_pid, 0) == 0 } {
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ }
+
+ // Close inherited Wayland FDs so the new clearwm instance doesn't
+ // confuse River with stale connections.
// (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); }
+ unsafe {
+ libc::close(fd);
+ }
}
- let ret = unsafe {
- libc::execl(
- path_str.as_ptr() as *const i8,
- path_str.as_ptr() as *const i8,
- std::ptr::null::<i8>(),
- )
- };
- if ret < 0 {
- // If execl fails, just exit silently
- std::process::exit(1);
+ // Exec the same binary — replaces this process with a fresh clearwm.
+ // Retry up to 3 times with a short delay — the binary may be temporarily
+ // unavailable if cargo build is replacing it mid-write (atomic rename).
+ for attempt in 0..3 {
+ let ret = unsafe {
+ libc::execl(
+ path_cstr.as_ptr(),
+ path_cstr.as_ptr(),
+ std::ptr::null::<i8>(),
+ )
+ };
+ let errno = unsafe { *libc::__errno_location() };
+ if attempt < 2 {
+ eprintln!(
+ "wm_restart: execl attempt {} failed (errno={} {}), retrying in 500ms...",
+ attempt + 1,
+ errno,
+ std::io::Error::from_raw_os_error(errno)
+ );
+ std::thread::sleep(std::time::Duration::from_millis(500));
+ let _ = ret; // suppress unused
+ } else {
+ // Final attempt failed — log and exit
+ eprintln!(
+ "wm_restart: execl failed after 3 attempts! errno={} ({})",
+ errno,
+ std::io::Error::from_raw_os_error(errno)
+ );
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open("/tmp/clearwm-death.log")
+ {
+ use std::io::Write;
+ let _ = writeln!(
+ f,
+ "child: execl failed after 3 attempts! errno={} ({}) path={}",
+ errno,
+ std::io::Error::from_raw_os_error(errno),
+ exe_path.display()
+ );
+ }
+ let _ = ret; // suppress unused
+ std::process::exit(1);
+ }
}
} else {
- // Parent: exit immediately so River sees the connection drop
+ // ── Parent process ──
+ // Exit immediately so River sees the Wayland connection drop
+ // and tears down the old WM binding.
std::process::exit(0);
}
}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..e8ccf2a
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,456 @@
+// Persistent state file for clearwm restart recovery.
+//
+// Writes window tag assignments and global tag/layout state to
+// ~/.cache/clearwm_state so that it survives restarts. On startup,
+// the state file is read and applied to re-advertised windows
+// matched by their River identifier (stable across WM restarts)
+// or app_id+title as a fallback.
+//
+// What is persisted:
+// - active_tags bitmask
+// - Per-tag layout overrides (tag_layouts + has_tag_layout)
+// - Per-window: tags, tiling_mode (only if mode_locked), identifier, app_id, title
+//
+// What is NOT persisted:
+// - Focus (seat.focused_window_id)
+// - Window positions/dimensions (recomputed on startup)
+// - Transient flags (is_new, needs_*, etc.)
+
+use crate::types::{TilingMode, WindowManager, NUM_TAGS};
+use std::fs;
+use std::io::{BufRead, Write};
+use std::path::PathBuf;
+
+/// Get the state file path: ~/.cache/clearwm_state
+fn state_file_path() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
+ let mut path = PathBuf::from(home);
+ path.push(".cache");
+ path.push("clearwm_state");
+ path
+}
+
+/// Persistent state snapshot (plain-text, line-oriented).
+///
+/// Format:
+/// active_tags=<u32>
+/// tag_layout=<tag_index> <mode_str> <has_layout_bool>
+/// window <identifier> <app_id> <title> <tags> <mode_str> <mode_locked>
+///
+/// Fields are tab-separated. App_id and title use URL-style percent-encoding
+/// for spaces, tabs, and newlines so they never break the line format.
+pub struct PersistentState {
+ pub active_tags: u32,
+ pub tag_layouts: Vec<(usize, TilingMode, bool)>,
+ pub windows: Vec<PersistentWindow>,
+}
+
+pub struct PersistentWindow {
+ pub identifier: Option<String>,
+ pub app_id: Option<String>,
+ pub title: Option<String>,
+ pub tags: u32,
+ pub tiling_mode: TilingMode,
+ pub mode_locked: bool,
+}
+
+/// Percent-encode spaces, tabs, newlines, and percent signs in a string.
+fn pct_encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ ' ' => out.push_str("%20"),
+ '\t' => out.push_str("%09"),
+ '\n' => out.push_str("%0A"),
+ '\r' => out.push_str("%0D"),
+ '%' => out.push_str("%25"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+/// Decode a percent-encoded string.
+fn pct_decode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let mut chars = s.chars();
+ while let Some(c) = chars.next() {
+ if c == '%' {
+ let hex: String = chars.by_ref().take(2).collect();
+ if let Ok(byte) = u8::from_str_radix(&hex, 16) {
+ out.push(byte as char);
+ } else {
+ out.push('%');
+ out.push_str(&hex);
+ }
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
+/// Write the current WM state to the state file.
+/// Safe to call inside Dispatch callbacks — just file I/O, no fork.
+pub fn write_state(wm: &WindowManager) {
+ let path = state_file_path();
+
+ // Ensure ~/.cache/ exists
+ if let Some(parent) = path.parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+
+ if let Ok(mut f) = fs::File::create(&path) {
+ let _ = writeln!(f, "active_tags={}", wm.active_tags);
+
+ for tag_bit in 0..NUM_TAGS {
+ let mode_str = wm.tag_layouts[tag_bit].as_str();
+ let has = wm.has_tag_layout[tag_bit];
+ let _ = writeln!(f, "tag_layout\t{}\t{}\t{}", tag_bit, mode_str, has);
+ }
+
+ for win in &wm.windows {
+ if win.closed {
+ continue;
+ }
+ let ident = win
+ .identifier
+ .as_deref()
+ .map(pct_encode)
+ .unwrap_or_else(|| "-".to_string());
+ let app_id = win
+ .app_id
+ .as_deref()
+ .map(pct_encode)
+ .unwrap_or_else(|| "-".to_string());
+ let title = win
+ .title
+ .as_deref()
+ .map(pct_encode)
+ .unwrap_or_else(|| "-".to_string());
+ let mode_str = win.tiling_mode.as_str();
+ let _ = writeln!(
+ f,
+ "window\t{}\t{}\t{}\t{}\t{}\t{}",
+ ident, app_id, title, win.tags, mode_str, win.mode_locked
+ );
+ }
+
+ let _ = f.sync_all();
+ }
+}
+
+/// Read the persisted state from disk. Returns None if the file doesn't
+/// exist or can't be parsed.
+pub fn read_state() -> Option<PersistentState> {
+ let path = state_file_path();
+ let file = fs::File::open(&path).ok()?;
+ let reader = std::io::BufReader::new(file);
+
+ let mut active_tags: Option<u32> = None;
+ let mut tag_layouts = Vec::new();
+ let mut windows = Vec::new();
+
+ for line in reader.lines() {
+ let line = line.ok()?;
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+
+ if let Some(rest) = line.strip_prefix("active_tags=") {
+ active_tags = rest.parse::<u32>().ok();
+ continue;
+ }
+
+ if let Some(rest) = line.strip_prefix("tag_layout") {
+ let rest = rest.trim_start();
+ let parts: Vec<&str> = rest.split('\t').collect();
+ if parts.len() >= 3 {
+ if let Ok(idx) = parts[0].parse::<usize>() {
+ let mode = parse_tiling_mode_str(parts[1]);
+ let has = parts[2] == "true";
+ tag_layouts.push((idx, mode, has));
+ }
+ }
+ continue;
+ }
+
+ if let Some(rest) = line.strip_prefix("window") {
+ let rest = rest.trim_start();
+ let parts: Vec<&str> = rest.split('\t').collect();
+ if parts.len() >= 6 {
+ let identifier = if parts[0] == "-" {
+ None
+ } else {
+ Some(pct_decode(parts[0]))
+ };
+ let app_id = if parts[1] == "-" {
+ None
+ } else {
+ Some(pct_decode(parts[1]))
+ };
+ let title = if parts[2] == "-" {
+ None
+ } else {
+ Some(pct_decode(parts[2]))
+ };
+ let tags = parts[3].parse::<u32>().unwrap_or(1);
+ let tiling_mode = parse_tiling_mode_str(parts[4]);
+ let mode_locked = parts[5] == "true";
+
+ windows.push(PersistentWindow {
+ identifier,
+ app_id,
+ title,
+ tags,
+ tiling_mode,
+ mode_locked,
+ });
+ }
+ continue;
+ }
+ }
+
+ Some(PersistentState {
+ active_tags: active_tags.unwrap_or(1),
+ tag_layouts,
+ windows,
+ })
+}
+
+/// Parse a tiling mode string (case-insensitive).
+fn parse_tiling_mode_str(s: &str) -> TilingMode {
+ match s {
+ "Cascade" => TilingMode::Cascade,
+ "Grid" => TilingMode::Grid,
+ "Vsplit" => TilingMode::Vsplit,
+ "Hsplit" => TilingMode::Hsplit,
+ "Fullscreen" => TilingMode::Fullscreen,
+ "Floating" => TilingMode::Floating,
+ _ => TilingMode::Cascade,
+ }
+}
+
+/// Apply persisted state to the window manager.
+/// Called on startup after config is loaded but before the main loop.
+///
+/// - Restores active_tags (so the correct tag view is shown).
+/// - Restores per-tag layout overrides.
+/// - For each live window, looks up its persisted entry by identifier
+/// (preferred) or app_id+title (fallback) and restores:
+/// - tags (which tag the window is on)
+/// - tiling_mode + mode_locked (if the mode was user-set)
+pub fn apply_state(wm: &mut WindowManager, state: &PersistentState) {
+ // Restore active_tags
+ wm.active_tags = state.active_tags;
+
+ // Restore per-tag layouts
+ for (idx, mode, has) in &state.tag_layouts {
+ if *idx < NUM_TAGS {
+ wm.tag_layouts[*idx] = *mode;
+ wm.has_tag_layout[*idx] = *has;
+ }
+ }
+
+ // Apply per-window state from persisted entries.
+ // Build a lookup: identifier -> PersistentWindow, and (app_id, title) -> PersistentWindow.
+ // Identifier is the primary key (stable across restarts).
+ // App_id+title is a fallback for windows without identifiers.
+ let mut by_identifier: Vec<&PersistentWindow> = Vec::new();
+ let mut by_app_id_title: Vec<&PersistentWindow> = Vec::new();
+
+ for pw in &state.windows {
+ if pw.identifier.is_some() {
+ by_identifier.push(pw);
+ }
+ if pw.app_id.is_some() {
+ by_app_id_title.push(pw);
+ }
+ }
+
+ for win in &mut wm.windows {
+ if win.closed {
+ continue;
+ }
+
+ // Try identifier match first
+ let matched = if let Some(ref ident) = win.identifier {
+ by_identifier.iter().find(|pw| {
+ pw.identifier.as_deref() == Some(ident.as_str())
+ })
+ } else {
+ None
+ };
+
+ // Fallback: app_id + title match
+ let matched = matched.or_else(|| {
+ if let Some(ref aid) = win.app_id {
+ by_app_id_title.iter().find(|pw| {
+ pw.app_id.as_deref() == Some(aid.as_str())
+ && pw.title == win.title
+ })
+ } else {
+ None
+ }
+ });
+
+ if let Some(pw) = matched {
+ // Restore tag assignment
+ win.tags = pw.tags;
+
+ // Restore locked tiling mode (user explicitly set this)
+ if pw.mode_locked {
+ win.tiling_mode = pw.tiling_mode;
+ win.mode_locked = true;
+ }
+ }
+ }
+
+ eprintln!(
+ "[state] restored: active_tags={}, tag_layouts={:?}, window_entries={}",
+ state.active_tags,
+ state
+ .tag_layouts
+ .iter()
+ .filter(|(_, _, has)| *has)
+ .count(),
+ state.windows.len()
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_pct_encode_decode_roundtrip() {
+ let cases = vec![
+ "",
+ "hello",
+ "hello world",
+ "foo\tbar\nbaz",
+ "100%",
+ "org.qutebrowser.qutebrowser",
+ "zed-industries/awesome-gpui: Awesome projects!",
+ ];
+ for case in cases {
+ assert_eq!(pct_decode(&pct_encode(case)), case);
+ }
+ }
+
+ #[test]
+ fn test_write_read_roundtrip() {
+ let dir = std::env::temp_dir().join("clearwm_state_test");
+ let _ = fs::create_dir_all(&dir);
+ let path = dir.join("clearwm_state");
+
+ let mut wm = WindowManager::default();
+ wm.active_tags = 0b1010; // tags 2 and 4
+ wm.tag_layouts[1] = TilingMode::Grid;
+ wm.has_tag_layout[1] = true;
+
+ // Add a window
+ let mut win = crate::types::Window::default();
+ win.id = 42;
+ win.identifier = Some("river-window-123".to_string());
+ win.app_id = Some("org.qutebrowser.qutebrowser".to_string());
+ win.title = Some("Test Page - qutebrowser".to_string());
+ win.tags = 0b100; // tag 3
+ win.tiling_mode = TilingMode::Fullscreen;
+ win.mode_locked = true;
+ wm.windows.push(win);
+
+ // Write
+ if let Some(parent) = path.parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ if let Ok(mut f) = fs::File::create(&path) {
+ // Manually write using the same format as write_state
+ let _ = writeln!(f, "active_tags={}", wm.active_tags);
+ for tag_bit in 0..NUM_TAGS {
+ let _ = writeln!(
+ f,
+ "tag_layout\t{}\t{}\t{}",
+ tag_bit,
+ wm.tag_layouts[tag_bit].as_str(),
+ wm.has_tag_layout[tag_bit]
+ );
+ }
+ for w in &wm.windows {
+ let ident = w.identifier.as_deref().map(pct_encode).unwrap_or("-".to_string());
+ let app_id = w.app_id.as_deref().map(pct_encode).unwrap_or("-".to_string());
+ let title = w.title.as_deref().map(pct_encode).unwrap_or("-".to_string());
+ let _ = writeln!(
+ f,
+ "window\t{}\t{}\t{}\t{}\t{}\t{}",
+ ident, app_id, title, w.tags, w.tiling_mode.as_str(), w.mode_locked
+ );
+ }
+ }
+
+ // Read and verify via the public read_state() path.
+ // We set HOME to our temp dir so state_file_path() finds the test file.
+ // Instead, use a direct parse approach:
+ let file = fs::File::open(&path).unwrap();
+ let reader = std::io::BufReader::new(file);
+ let mut active_tags: Option<u32> = None;
+ let mut tag_layouts = Vec::new();
+ let mut windows = Vec::new();
+ for line_r in reader.lines() {
+ let line = line_r.unwrap();
+ let line = line.trim();
+ if line.is_empty() { continue; }
+ if let Some(rest) = line.strip_prefix("active_tags=") {
+ active_tags = rest.parse::<u32>().ok();
+ } else if let Some(rest) = line.strip_prefix("tag_layout") {
+ let rest = rest.trim_start();
+ let parts: Vec<&str> = rest.split('\t').collect();
+ if parts.len() >= 3 {
+ if let Ok(idx) = parts[0].parse::<usize>() {
+ let mode = parse_tiling_mode_str(parts[1]);
+ let has = parts[2] == "true";
+ tag_layouts.push((idx, mode, has));
+ }
+ }
+ } else if let Some(rest) = line.strip_prefix("window") {
+ let rest = rest.trim_start();
+ let parts: Vec<&str> = rest.split('\t').collect();
+ if parts.len() >= 6 {
+ let identifier = if parts[0] == "-" { None } else { Some(pct_decode(parts[0])) };
+ let app_id = if parts[1] == "-" { None } else { Some(pct_decode(parts[1])) };
+ let title = if parts[2] == "-" { None } else { Some(pct_decode(parts[2])) };
+ let tags = parts[3].parse::<u32>().unwrap_or(1);
+ let tiling_mode = parse_tiling_mode_str(parts[4]);
+ let mode_locked = parts[5] == "true";
+ windows.push(PersistentWindow { identifier, app_id, title, tags, tiling_mode, mode_locked });
+ }
+ }
+ }
+ let state = PersistentState {
+ active_tags: active_tags.unwrap_or(1),
+ tag_layouts,
+ windows,
+ };
+ assert_eq!(state.active_tags, 0b1010);
+ assert_eq!(state.tag_layouts.len(), NUM_TAGS);
+ // Tag layout at index 1 should be Grid + has=true
+ let tl1 = state.tag_layouts.iter().find(|(idx, _, _)| *idx == 1);
+ assert!(tl1.is_some());
+ let (_, mode, has) = tl1.unwrap();
+ assert_eq!(*mode, TilingMode::Grid);
+ assert!(has);
+
+ assert_eq!(state.windows.len(), 1);
+ let pw = &state.windows[0];
+ assert_eq!(pw.identifier.as_deref(), Some("river-window-123"));
+ assert_eq!(pw.app_id.as_deref(), Some("org.qutebrowser.qutebrowser"));
+ assert_eq!(pw.title.as_deref(), Some("Test Page - qutebrowser"));
+ assert_eq!(pw.tags, 0b100);
+ assert_eq!(pw.tiling_mode, TilingMode::Fullscreen);
+ assert!(pw.mode_locked);
+
+ // Clean up
+ let _ = fs::remove_file(&path);
+ }
+}
diff --git a/src/status.rs b/src/status.rs
index 71f1866..914b1a9 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -50,13 +50,14 @@ pub fn write_status_files(state: &WindowManager) {
};
let _ = writeln!(
f,
- "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={}",
+ "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={} has_parent={}",
win.app_id.as_deref().unwrap_or("(null)"),
win.title.as_deref().unwrap_or("(null)"),
mode_str,
decoration_str,
presentation_str,
win.tags, win.x, win.y, win.width, win.height,
+ win.has_parent,
);
}
diff --git a/src/tiling.rs b/src/tiling.rs
index ef0b3d1..df4cfee 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -3,8 +3,8 @@
/// Cascade depth factor: each depth step multiplies channels by this
pub const CASCADE_DEPTH_FACTOR: f64 = 0.80;
-/// Full alpha in high-byte-first fixed-point
-pub const CASCADE_ALPHA: u32 = 0x000000FFu32;
+/// Full alpha, byte-replicated for River's color format
+pub const CASCADE_ALPHA: u32 = 0xFFFFFFFFu32;
/// Cascade base green (focused window) in fixed-point format
/// These come from the layout.border_r/g/b values, not hardcoded constants.
@@ -15,27 +15,31 @@ pub const CASCADE_ALPHA: u32 = 0x000000FFu32;
///
/// Returns (x, y, width, height) for the window at the given cascade index.
///
-/// The cascade formula from C:
-/// width = screen_w - (gap + bw)*2 - offset*(n_cascade - 1)
-/// height = screen_h - (gap + bw)*2 - offset*(n_cascade - 1)
-/// x = gap + bw + idx * offset
-/// y = bar_height + gap + bw + idx * offset
+/// Screen edges use per-side gaps; inter-window offset is cascade_offset.
+/// width = screen_w - gap_left - gap_right - bw*2 - cascade_offset*(n_cascade - 1)
+/// height = screen_h - gap_top - gap_bottom - bw*2 - cascade_offset*(n_cascade - 1)
+/// x = gap_left + bw + idx * cascade_offset
+/// y = bar_height + gap_top + bw + idx * cascade_offset
pub fn tile_cascade(
screen_w: i32,
screen_h: i32,
- gap: i32,
+ _gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: i32,
bw: i32,
- offset: i32,
+ cascade_offset: i32,
bar_height: i32,
n_cascade: i32,
idx: i32,
) -> (i32, i32, i32, i32) {
- let width = screen_w - (gap + bw) * 2 - offset * (n_cascade - 1);
- let height = screen_h - (gap + bw) * 2 - offset * (n_cascade - 1);
+ let width = screen_w - gap_left - gap_right - bw * 2 - cascade_offset * (n_cascade - 1);
+ let height = screen_h - gap_top - gap_bottom - bw * 2 - cascade_offset * (n_cascade - 1);
let width = if width < 1 { 1 } else { width };
let height = if height < 1 { 1 } else { height };
- let x = gap + bw + idx * offset;
- let y = bar_height + gap + bw + idx * offset;
+ let x = gap_left + bw + idx * cascade_offset;
+ let y = bar_height + gap_top + bw + idx * cascade_offset;
(x, y, width, height)
}
@@ -43,17 +47,21 @@ pub fn tile_cascade(
///
/// Uses a 2-column grid layout.
///
-/// The grid formula from C:
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
/// cols = 2
/// rows = (n_grid + cols - 1) / cols
-/// width = (screen_w - (cols + 1) * gap) / cols - 2 * bw
-/// height = (screen_h - (rows + 1) * gap) / rows - 2 * bw
-/// x = gap + bw + col * (width + 2 * bw + gap)
-/// y = bar_height + gap + bw + row * (height + 2 * bw + gap)
+/// width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw
+/// height = (screen_h - gap_top - gap_bottom - (rows - 1) * gap) / rows - 2 * bw
+/// x = gap_left + bw + col * (width + 2 * bw + gap)
+/// y = bar_height + gap_top + bw + row * (height + 2 * bw + gap)
pub fn tile_grid(
screen_w: i32,
screen_h: i32,
gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: i32,
bw: i32,
bar_height: i32,
n_grid: i32,
@@ -63,12 +71,12 @@ pub fn tile_grid(
let row = idx / cols;
let col = idx % cols;
let rows = (n_grid + cols - 1) / cols;
- let width = (screen_w - (cols + 1) * gap) / cols - 2 * bw;
- let height = (screen_h - (rows + 1) * gap) / rows - 2 * bw;
+ let width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw;
+ let height = (screen_h - gap_top - gap_bottom - (rows - 1) * gap) / rows - 2 * bw;
let width = if width < 1 { 1 } else { width };
let height = if height < 1 { 1 } else { height };
- let x = gap + bw + col * (width + 2 * bw + gap);
- let y = bar_height + gap + bw + row * (height + 2 * bw + gap);
+ let x = gap_left + bw + col * (width + 2 * bw + gap);
+ let y = bar_height + gap_top + bw + row * (height + 2 * bw + gap);
(x, y, width, height)
}
@@ -76,27 +84,32 @@ pub fn tile_grid(
///
/// Each window gets an equal share of the horizontal space.
///
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
/// 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
+/// width = (screen_w - gap_left - gap_right - (n - 1) * gap) / n - 2 * bw
+/// height = screen_h - gap_top - gap_bottom - bw * 2 - bar_height
+/// x = gap_left + bw + idx * (width + 2 * bw + gap)
+/// y = bar_height + gap_top + bw
pub fn tile_vsplit(
screen_w: i32,
screen_h: i32,
gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: 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 = (screen_w - gap_left - gap_right - (n - 1) * gap) / n - 2 * bw;
+ let height = screen_h - gap_top - gap_bottom - 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;
+ let x = gap_left + bw + idx * (width + 2 * bw + gap);
+ let y = bar_height + gap_top + bw;
(x, y, width, height)
}
@@ -104,46 +117,77 @@ pub fn tile_vsplit(
///
/// Each window gets an equal share of the vertical space.
///
+/// Screen edges use per-side gaps; inter-window spacing uses `gap`.
/// 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)
+/// width = screen_w - gap_left - gap_right - bw * 2
+/// height = (screen_h - bar_height - gap_top - gap_bottom - (n - 1) * gap) / n - 2 * bw
+/// x = gap_left + bw
+/// y = bar_height + gap_top + bw + idx * (height + 2 * bw + gap)
pub fn tile_hsplit(
screen_w: i32,
screen_h: i32,
gap: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: 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 = screen_w - gap_left - gap_right - bw * 2;
+ let height = (screen_h - bar_height - gap_top - gap_bottom - (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);
+ let x = gap_left + bw;
+ let y = bar_height + gap_top + bw + idx * (height + 2 * bw + gap);
(x, y, width, height)
}
-/// Interpolate a fixed-point channel (0xRR000000) by factor^depth.
+/// Tile a window in fullscreen mode — fills the screen minus gaps, bar, and borders.
+///
+/// Only the focused window is visible; other fullscreen windows are skipped.
+/// width = screen_w - gap_left - gap_right - bw * 2
+/// height = screen_h - bar_height - gap_top - gap_bottom - bw * 2
+/// x = gap_left + bw
+/// y = bar_height + gap_top + bw
+pub fn tile_fullscreen(
+ screen_w: i32,
+ screen_h: i32,
+ gap_top: i32,
+ gap_left: i32,
+ gap_right: i32,
+ gap_bottom: i32,
+ bw: i32,
+ bar_height: i32,
+) -> (i32, i32, i32, i32) {
+ let width = screen_w - gap_left - gap_right - bw * 2;
+ let height = screen_h - bar_height - gap_top - gap_bottom - bw * 2;
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+ let x = gap_left + bw;
+ let y = bar_height + gap_top + bw;
+ (x, y, width, height)
+}
+
+/// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
///
/// depth 0 returns the base color unchanged; each step darkens by factor.
-/// Returns the value in 32-bit fixed-point (channel in high byte).
+/// Returns byte-replicated 32-bit value for River's color format.
///
/// Ported from C: interp_channel()
pub fn interp_channel(fp_channel: u32, factor: f64, depth: i32) -> u32 {
- let base = (fp_channel >> 24) as u8;
+ let base = (fp_channel & 0xFF) as u8;
let f = factor.powi(depth);
let val = ((base as f64) * f) as u8;
- (val as u32) << 24
+ val as u32 * 0x01010101
}
/// Compute a "#RRGGBB" hex color string for a given cascade depth.
///
-/// Takes the base border color channels in fixed-point format and
+/// Takes the base border color channels in byte-replicated format and
/// interpolates them toward black by CASCADE_DEPTH_FACTOR^depth.
///
/// Ported from C: cascade_hex_color()
@@ -151,10 +195,10 @@ pub fn cascade_hex_color(r: u32, g: u32, b: u32, depth: i32) -> String {
let ri = interp_channel(r, CASCADE_DEPTH_FACTOR, depth);
let gi = interp_channel(g, CASCADE_DEPTH_FACTOR, depth);
let bi = interp_channel(b, CASCADE_DEPTH_FACTOR, depth);
- // Extract the high byte for display
- let rv = ri >> 24;
- let gv = gi >> 24;
- let bv = bi >> 24;
+ // Extract the low byte for display
+ let rv = ri & 0xFF;
+ let gv = gi & 0xFF;
+ let bv = bi & 0xFF;
format!("#{:02x}{:02x}{:02x}", rv, gv, bv)
}
@@ -165,35 +209,47 @@ mod tests {
#[test]
fn test_tile_cascade_single() {
// Single cascade window should fill the screen minus gaps/borders
- 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
+ let (x, y, w, h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 1, 0);
+ assert_eq!(x, 36); // gap_left + bw
+ assert_eq!(y, 64); // bar_height + gap_top + bw
+ // w = 1920 - 18 - 18 - 18*2 - 32*0 = 1920 - 72 = 1848
assert_eq!(w, 1848);
- // h = 1080 - (18+18)*2 - 32*0 = 1080 - 72 = 1008
+ // h = 1080 - 18 - 18 - 18*2 - 32*0 = 1080 - 72 = 1008
assert_eq!(h, 1008);
}
#[test]
fn test_tile_cascade_multiple() {
// 3 cascade windows, idx=2 (the back one)
- let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 32, 28, 3, 2);
- assert_eq!(x, 36 + 2 * 32); // gap + bw + idx * offset
- assert_eq!(y, 64 + 2 * 32); // bar + gap + bw + idx * offset
+ let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 3, 2);
+ assert_eq!(x, 36 + 2 * 32); // gap_left + bw + idx * offset
+ assert_eq!(y, 64 + 2 * 32); // bar + gap_top + bw + idx * offset
}
#[test]
fn test_tile_cascade_minimum_size() {
// Very small screen, many windows — should clamp to 1
- let (_, _, w, h) = tile_cascade(100, 100, 18, 18, 32, 28, 10, 5);
+ let (_, _, w, h) = tile_cascade(100, 100, 18, 18, 18, 18, 18, 18, 32, 28, 10, 5);
assert!(w >= 1);
assert!(h >= 1);
}
+ #[test]
+ fn test_tile_cascade_asymmetric_gaps() {
+ // Asymmetric screen gaps: top=10, left=20, right=30, bottom=40
+ let (x, y, w, h) = tile_cascade(1920, 1080, 12, 10, 20, 30, 40, 6, 24, 28, 1, 0);
+ assert_eq!(x, 20 + 6); // gap_left + bw = 26
+ assert_eq!(y, 28 + 10 + 6); // bar_height + gap_top + bw = 44
+ // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+ assert_eq!(w, 1858);
+ // h = 1080 - 10 - 40 - 6*2 = 1080 - 62 = 1018
+ assert_eq!(h, 1018);
+ }
+
#[test]
fn test_tile_grid_two_windows() {
- let (x0, y0, w0, h0) = tile_grid(1920, 1080, 18, 18, 28, 2, 0);
- let (x1, y1, w1, h1) = tile_grid(1920, 1080, 18, 18, 28, 2, 1);
+ let (x0, y0, w0, h0) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+ let (x1, y1, w1, h1) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
// Both windows should have same dimensions
assert_eq!(w0, w1);
@@ -203,20 +259,19 @@ mod tests {
assert!(x1 > x0);
assert_eq!(y0, y1); // Same row
- // Width = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+ // Width = (1920 - 18 - 18 - 1*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
assert_eq!(w0, 897);
- // Height = (1080 - 2*18) / 1 - 2*18 = 1044 - 36 = 1008
- // Wait: rows = (2+2-1)/2 = 1, so height = (1080 - 2*18)/1 - 36 = 1044 - 36 = 1008
+ // rows=1, Height = (1080 - 18 - 18 - 0*18) / 1 - 2*18 = 1044 - 36 = 1008
assert_eq!(h0, 1008);
}
#[test]
fn test_tile_grid_four_windows() {
// 4 windows in 2x2 grid
- let (x0, y0, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 0);
- let (x1, y1, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 1);
- let (x2, y2, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 2);
- let (x3, y3, _, _) = tile_grid(1920, 1080, 18, 18, 28, 4, 3);
+ let (x0, y0, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 0);
+ let (x1, y1, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 1);
+ let (x2, y2, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 2);
+ let (x3, y3, _, _) = tile_grid(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 4, 3);
// Row 0: windows 0,1
assert!(y0 == y1);
@@ -228,36 +283,57 @@ mod tests {
assert!(y2 > y0);
}
+ #[test]
+ fn test_tile_grid_asymmetric_gaps() {
+ // Asymmetric screen gaps with 2 windows in 1 row
+ let (x0, y0, w0, h0) = tile_grid(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+ let (x1, _y1, w1, _h1) = tile_grid(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+ // Width = (1920 - 20 - 30 - 1*12) / 2 - 2*6 = (1920-62)/2 - 12 = 929 - 12 = 917
+ assert_eq!(w0, 917);
+ assert_eq!(w0, w1);
+
+ // x0 = gap_left + bw = 20 + 6 = 26
+ assert_eq!(x0, 26);
+ // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+ assert_eq!(y0, 44);
+ // rows=1, Height = (1080 - 10 - 40 - 0*12) / 1 - 2*6 = 1030 - 12 = 1018
+ assert_eq!(h0, 1018);
+
+ // x1 = gap_left + bw + 1*(917 + 2*6 + 12) = 26 + 941 = 967
+ assert_eq!(x1, 967);
+ }
+
#[test]
fn test_interp_channel_depth_zero() {
// depth 0 should return the base color unchanged
- let result = interp_channel(0x5C000000, CASCADE_DEPTH_FACTOR, 0);
- assert_eq!(result >> 24, 0x5C);
+ let result = interp_channel(0x5C5C5C5C, CASCADE_DEPTH_FACTOR, 0);
+ assert_eq!(result, 0x5C5C5C5C);
}
#[test]
fn test_interp_channel_depth_one() {
- let result = interp_channel(0x90000000, CASCADE_DEPTH_FACTOR, 1);
- // 0x90 * 0.80 = 0x90 * 0.80 = 144 * 0.80 = 115.2 → 115 = 0x73
- assert_eq!(result >> 24, 0x73);
+ let result = interp_channel(0x90909090, CASCADE_DEPTH_FACTOR, 1);
+ // 0x90 * 0.80 = 144 * 0.80 = 115.2 → 115 = 0x73
+ assert_eq!(result & 0xFF, 0x73);
}
#[test]
fn test_interp_channel_depth_two() {
- let result = interp_channel(0x60000000, CASCADE_DEPTH_FACTOR, 2);
+ let result = interp_channel(0x60606060, CASCADE_DEPTH_FACTOR, 2);
// 0x60 * 0.80^2 = 96 * 0.64 = 61.44 → 61 = 0x3D
- assert_eq!(result >> 24, 0x3D);
+ assert_eq!(result & 0xFF, 0x3D);
}
#[test]
fn test_cascade_hex_color_depth_zero() {
- let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 0);
+ let color = cascade_hex_color(0x5C5C5C5C, 0x90909090, 0x60606060, 0);
assert_eq!(color, "#5c9060");
}
#[test]
fn test_cascade_hex_color_depth_one() {
- let color = cascade_hex_color(0x5C000000, 0x90000000, 0x60000000, 1);
+ let color = cascade_hex_color(0x5C5C5C5C, 0x90909090, 0x60606060, 1);
// 0x5C*0.80=0x49, 0x90*0.80=0x73, 0x60*0.80=0x4C
assert_eq!(color, "#49734c");
}
@@ -265,27 +341,27 @@ mod tests {
#[test]
fn test_cascade_hex_color_dark_gray() {
// Using #3e3e3e as base (normal border color)
- let color = cascade_hex_color(0x3E000000, 0x3E000000, 0x3E000000, 0);
+ let color = cascade_hex_color(0x3E3E3E3E, 0x3E3E3E3E, 0x3E3E3E3E, 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
+ let (x, y, w, h) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 1, 0);
+ assert_eq!(x, 36); // gap_left + bw
+ assert_eq!(y, 64); // bar_height + gap_top + bw
+ // w = (1920 - 18 - 18 - 0*18) / 1 - 2*18 = 1884 - 36 = 1848
assert_eq!(w, 1848);
- // h = 1080 - (18+18)*2 - 28 = 1080 - 72 - 28 = 980
+ // h = 1080 - 18 - 18 - 18*2 - 28 = 1080 - 100 = 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);
+ let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+ let (x1, y1, w1, h1) = tile_vsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
// Same dimensions
assert_eq!(w0, w1);
@@ -295,34 +371,55 @@ mod tests {
// Window 1 is to the right
assert!(x1 > x0);
- // w = (1920 - 3*18) / 2 - 2*18 = (1920-54)/2 - 36 = 933 - 36 = 897
+ // w = (1920 - 18 - 18 - 1*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);
+ let (_, _, w, h) = tile_vsplit(100, 100, 18, 18, 18, 18, 18, 18, 28, 10, 5);
assert!(w >= 1);
assert!(h >= 1);
}
+ #[test]
+ fn test_tile_vsplit_asymmetric_gaps() {
+ // Asymmetric screen gaps with 2 vsplit windows
+ let (x0, y0, w0, h0) = tile_vsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+ let (x1, _y1, w1, _h1) = tile_vsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+ // w = (1920 - 20 - 30 - 1*12) / 2 - 2*6 = (1920-62)/2 - 12 = 929 - 12 = 917
+ assert_eq!(w0, 917);
+ assert_eq!(w0, w1);
+
+ // x0 = gap_left + bw = 20 + 6 = 26
+ assert_eq!(x0, 26);
+ // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+ assert_eq!(y0, 44);
+ // h = 1080 - 10 - 40 - 6*2 - 28 = 1080 - 90 = 990
+ assert_eq!(h0, 990);
+
+ // x1 = gap_left + bw + 1*(917 + 2*6 + 12) = 26 + 941 = 967
+ assert_eq!(x1, 967);
+ }
+
#[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
+ let (x, y, w, h) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 1, 0);
+ assert_eq!(x, 36); // gap_left + bw
+ assert_eq!(y, 64); // bar_height + gap_top + bw
+ // w = 1920 - 18 - 18 - 18*2 = 1920 - 72 = 1848
assert_eq!(w, 1848);
- // h = (1080 - 28 - 2*18) / 1 - 2*18 = 1016 - 36 = 980
+ // h = (1080 - 28 - 18 - 18 - 0*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);
+ let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 0);
+ let (x1, y1, w1, h1) = tile_hsplit(1920, 1080, 18, 18, 18, 18, 18, 18, 28, 2, 1);
// Same dimensions
assert_eq!(w0, w1);
@@ -332,13 +429,69 @@ mod tests {
// 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
+ // h = (1080 - 28 - 18 - 18 - 1*18) / 2 - 2*18 = (1080-82)/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);
+ let (_, _, w, h) = tile_hsplit(100, 100, 18, 18, 18, 18, 18, 18, 28, 10, 5);
+ assert!(w >= 1);
+ assert!(h >= 1);
+ }
+
+ #[test]
+ fn test_tile_hsplit_asymmetric_gaps() {
+ // Asymmetric screen gaps with 2 hsplit windows
+ let (x0, y0, w0, h0) = tile_hsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 0);
+ let (_x1, y1, w1, h1) = tile_hsplit(1920, 1080, 12, 10, 20, 30, 40, 6, 28, 2, 1);
+
+ // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+ assert_eq!(w0, 1858);
+ assert_eq!(w0, w1);
+
+ // x0 = gap_left + bw = 20 + 6 = 26
+ assert_eq!(x0, 26);
+ // y0 = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+ assert_eq!(y0, 44);
+
+ // h = (1080 - 28 - 10 - 40 - 1*12) / 2 - 2*6 = (1080-90)/2 - 12 = 495 - 12 = 483
+ assert_eq!(h0, 483);
+ assert_eq!(h0, h1);
+
+ // y1 = bar_height + gap_top + bw + 1*(483 + 2*6 + 12) = 44 + 507 = 551
+ assert_eq!(y1, 551);
+ }
+
+ #[test]
+ fn test_tile_fullscreen_basic() {
+ let (x, y, w, h) = tile_fullscreen(1920, 1080, 18, 18, 18, 18, 6, 28);
+ // x = gap_left + bw = 18 + 6 = 24
+ assert_eq!(x, 24);
+ // y = bar_height + gap_top + bw = 28 + 18 + 6 = 52
+ assert_eq!(y, 52);
+ // w = 1920 - 18 - 18 - 6*2 = 1920 - 48 = 1872
+ assert_eq!(w, 1872);
+ // h = 1080 - 28 - 18 - 18 - 6*2 = 1080 - 76 = 1004
+ assert_eq!(h, 1004);
+ }
+
+ #[test]
+ fn test_tile_fullscreen_asymmetric_gaps() {
+ let (x, y, w, h) = tile_fullscreen(1920, 1080, 10, 20, 30, 40, 6, 28);
+ // x = gap_left + bw = 20 + 6 = 26
+ assert_eq!(x, 26);
+ // y = bar_height + gap_top + bw = 28 + 10 + 6 = 44
+ assert_eq!(y, 44);
+ // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
+ assert_eq!(w, 1858);
+ // h = 1080 - 28 - 10 - 40 - 6*2 = 1080 - 90 = 990
+ assert_eq!(h, 990);
+ }
+
+ #[test]
+ fn test_tile_fullscreen_minimum_size() {
+ let (_, _, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
assert!(w >= 1);
assert!(h >= 1);
}
diff --git a/src/types.rs b/src/types.rs
index 2f40721..bdcea97 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -40,6 +40,7 @@ pub enum Action {
Exit,
Fullscreen,
LayoutNext,
+ ModeNext,
Reload,
Restart,
View1,
@@ -59,27 +60,55 @@ pub enum Action {
/// Layout parameters
#[derive(Debug, Clone)]
pub struct Layout {
- pub gap: i32,
- pub offset: i32,
+ pub gap: i32, // inter-window spacing
+ pub gap_top: i32, // screen edge inset, top (above bar)
+ pub gap_left: i32, // screen edge inset, left
+ pub gap_right: i32, // screen edge inset, right
+ pub gap_bottom: i32, // screen edge inset, bottom
+ pub cascade_offset: i32,
pub bar_height: i32,
pub border_width: i32,
+ pub fullscreen_border_width: i32,
+ pub cascade_border_width: i32,
+ pub grid_border_width: i32,
+ pub vsplit_border_width: i32,
+ pub hsplit_border_width: i32,
+ pub floating_border_width: i32,
pub border_r: u32,
pub border_g: u32,
pub border_b: u32,
pub border_a: u32,
+ pub background_r: u32,
+ pub background_g: u32,
+ pub background_b: u32,
+ pub background_a: u32,
}
impl Default for Layout {
fn default() -> Self {
Layout {
gap: 48,
- offset: 20,
+ gap_top: 48,
+ gap_left: 48,
+ gap_right: 48,
+ gap_bottom: 48,
+ cascade_offset: 20,
bar_height: 24,
border_width: 6,
- border_r: 0x3E000000u32,
- border_g: 0x3E000000u32,
- border_b: 0x3E000000u32,
- border_a: 0x000000FFu32,
+ fullscreen_border_width: 0,
+ cascade_border_width: 6,
+ grid_border_width: 6,
+ vsplit_border_width: 6,
+ hsplit_border_width: 6,
+ floating_border_width: 6,
+ border_r: 0x3E3E3E3Eu32,
+ border_g: 0x3E3E3E3Eu32,
+ border_b: 0x3E3E3E3Eu32,
+ border_a: 0xFFFFFFFFu32,
+ background_r: 0x0A0A0A0Au32,
+ background_g: 0x1A1A1A1Au32,
+ background_b: 0x0E0E0E0Eu32,
+ background_a: 0xFFFFFFFFu32,
}
}
}
@@ -173,10 +202,25 @@ pub struct Window {
pub title: Option<String>,
pub identifier: Option<String>,
pub parent_id: Option<u64>,
+ pub has_parent: bool,
+ pub pid: u32,
+ pub hint_min_width: i32,
+ pub hint_min_height: i32,
+ pub hint_max_width: i32,
+ pub hint_max_height: i32,
pub decoration_hint: u32,
pub presentation_hint: u32,
+ pub fullscreen_requested: bool,
+ pub maximize_requested: bool,
+ pub minimize_requested: bool,
pub tiling_mode: TilingMode,
pub mode_locked: bool,
+ /// Whether we've queued an xprop check for XWayland parent detection.
+ /// River doesn't forward WM_TRANSIENT_FOR for XWayland windows, so we
+ /// check via xprop as a fallback.
+ pub needs_xprop_check: bool,
+ /// How many ManageStart cycles we've waited for the xprop result file.
+ pub xprop_check_attempts: u8,
}
impl Default for Window {
@@ -194,10 +238,21 @@ impl Default for Window {
title: None,
identifier: None,
parent_id: None,
+ has_parent: false,
+ pid: 0,
+ hint_min_width: 0,
+ hint_min_height: 0,
+ hint_max_width: 0,
+ hint_max_height: 0,
decoration_hint: 3, // no_preference
presentation_hint: 0, // vsync
+ fullscreen_requested: false,
+ maximize_requested: false,
+ minimize_requested: false,
tiling_mode: TilingMode::Floating,
mode_locked: false,
+ needs_xprop_check: false,
+ xprop_check_attempts: 0,
}
}
}
@@ -260,6 +315,17 @@ pub struct WindowManager {
/// 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,
+ /// When true, apply persisted state from ~/.cache/clearwm_state on the
+ /// next ManageStart cycle (after windows have been re-advertised).
+ /// Set to true on startup/restart, consumed after application.
+ pub needs_state_restore: bool,
+ /// Counter for how many ManageStart cycles we've waited for window metadata
+ /// before applying persisted state. Reset to 0 after state is applied.
+ pub state_restore_attempts: u8,
+ /// Whether tap-to-click is enabled on touchpad devices
+ pub tap_to_click: bool,
+ /// Whether tap-to-click config has been applied to libinput devices yet
+ pub tap_config_applied: bool,
}
impl Default for WindowManager {
@@ -289,6 +355,10 @@ impl Default for WindowManager {
startup_spawned: false,
output_scale: 0.0,
pending_scale_apply: false,
+ needs_state_restore: true,
+ state_restore_attempts: 0,
+ tap_to_click: false,
+ tap_config_applied: false,
}
}
}
@@ -349,7 +419,9 @@ impl WindowManager {
}
/// Parse a hex color string like "#RRGGBB" or "#RRGGBBAA" into
-/// fixed-point 32-bit channel values (0xRR000000 format)
+/// byte-replicated 32-bit channel values for River's color format.
+/// River divides each u32 by maxInt(u32) to get a float, so each byte
+/// must be replicated: 0xVV -> 0xVVVVVVVV.
pub fn parse_hex_color(s: &str) -> Option<(u32, u32, u32, u32)> {
let s = s.strip_prefix('#')?;
if s.len() != 6 && s.len() != 8 {
@@ -363,8 +435,8 @@ pub fn parse_hex_color(s: &str) -> Option<(u32, u32, u32, u32)> {
} else {
0xFFu32
};
- // Convert to high-byte-first fixed-point: 0xRR000000
- Some((r << 24, g << 24, b << 24, a << 24))
+ // Byte-replicate: 0xVV -> 0xVVVVVVVV
+ Some((r * 0x01010101, g * 0x01010101, b * 0x01010101, a * 0x01010101))
}
/// Parse a tiling mode string
@@ -394,6 +466,8 @@ pub fn parse_action(s: &str) -> Action {
Action::Resize
} else if s == "layout-next" {
Action::LayoutNext
+ } else if s == "mode-next" {
+ Action::ModeNext
} else if s == "reload" {
Action::Reload
} else if s == "restart" {
@@ -498,19 +572,19 @@ mod tests {
#[test]
fn test_parse_hex_color_rgb() {
let (r, g, b, a) = parse_hex_color("#3e3e3e").unwrap();
- assert_eq!(r, 0x3E000000);
- assert_eq!(g, 0x3E000000);
- assert_eq!(b, 0x3E000000);
- assert_eq!(a, 0xFF000000);
+ assert_eq!(r, 0x3E3E3E3E);
+ assert_eq!(g, 0x3E3E3E3E);
+ assert_eq!(b, 0x3E3E3E3E);
+ assert_eq!(a, 0xFFFFFFFF);
}
#[test]
fn test_parse_hex_color_rgba() {
let (r, g, b, a) = parse_hex_color("#5c9060ff").unwrap();
- assert_eq!(r, 0x5C000000);
- assert_eq!(g, 0x90000000);
- assert_eq!(b, 0x60000000);
- assert_eq!(a, 0xFF000000);
+ assert_eq!(r, 0x5C5C5C5C);
+ assert_eq!(g, 0x90909090);
+ assert_eq!(b, 0x60606060);
+ assert_eq!(a, 0xFFFFFFFF);
}
#[test]
@@ -574,5 +648,9 @@ mod tests {
assert_eq!(wm.global_layout, TilingMode::Cascade);
assert!(!wm.config_done);
assert_eq!(wm.layout.gap, 48);
+ assert_eq!(wm.layout.gap_top, 48);
+ assert_eq!(wm.layout.gap_left, 48);
+ assert_eq!(wm.layout.gap_right, 48);
+ assert_eq!(wm.layout.gap_bottom, 48);
}
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 987dad0..18e7628 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -9,6 +9,11 @@ use crate::protocol::river_input_management::client::{
river_input_device_v1::{self, RiverInputDeviceV1},
river_input_manager_v1::{self, RiverInputManagerV1},
};
+use crate::protocol::river_libinput_config::client::{
+ river_libinput_config_v1::{self, RiverLibinputConfigV1},
+ river_libinput_device_v1::{self, RiverLibinputDeviceV1},
+ river_libinput_result_v1::{self, RiverLibinputResultV1},
+};
use crate::protocol::river_layer_shell::client::{
river_layer_shell_output_v1::{self, RiverLayerShellOutputV1},
river_layer_shell_v1::{self, RiverLayerShellV1},
@@ -41,6 +46,7 @@ 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_LIBINPUT_CONFIG: &str = "river_libinput_config_v1";
const IFACE_WLR_OUTPUT_MANAGER: &str = "zwlr_output_manager_v1";
/// Wayland proxy objects stored alongside each Window, so we can
@@ -111,6 +117,22 @@ pub struct AppState {
// --- Status socket sender for waybar ---
pub status_sender: Option<crate::status_server::StatusSender>,
+
+ // --- Libinput config protocol state ---
+ pub libinput_config: Option<RiverLibinputConfigV1>,
+ /// Tracked libinput devices with their tap state
+ pub libinput_devices: Vec<LibinputDeviceInfo>,
+}
+
+/// Info tracked for each libinput device discovered via river_libinput_config_v1
+pub struct LibinputDeviceInfo {
+ pub device: RiverLibinputDeviceV1,
+ /// Device name (from the river_input_device_v1 name event)
+ pub name: String,
+ /// Number of fingers supported for tap (0 = unsupported)
+ pub tap_finger_count: i32,
+ /// Whether we've received enough events to apply tap config
+ pub tap_info_received: bool,
}
/// Tracked info for a wlr-output-management head.
@@ -145,6 +167,8 @@ impl AppState {
output_heads: Vec::new(),
output_config: None,
status_sender: None,
+ libinput_config: None,
+ libinput_devices: Vec::new(),
}
}
@@ -245,6 +269,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_LIBINPUT_CONFIG {
+ eprintln!("registry: binding {}", IFACE_LIBINPUT_CONFIG);
+ let lc: RiverLibinputConfigV1 =
+ registry.bind::<RiverLibinputConfigV1, _, _>(name, 1, qhandle, ());
+ state.libinput_config = Some(lc);
} else if interface == IFACE_WLR_OUTPUT_MANAGER {
eprintln!("registry: binding {}", IFACE_WLR_OUTPUT_MANAGER);
let om: ZwlrOutputManagerV1 =
@@ -288,6 +317,21 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
state.exit_requested = true;
} else {
eprintln!("river sent finished unexpectedly, restarting");
+ // Write to death log before fork+exit loses all traces
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open("/tmp/clearwm-death.log")
+ {
+ use std::io::Write;
+ let _ = writeln!(
+ f,
+ "river sent Finished event — protocol error likely, in_manage={}, render_count={}",
+ state.wm.in_manage_sequence,
+ state.render_count
+ );
+ let _ = f.sync_all();
+ }
crate::restart::wm_restart();
}
}
@@ -298,11 +342,115 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
state.wm.focused_tags = 0;
state.wm.needs_render = true;
- // Remove closed windows
+ // Remove closed windows and their proxy objects.
+ // Collect IDs first, then clean up proxies, then remove from windows vector.
+ // This matches tinyrwm's remove_windows() pattern.
+ let closed_ids: Vec<(u64, Option<String>)> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| w.closed)
+ .map(|w| (w.id, w.app_id.clone()))
+ .collect();
+ // Destroy proxy objects for closed windows.
+ // Also clean up seat state (focused_window_id, hovered_window_id,
+ // interacted_window_id) that references closed windows, matching
+ // tinyrwm's remove_windows() pattern which cleans up seat ops
+ // referencing closed window proxies.
+ for (closed_id, closed_app_id) in &closed_ids {
+ state.window_proxies.retain(|(id, wp)| {
+ if id == closed_id {
+ wp.river_window.destroy();
+ false
+ } else {
+ true
+ }
+ });
+ state.window_nodes.retain(|(id, node)| {
+ if id == closed_id {
+ node.destroy();
+ false
+ } else {
+ true
+ }
+ });
+ // Clear seat references to this closed window.
+ // If the focused window closed, reassign focus to another
+ // visible window (matching tinyrwm's focus_top pattern).
+ let active_tags = state.wm.active_tags;
+ for seat in &mut state.wm.seats {
+ if seat.focused_window_id == Some(*closed_id) {
+ // Pick the last visible window (cascade front)
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| {
+ !w.closed && (w.tags & active_tags) != 0 && w.id != *closed_id
+ })
+ .map(|w| w.id)
+ .collect();
+ seat.focused_window_id = visible_ids.last().copied();
+ if seat.focused_window_id.is_some() {
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
+ }
+ eprintln!(
+ "[manage] focused window {} (app_id={:?}) closed, reassigned to {:?}",
+ closed_id, closed_app_id, seat.focused_window_id
+ );
+ }
+ if seat.hovered_window_id == Some(*closed_id) {
+ seat.hovered_window_id = None;
+ }
+ if seat.interacted_window_id == Some(*closed_id) {
+ seat.interacted_window_id = None;
+ }
+ }
+ }
state.wm.windows.retain(|w| !w.closed);
- // Remove removed outputs
+
+ // Remove removed outputs — destroy protocol proxies first
+ // (matching tinyrwm's remove_outputs pattern).
+ let removed_output_ids: Vec<u64> = state
+ .wm
+ .outputs
+ .iter()
+ .filter(|o| o.removed)
+ .map(|o| o.id)
+ .collect();
+ for removed_id in &removed_output_ids {
+ state.output_proxies.retain(|(id, op)| {
+ if id == removed_id {
+ op.river_output.destroy();
+ false
+ } else {
+ true
+ }
+ });
+ }
state.wm.outputs.retain(|o| !o.removed);
- // Remove removed seats
+
+ // Remove removed seats — destroy protocol proxies first
+ // (matching tinyrwm's remove_seats pattern which destroys
+ // seat proxy and all binding proxies).
+ let removed_seat_ids: Vec<u64> = state
+ .wm
+ .seats
+ .iter()
+ .filter(|s| s.removed)
+ .map(|s| s.id)
+ .collect();
+ for removed_id in &removed_seat_ids {
+ state.seat_proxies.retain(|(id, sp)| {
+ if id == removed_id {
+ sp.river_seat.destroy();
+ false
+ } else {
+ true
+ }
+ });
+ }
state.wm.seats.retain(|s| !s.removed);
// Set default layer shell on first output
@@ -329,6 +477,116 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
}
+ // Check xprop results for XWayland parent detection.
+ // River doesn't send the Parent event for XWayland windows,
+ // so we detect WM_TRANSIENT_FOR / _NET_WM_WINDOW_TYPE_DIALOG|UTILITY
+ // via an async xprop check spawned in the UnreliablePid handler.
+ // This must run before assign_window_modes so has_parent is set
+ // before mode assignment decides Floating vs tiled.
+ {
+ let mut xprop_parent_changed = false;
+ let max_attempts: u8 = 10;
+ for window in &mut state.wm.windows {
+ if !window.needs_xprop_check || window.has_parent || window.closed {
+ continue;
+ }
+ window.xprop_check_attempts += 1;
+ let path = format!("/tmp/clearwm-xprop-{}", window.id);
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ let _ = std::fs::remove_file(&path);
+ // Each line: title|window_type_line
+ // Match by title against our window's title.
+ let my_title = window.title.as_deref().unwrap_or("");
+ for line in content.lines() {
+ let parts: Vec<&str> = line.splitn(2, '|').collect();
+ if parts.len() < 2 {
+ continue;
+ }
+ let x11_title = parts[0];
+ let wtype = parts[1];
+ if x11_title != my_title {
+ continue;
+ }
+ let is_dialog =
+ wtype.contains("DIALOG") || wtype.contains("UTILITY");
+ // Only use the window type hint, not WM_TRANSIENT_FOR alone.
+ // Transient-for is unreliable: some apps (e.g. Houdini)
+ // set WM_TRANSIENT_FOR on their main window (transient to the
+ // splash screen). The window type hint is the reliable signal
+ // that a window is actually a dialog/utility child.
+ if is_dialog {
+ window.has_parent = true;
+ xprop_parent_changed = true;
+ eprintln!(
+ "[xprop] id={} (app_id={:?}) detected XWayland dialog/utility window",
+ window.id, window.app_id
+ );
+ }
+ }
+ window.needs_xprop_check = false;
+ } else if window.xprop_check_attempts >= max_attempts {
+ // Give up — xprop file never appeared (native Wayland window?)
+ window.needs_xprop_check = false;
+ eprintln!(
+ "[xprop] id={} (app_id={:?}) giving up after {} attempts",
+ window.id, window.app_id, max_attempts
+ );
+ }
+ }
+ // If any window gained has_parent, trigger a re-manage so
+ // assign_window_modes picks it up on the next cycle.
+ if xprop_parent_changed {
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
+ }
+ }
+
+ // Apply persisted state from ~/.cache/clearwm_state on first ManageStart.
+ // This restores window tag assignments, active_tags, tag_layouts, and
+ // locked tiling modes from the previous session. Must run before
+ // assign_window_modes so restored tags/modes take effect.
+ //
+ // We retry across multiple ManageStart cycles because window metadata
+ // (identifier, app_id, title) arrives as separate events AFTER the
+ // Window creation event. On the first ManageStart, these fields may
+ // still be None, so matching would fail. We keep needs_state_restore
+ // true until we've had at least one window with metadata (or after
+ // 5 cycles, giving up gracefully).
+ if state.wm.needs_state_restore {
+ if let Some(pstate) = crate::state::read_state() {
+ // Check if any window has metadata yet
+ let has_metadata = state.wm.windows.iter().any(|w| {
+ w.identifier.is_some() || w.app_id.is_some()
+ });
+ if has_metadata {
+ crate::state::apply_state(&mut state.wm, &pstate);
+ state.wm.needs_state_restore = false;
+ eprintln!("[state] restored state on ManageStart (windows have metadata)");
+ } else {
+ // Increment a counter; give up after 5 cycles
+ state.wm.state_restore_attempts += 1;
+ if state.wm.state_restore_attempts >= 5 {
+ // No windows with metadata yet — probably a fresh start
+ // with no existing windows. Apply global state only
+ // (active_tags, tag_layouts) and stop retrying.
+ state.wm.active_tags = pstate.active_tags;
+ for (idx, mode, has) in &pstate.tag_layouts {
+ if *idx < crate::types::NUM_TAGS {
+ state.wm.tag_layouts[*idx] = *mode;
+ state.wm.has_tag_layout[*idx] = *has;
+ }
+ }
+ state.wm.needs_state_restore = false;
+ eprintln!("[state] no windows with metadata after 5 cycles, applied global state only");
+ }
+ }
+ } else {
+ // No state file — fresh start
+ state.wm.needs_state_restore = false;
+ }
+ }
+
// 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);
@@ -338,6 +596,47 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
// during a manage sequence (per River protocol spec).
crate::wm::manage_windows(state, qhandle);
+ // Auto-focus: new windows on the current tags get keyboard focus.
+ // This runs after manage_windows so the new window is tiled and
+ // has a valid position/proxy, but before focus application so
+ // needs_focus will trigger the actual focus_window() call.
+ {
+ let active_tags = state.wm.active_tags;
+ // Find the last new window on active tags (cascade-front).
+ // Multiple new windows can appear in one manage cycle (e.g.
+ // spawning several apps at once); focusing the last one is
+ // consistent with FocusNext and WindowInteraction which also
+ // pick visible_ids.last().
+ let new_focused_id = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| w.is_new && !w.closed && (w.tags & active_tags) != 0)
+ .map(|w| w.id)
+ .last();
+ if let Some(new_id) = new_focused_id {
+ for seat in &mut state.wm.seats {
+ if seat.removed {
+ continue;
+ }
+ seat.focused_window_id = Some(new_id);
+ }
+ // Move to cascade-front position (last in windows vec)
+ state.wm.move_window_to_end(new_id);
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
+ let app_id = state.wm.get_window(new_id).and_then(|w| w.app_id.clone());
+ eprintln!(
+ "[focus] auto-focusing new window id={} (app_id={:?})",
+ new_id, app_id
+ );
+ }
+ // Clear is_new on all windows (only matters once)
+ for window in &mut state.wm.windows {
+ window.is_new = false;
+ }
+ }
+
// 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
@@ -352,7 +651,14 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
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);
+ let app_id = state
+ .wm
+ .get_window(focused_id)
+ .and_then(|w| w.app_id.clone());
+ eprintln!(
+ "[focus] calling focus_window for id={} (app_id={:?})",
+ focused_id, app_id
+ );
sp.river_seat.focus_window(&wp.river_window);
}
}
@@ -394,6 +700,11 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
wm_proxy.manage_finish();
+ // Flush immediately so River can start the configure/render cycle
+ // without waiting for our blocking_dispatch to complete.
+ if let Err(e) = conn.flush() {
+ eprintln!("[manage] FATAL: flush after manage_finish failed: {:?}", e);
+ }
state.wm.in_manage_sequence = false;
eprintln!("[manage] ManageStart done in {:?}", ms_start.elapsed());
// NOTE: Do NOT call update_status_files() here — it calls
@@ -441,9 +752,13 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
.find(|(id, _)| *id == focused_id)
.map(|(_, n)| n)
{
+ let app_id = state
+ .wm
+ .get_window(focused_id)
+ .and_then(|w| w.app_id.clone());
eprintln!(
- "[render] place_top for focused window id={}",
- focused_id
+ "[render] place_top for focused window id={} (app_id={:?})",
+ focused_id, app_id
);
node.place_top();
}
@@ -454,8 +769,16 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
wm_proxy.render_finish();
- eprintln!("[render] render_finish #{} queued", state.render_count);
-
+ // Flush immediately so River receives render_finish without waiting
+ // for blocking_dispatch to complete. River has a 3-second unresponsive
+ // timeout, and if we don't flush promptly, River will kill us.
+ if let Err(e) = conn.flush() {
+ eprintln!(
+ "[render] FATAL: flush after render_finish #{} failed: {:?}",
+ state.render_count, e
+ );
+ }
+ eprintln!("[render] render_finish #{} flushed", 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.
@@ -475,6 +798,10 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
if state.wm.needs_status_update {
crate::status::write_status_files(&state.wm);
+ // Persist state to ~/.cache/clearwm_state for restart recovery.
+ // Safe: just file I/O, no fork, no blocking.
+ crate::state::write_state(&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 {
@@ -484,6 +811,11 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
state.wm.needs_status_update = false;
}
+
+ // Re-apply tap-to-click config if it was changed via IPC
+ if !state.wm.tap_config_applied && !state.libinput_devices.is_empty() {
+ crate::wayland::apply_tap_config(state, qhandle);
+ }
}
// Window event: field is `id` (the new RiverWindowV1 proxy)
@@ -506,7 +838,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
state
.window_proxies
.push((id, WindowProxy { river_window }));
- eprintln!("wm_handle_window: new window id={}", id);
+ eprintln!("[window] new window id={} (app_id pending)", id);
}
// Output event: field is `id` (the new RiverOutputV1 proxy)
@@ -593,7 +925,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
river_window_v1::Event::Closed => {
if let Some(window) = state.wm.get_window_mut(wid) {
window.closed = true;
- eprintln!("window id={} closed", wid);
+ eprintln!("window id={} (app_id={:?}) closed", wid, window.app_id);
}
}
@@ -605,10 +937,21 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
}
river_window_v1::Event::AppId { app_id } => {
+ let mut re_eval = false;
if let Some(window) = state.wm.get_window_mut(wid) {
if window.app_id != app_id {
+ eprintln!(
+ "[window] id={} app_id: {:?} -> {:?}",
+ wid, window.app_id, app_id
+ );
window.app_id = app_id;
- state.wm.needs_render = true;
+ re_eval = !window.mode_locked;
+ }
+ }
+ if re_eval {
+ state.wm.needs_render = true;
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
}
}
}
@@ -641,6 +984,146 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
}
}
+ river_window_v1::Event::UnreliablePid { unreliable_pid } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.pid = unreliable_pid as u32;
+ // Spawn an async xprop check for XWayland parent detection.
+ // River doesn't forward WM_TRANSIENT_FOR for XWayland windows,
+ // so we check via xdotool + xprop as a fallback.
+ // The script writes results to /tmp/clearwm-xprop-{wid} which
+ // is read on the next ManageStart cycle.
+ if !window.has_parent && window.pid > 0 {
+ let pid = window.pid;
+ let id = window.id;
+ let cmd = format!(
+ "for xid in $(xdotool search --pid {pid} 2>/dev/null); do \
+ t=$(xdotool getwindowname $xid 2>/dev/null); \
+ wt=$(xprop -id $xid _NET_WM_WINDOW_TYPE 2>/dev/null); \
+ printf '%s|%s\\n' \"$t\" \"$wt\"; \
+ done > /tmp/clearwm-xprop-{id}",
+ pid = pid,
+ id = id
+ );
+ crate::config::spawn_command_bg(&cmd);
+ window.needs_xprop_check = true;
+ eprintln!(
+ "[window] id={} (app_id={:?}) spawned xprop check for pid={}",
+ wid, window.app_id, pid
+ );
+ }
+ }
+ }
+
+ river_window_v1::Event::DimensionsHint {
+ min_width,
+ min_height,
+ max_width,
+ max_height,
+ } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.hint_min_width = min_width;
+ window.hint_min_height = min_height;
+ window.hint_max_width = max_width;
+ window.hint_max_height = max_height;
+ eprintln!(
+ "[window] id={} (app_id={:?}) dimensions_hint: min={}x{} max={}x{}",
+ wid, window.app_id, min_width, min_height, max_width, max_height
+ );
+ }
+ }
+
+ river_window_v1::Event::Parent { parent } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ let had_parent = window.has_parent;
+ match &parent {
+ Some(parent_proxy) => {
+ window.has_parent = true;
+ // Look up our internal ID for the parent proxy
+ let parent_id = state
+ .window_proxies
+ .iter()
+ .find(|(_, wp)| {
+ wp.river_window.id().protocol_id()
+ == parent_proxy.id().protocol_id()
+ })
+ .map(|(id, _)| *id);
+ window.parent_id = parent_id;
+ eprintln!(
+ "[window] id={} (app_id={:?}) has parent (internal_id={:?})",
+ wid, window.app_id, parent_id
+ );
+ }
+ None => {
+ window.has_parent = false;
+ window.parent_id = None;
+ }
+ }
+ // Parent status changed: re-assign mode (child windows float)
+ if window.has_parent != had_parent && !window.mode_locked {
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
+ }
+ }
+ }
+
+ river_window_v1::Event::FullscreenRequested { .. } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.fullscreen_requested = true;
+ eprintln!(
+ "[window] id={} (app_id={:?}) requested fullscreen",
+ wid, window.app_id
+ );
+ }
+ }
+
+ river_window_v1::Event::ExitFullscreenRequested { .. } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.fullscreen_requested = false;
+ eprintln!(
+ "[window] id={} (app_id={:?}) requested exit fullscreen",
+ wid, window.app_id
+ );
+ }
+ }
+
+ river_window_v1::Event::MaximizeRequested { .. } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.maximize_requested = true;
+ eprintln!(
+ "[window] id={} (app_id={:?}) requested maximize",
+ wid, window.app_id
+ );
+ }
+ }
+
+ river_window_v1::Event::UnmaximizeRequested { .. } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.maximize_requested = false;
+ eprintln!(
+ "[window] id={} (app_id={:?}) requested unmaximize",
+ wid, window.app_id
+ );
+ }
+ }
+
+ river_window_v1::Event::MinimizeRequested { .. } => {
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.minimize_requested = true;
+ eprintln!(
+ "[window] id={} (app_id={:?}) requested minimize",
+ wid, window.app_id
+ );
+ }
+ }
+
+ river_window_v1::Event::ShowWindowMenuRequested { x, y } => {
+ eprintln!(
+ "[window] id={} show_window_menu_requested at ({}, {}) — ignored",
+ wid, x, y
+ );
+ }
+
river_window_v1::Event::PointerMoveRequested { .. } => {
// Will handle pointer ops later
}
@@ -681,12 +1164,14 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
window: river_window,
} => {
if let Some(wid) = state.window_id_for_proxy(&river_window) {
+ let target_app_id = state.wm.get_window(wid).and_then(|w| w.app_id.clone());
if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
eprintln!(
- "[focus] WindowInteraction: seat={} focused_window_id={} -> {}",
+ "[focus] WindowInteraction: seat={} focused_window_id={} -> {} (app_id={:?})",
sid,
seat.focused_window_id.unwrap_or(0),
- wid
+ wid,
+ target_app_id
);
seat.focused_window_id = Some(wid);
// Move clicked window to front of cascade stack
@@ -991,8 +1476,7 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
.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;
+ let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
for fd in 3..max_fd {
libc::close(fd);
}
@@ -1004,16 +1488,25 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
}
}
Action::Close => {
- // 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.
+ // Ask the compositor to close the focused window by calling
+ // close() on its River protocol proxy. This matches tinyrwm's
+ // approach: close() sends a request to River, which asks the
+ // client to close. When the client actually closes, River sends
+ // Event::Closed, which sets window.closed = true. Then on the
+ // next ManageStart, remove_windows() drops it from the vector.
+ //
+ // close() modifies window management state and can only be called
+ // during a manage sequence — which it is, since execute_action
+ // runs inside ManageStart.
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) {
- window.closed = true;
+ // Send the close request to the compositor
+ if let Some(wp) = state.get_window_proxy(focused_id) {
+ wp.river_window.close();
}
- // Shift focus to the next visible window (excluding the one we just closed)
+ // Shift focus to the next visible window (excluding the one we just closed).
+ // The window isn't closed=true yet (that happens when River sends Event::Closed),
+ // so we exclude it by ID instead.
let visible_ids: Vec<u64> = state
.wm
.windows
@@ -1106,10 +1599,21 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
title,
identifier: None,
parent_id: None,
+ has_parent: false,
+ pid: 0,
+ hint_min_width: 0,
+ hint_min_height: 0,
+ hint_max_width: 0,
+ hint_max_height: 0,
decoration_hint: 3,
presentation_hint: 0,
+ fullscreen_requested: false,
+ maximize_requested: false,
+ minimize_requested: false,
tiling_mode: TilingMode::Fullscreen,
mode_locked,
+ needs_xprop_check: false,
+ xprop_check_attempts: 0,
};
crate::wm::get_mode_for_window(&state.wm, &temp_win)
.unwrap_or(state.wm.global_layout)
@@ -1137,25 +1641,86 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
TilingMode::Grid,
TilingMode::Vsplit,
TilingMode::Hsplit,
+ TilingMode::Fullscreen,
];
- let current = state.wm.global_layout;
+ // Cycle the layout for the currently active tag(s) only.
+ // Determine the "current" mode from the first active tag's layout
+ // (or global_layout if no tag_layout is set for it), then advance.
+ let active_tags = state.wm.active_tags;
+ let first_tag_bit = (0..crate::types::NUM_TAGS).find(|b| (active_tags & (1u32 << b)) != 0);
+ let current = if let Some(bit) = first_tag_bit {
+ if state.wm.has_tag_layout[bit] {
+ state.wm.tag_layouts[bit]
+ } else {
+ state.wm.global_layout
+ }
+ } else {
+ state.wm.global_layout
+ };
let next = cycle
.iter()
.position(|m| *m == current)
.map(|i| cycle[(i + 1) % cycle.len()])
.unwrap_or(TilingMode::Cascade);
- state.wm.global_layout = next;
- eprintln!("layout-next: global layout is now {}", next.as_str());
+
+ // Set the layout for every currently active tag.
+ for tag_bit in 0..crate::types::NUM_TAGS {
+ if (active_tags & (1u32 << tag_bit)) != 0 {
+ state.wm.tag_layouts[tag_bit] = next;
+ state.wm.has_tag_layout[tag_bit] = true;
+ }
+ }
+ eprintln!(
+ "layout-next: tag layout set to {} for active_tags=0b{:b}",
+ next.as_str(),
+ active_tags
+ );
// 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.
+ // Only windows that fell through to tag_layouts/global_layout will change.
state.wm.needs_render = true;
state.wm.needs_status_update = true;
}
+ Action::ModeNext => {
+ let cycle = [
+ TilingMode::Cascade,
+ TilingMode::Grid,
+ TilingMode::Vsplit,
+ TilingMode::Hsplit,
+ TilingMode::Fullscreen,
+ TilingMode::Floating,
+ ];
+ let focused_id = state
+ .wm
+ .seats
+ .iter()
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id);
+ if let Some(fid) = focused_id {
+ if let Some(win) = state.wm.get_window_mut(fid) {
+ let next = cycle
+ .iter()
+ .position(|m| *m == win.tiling_mode)
+ .map(|i| cycle[(i + 1) % cycle.len()])
+ .unwrap_or(TilingMode::Cascade);
+ eprintln!(
+ "mode-next: window {} ({:?}) {} -> {}",
+ fid,
+ win.app_id,
+ win.tiling_mode.as_str(),
+ next.as_str()
+ );
+ win.tiling_mode = next;
+ win.mode_locked = true;
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ }
+ }
+ }
Action::Reload => {
// TODO: implement reload (re-run config)
eprintln!("reload: not yet implemented");
@@ -1247,10 +1812,8 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
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| {
+ let window_left_active_tag =
+ state.wm.get_window_mut(focused_id).map_or(false, |window| {
window.tags = 1 << (tag - 1);
(window.tags & active_tags) == 0
});
@@ -1760,3 +2323,149 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
Ok((conn, event_queue, state))
}
+
+// --- RiverLibinputConfigV1 events ---
+
+impl Dispatch<RiverLibinputConfigV1, ()> for AppState {
+ event_created_child!(AppState, RiverLibinputConfigV1, [
+ river_libinput_config_v1::EVT_LIBINPUT_DEVICE_OPCODE => (RiverLibinputDeviceV1, ()),
+ ]);
+
+ fn event(
+ state: &mut Self,
+ _proxy: &RiverLibinputConfigV1,
+ event: river_libinput_config_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ river_libinput_config_v1::Event::LibinputDevice { id: device } => {
+ eprintln!("[libinput] device discovered");
+ state.libinput_devices.push(LibinputDeviceInfo {
+ device,
+ name: String::new(),
+ tap_finger_count: -1, // not yet received
+ tap_info_received: false,
+ });
+ }
+ river_libinput_config_v1::Event::Finished => {}
+ _ => {}
+ }
+ }
+}
+
+// --- RiverLibinputDeviceV1 events ---
+
+impl Dispatch<RiverLibinputDeviceV1, ()> for AppState {
+ fn event(
+ state: &mut Self,
+ _proxy: &RiverLibinputDeviceV1,
+ event: river_libinput_device_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ river_libinput_device_v1::Event::TapSupport { finger_count } => {
+ if let Some(dev) = state.libinput_devices.last_mut() {
+ dev.tap_finger_count = finger_count;
+ eprintln!("[libinput] tap support: {} fingers", finger_count);
+ }
+ // Don't apply tap config here — devices arrive one at a time.
+ // If we apply after the first device (which may not support tap),
+ // tap_config_applied gets set too early and we miss the touchpad.
+ // Instead, apply in RenderStart after all devices have been discovered.
+ }
+ river_libinput_device_v1::Event::TapDefault { state: tap_state } => {
+ let _ = tap_state;
+ eprintln!("[libinput] tap default received");
+ }
+ river_libinput_device_v1::Event::TapCurrent { state: tap_state } => {
+ let _ = tap_state;
+ eprintln!("[libinput] tap current received");
+ }
+ river_libinput_device_v1::Event::Removed => {
+ eprintln!("[libinput] device removed");
+ }
+ _ => {}
+ }
+ }
+}
+
+// --- RiverLibinputResultV1 events ---
+
+impl Dispatch<RiverLibinputResultV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &RiverLibinputResultV1,
+ event: river_libinput_result_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ river_libinput_result_v1::Event::Success => {
+ eprintln!("[libinput] config applied successfully");
+ }
+ river_libinput_result_v1::Event::Unsupported => {
+ eprintln!("[libinput] config unsupported by device");
+ }
+ river_libinput_result_v1::Event::Invalid => {
+ eprintln!("[libinput] config invalid");
+ }
+ }
+ }
+}
+
+/// Apply tap-to-click configuration to all libinput devices that support it.
+/// Called after device events arrive and after config changes.
+pub fn apply_tap_config(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+ if state.wm.tap_config_applied {
+ return;
+ }
+
+ // Wait until ALL discovered devices have received their tap_support event.
+ // Devices arrive one at a time; if we mark tap_config_applied after only
+ // the first device (which may not support tap), we'll miss the touchpad.
+ let all_info_received = state.libinput_devices.iter().all(|d| d.tap_finger_count >= 0);
+ if !all_info_received {
+ return;
+ }
+
+ let tap_to_click = state.wm.tap_to_click;
+
+ for dev_info in &mut state.libinput_devices {
+ if dev_info.tap_info_received {
+ continue; // Already applied to this device
+ }
+ if dev_info.tap_finger_count == 0 {
+ // Device doesn't support tap-to-click
+ dev_info.tap_info_received = true;
+ continue;
+ }
+
+ // Device supports tap — apply config
+ let tap_state = if tap_to_click {
+ river_libinput_device_v1::TapState::Enabled
+ } else {
+ river_libinput_device_v1::TapState::Disabled
+ };
+
+ eprintln!(
+ "[libinput] setting tap={} on device ({})",
+ if tap_to_click { "enabled" } else { "disabled" },
+ if dev_info.name.is_empty() { "unnamed" } else { &dev_info.name }
+ );
+
+ dev_info.device.set_tap(tap_state, qhandle, ());
+ dev_info.tap_info_received = true;
+ }
+
+ // Only mark as fully applied once all devices have been configured
+ let all_done = state.libinput_devices.iter().all(|d| d.tap_info_received);
+ if all_done && !state.libinput_devices.is_empty() {
+ state.wm.tap_config_applied = true;
+ eprintln!("[libinput] tap config applied to all devices");
+ }
+}
diff --git a/src/wm.rs b/src/wm.rs
index d7de94d..f9c0fda 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -25,6 +25,11 @@ pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMod
return None;
}
+ // 0. Windows with a parent (dialogs, file pickers, etc.) always float.
+ if win.has_parent {
+ return Some(TilingMode::Floating);
+ }
+
// 1. Check mode_rules for a match on app_id/title
for rule in &wm.mode_rules {
let match_app = rule.app_id_pattern == "*"
@@ -176,8 +181,11 @@ fn ensure_window_nodes(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
/// Compute tiling for all visible windows (read-only, returns results).
fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileResult> {
let gap = wm.layout.gap;
- let bw = wm.layout.border_width;
- let offset = wm.layout.offset;
+ let gap_top = wm.layout.gap_top;
+ let gap_left = wm.layout.gap_left;
+ let gap_right = wm.layout.gap_right;
+ let gap_bottom = wm.layout.gap_bottom;
+ let cascade_offset = wm.layout.cascade_offset;
let bar_height = wm.layout.bar_height;
// Count windows per tiling mode
@@ -198,14 +206,24 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
}
}
- // Check for fullscreen window
+ // Check for fullscreen window — prefer the focused window so FocusNext
+ // cycles visible windows when fullscreen is used as a layout mode.
let fullscreen_id = wm
- .windows
+ .seats
.iter()
- .find(|w| {
- (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+ .find(|s| !s.removed)
+ .and_then(|s| s.focused_window_id)
+ .filter(|&fid| {
+ wm.get_window(fid).map_or(false, |w| {
+ (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+ })
})
- .map(|w| w.id);
+ .or_else(|| {
+ // Fallback: first fullscreen window if no focused window qualifies
+ wm.windows.iter().find(|w| {
+ (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen
+ }).map(|w| w.id)
+ });
// Compute tiling
let mut results = Vec::new();
@@ -225,7 +243,16 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
let (x, y, w, h) = match mode {
TilingMode::Fullscreen => {
if fullscreen_id == Some(wid) {
- (0, 0, screen_w, screen_h)
+ tiling::tile_fullscreen(
+ screen_w,
+ screen_h,
+ gap_top,
+ gap_left,
+ gap_right,
+ gap_bottom,
+ wm.layout.fullscreen_border_width,
+ bar_height,
+ )
} else {
continue;
}
@@ -235,8 +262,12 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
screen_w,
screen_h,
gap,
- bw,
- offset,
+ gap_top,
+ gap_left,
+ gap_right,
+ gap_bottom,
+ wm.layout.cascade_border_width,
+ cascade_offset,
bar_height,
n_cascade,
idx_cascade,
@@ -246,20 +277,20 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
}
TilingMode::Grid => {
let (x, y, w, h) =
- tiling::tile_grid(screen_w, screen_h, gap, bw, bar_height, n_grid, idx_grid);
+ tiling::tile_grid(screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.grid_border_width, bar_height, n_grid, idx_grid);
idx_grid += 1;
(x, y, w, h)
}
TilingMode::Vsplit => {
let (x, y, w, h) = tiling::tile_vsplit(
- screen_w, screen_h, gap, bw, bar_height, n_vsplit, idx_vsplit,
+ screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.vsplit_border_width, 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,
+ screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.hsplit_border_width, bar_height, n_hsplit, idx_hsplit,
);
idx_hsplit += 1;
(x, y, w, h)
@@ -270,6 +301,7 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
// triggers River's unresponsive-client detection).
// Use the window's existing dimensions, or a reasonable
// default if unset.
+ let fbw = wm.layout.floating_border_width;
let fw = if win.width > 0 {
win.width
} else {
@@ -283,12 +315,12 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
let fx = if win.x != 0 || win.y != 0 {
win.x
} else {
- gap + bw + offset * idx_cascade
+ gap_left + fbw + cascade_offset * idx_cascade
};
let fy = if win.x != 0 || win.y != 0 {
win.y
} else {
- gap + bw + bar_height + offset * idx_cascade
+ gap_left + fbw + bar_height + gap_top + cascade_offset * idx_cascade
};
idx_cascade += 1;
(fx, fy, fw, fh)
@@ -317,6 +349,13 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
// Propose dimensions via river_window_v1
if let Some(wp) = state.get_window_proxy(tr.wid) {
wp.river_window.propose_dimensions(tr.w, tr.h);
+ // Tell the client to use server-side decoration.
+ // Per the River protocol, use_csd is the default when neither
+ // use_csd nor use_ssd is called. Calling use_ssd here ensures
+ // windows don't draw their own CSD titlebars/borders.
+ // use_ssd has no effect if the client only supports CSD
+ // (decoration_hint == only_supports_csd).
+ wp.river_window.use_ssd();
}
// Update internal state
diff --git a/start-river.sh b/start-river.sh
index 4edb36f..f21b21f 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -1,19 +1,37 @@
#!/bin/bash
-# Launch river with clearwm-rs on this TTY
+# Launch river with clearwm on this TTY
# Usage: Switch to a free TTY, log in, and run this script
+LOGGING=false
+for arg in "$@"; do
+ case "$arg" in
+ --logging) LOGGING=true ;;
+ esac
+done
+
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'
+if [ "$LOGGING" = true ]; then
+ cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
+#!/bin/sh
+exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm.log
+LAUNCH_EOF
+else
+ cat > /tmp/clearwm-rs-launch.sh << 'LAUNCH_EOF'
#!/bin/sh
-exec /home/lsgalante/.local/bin/clearwm 2>/tmp/clearwm-test.log
+exec /home/lsgalante/.local/bin/clearwm
LAUNCH_EOF
+fi
chmod +x /tmp/clearwm-rs-launch.sh
-echo "Starting river with clearwm-rs..."
-echo "Log will be at /tmp/river-clearwm.log"
+echo "Starting river with clearwm..."
+if [ "$LOGGING" = true ]; then
+ echo "Logs: /tmp/river-clearwm.log + /tmp/clearwm.log"
+else
+ echo "Logging disabled. Use --logging to enable."
+fi
exec river -c /tmp/clearwm-rs-launch.sh 2>/tmp/river-clearwm.log
diff --git a/test_proto b/test_proto
new file mode 100755
index 0000000..c7eed87
Binary files /dev/null and b/test_proto differ