Repository: AMNatty/wleave Branch: development Commit: 19de1116be22 Files: 31 Total size: 99.2 KB Directory structure: gitextract_wwkaal9a/ ├── .editorconfig ├── .gitignore ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── completions/ │ ├── _wleave │ ├── wleave.bash │ └── wleave.fish ├── completions_gen/ │ ├── .gitignore │ ├── Cargo.toml │ └── src/ │ └── main.rs ├── icons/ │ └── icon-attribution.md ├── layout.json ├── man/ │ ├── wleave.1.scd │ ├── wleave.5.scd │ └── wleave.json.5.scd ├── sh.natty.Wleave.desktop ├── src/ │ ├── app/ │ │ ├── mod.rs │ │ └── window.rs │ ├── button.rs │ ├── cli_opt.rs │ ├── config.rs │ ├── error.rs │ ├── exec.rs │ ├── layout.rs │ ├── lib.rs │ ├── main.rs │ ├── paintable.rs │ └── units.rs └── style.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true max_line_length = 120 tab_width = 4 [*.rs] max_line_length = 120 [*.json] indent_size = 4 [*.scd] indent_style = tab ================================================ FILE: .gitignore ================================================ /target /.idea /.vscode /layout.dev ================================================ FILE: Cargo.toml ================================================ [package] name = "wleave" version = "0.7.2" edition = "2024" license = "MIT" description = "A Wayland layer-shell logout prompt written in GTK" repository = "https://github.com/AMNatty/wleave" [workspace] members = [".", "completions_gen"] [dependencies] clap = { version = "4.1", features = ["derive"] } thiserror = "2" miette = { version = "7", features = ["derive", "fancy"] } cfg-if = "1" convert_case = "0.10" dirs = "6.0" cairo-rs = "0.21" glib = "0.21" glib-macros = "0.21" libadwaita = { version = "0.8", features = ["v1_7"] } librsvg = "2.61" gdk4 = { version = "0.10", features = ["v4_18"] } gtk4 = { version = "0.10", features = ["gnome_45"] } gtk4-layer-shell = "0.7" which = "8" cssparser = "0.36" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = { version = "0.1" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Natty 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. ================================================ FILE: Makefile ================================================ ./target/release/wleave: $(wildcard src/**.rs) cargo build --frozen --release --all-features .PHONY: wleave wleave: ./target/release/wleave .PHONY: completions completions: wleave mkdir -p completions OUT_DIR=completions cargo run --package wleave_completions --bin wleave_completions .PHONY: all all: wleave .PHONY: clean clean: rm -rf ./target ./completions_generated ================================================ FILE: README.md ================================================ # wleave ![AUR version](https://img.shields.io/aur/version/wleave-git) ![GitHub](https://img.shields.io/github/license/AMNatty/wleave) A Wayland layer-shell logout prompt! Originally a fork of [wlogout](https://github.com/ArtsyMacaw/wlogout), `wleave` is now natively GTK4 & Libadwaita and has a bunch of added quality-of-life features. ![The default Wleave menu look](/example.png) ## Installation ### Arch Linux **wleave** can be installed from the **AUR**: ```shell paru -S wleave-git ``` ### Building from sources Dependencies: - gtk4-layer-shell - gtk4 - librsvg (for SVG images) - libadwaita - a stable version of the Rust toolchain You can run the application using `cargo run --release` or GNU make: ```shell make ./target/release/wleave ``` ## Usage The command line options are backwards-compatible with **wlogout**. See `--help` for a list of options. ### Help, how do I close the menu? The `` key closes the menu, an option to change this may be added eventually. ## Configuration **wleave** is backwards-compatible with **wlogout** configuration files. Since **version 0.6.0**, *full JSON configuration* can be used in place of the `wlogout`-based configuration. The default configuration file can be copied from `/etc/wleave/layout.json`. The new configuration system is more flexible as it removes the need for extra command-line arguments. From `man 5 wleave.json`, the allowed top-level options are: * `"buttons"` **(array)** - a list of buttons * `"css"` **(string)** - Specify a custom CSS file instead of the default one * `"service": false` **(boolean)** Run the application as a service, with all instances of wleave opening this one. Allows faster startup at the cost of running in the background * `"button-layout": "grid"` Specify the way buttons should be laid out. See [dynamic layouts](#dynamic-layouts-supsince-070sup) for more details. * `"buttons-per-row": "3"` **(string)** Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows) * `"column-spacing": "8px"` **(number / "#px" / "#%")** Set space between buttons columns * `"row-spacing": "8px"` **(number / "#px" / "#%")** Set space between buttons rows * `"margin": "20%"` **(number / "#px" / "#%")** Set margin on all sides * `"margin-left"` **(number / "#px" / "#%")** Set margin for left of buttons. Falls back to the value set by *margin* * `"margin-right"` **(number / "#px" / "#%")** Set margin for right of buttons. Falls back to the value set by *margin* * `"margin-top"` **(number / "#px" / "#%")** Set margin for top of buttons. Falls back to the value set by *margin* * `"margin-bottom"` **(number / "#px" / "#%")** Set margin for bottom of buttons. Falls back to the value set by *margin* * `"button-aspect-ratio"` **(string or number)** Set the aspect ratio of the buttons, either as a float (as a number or string) or a ratio (e.g. "5/4"). If unspecified, the buttons fill all available space between the margins. * `"close-on-lost-focus": false` **(boolean)** Closes the menu if focus is lost * `"show-keybinds": false`: **(boolean)** Show the associated key binds for each button * `"protocol": "layer-shell"` (**"layer-shell"**/**"xdg"**/**"none"**) Backend to use for full-screening the menu * `"no-version-info": false` **(boolean)** Hides the version label. * `"delay-command-ms": 100` **(number)** The number of milliseconds to wait after an action before the associated command is executed The command-line option counterparts of these options take precedence over the configuration file. *Example configuration* with one button that executes `swaylock` on click: ```json { "margin": 200, "buttons-per-row": "1/1", "delay-command-ms": 100, "close-on-lost-focus": true, "show-keybinds": true, "buttons": [ { "label": "lock", "action": "swaylock", "text": "Lock", "keybind": "l", "icon": "/usr/share/wleave/icons/lock.svg" } ] } ``` Layout files may also be read from *stdin* with `--layout -`. For example, with `jq`, buttons can be picked out: ```shell $ jq '.buttons[] |= select([.label] | inside(["lock", "logout"]))' layout.json | wleave --layout - ``` ### Running as a service since 0.7.0 Add the flag `--service` to run the application as a service, waiting until it is activated by another call to `wleave`. This allows faster startup at the cost of running in the background. For example, to run `wleave` in the background with the [Niri](https://github.com/YaLTeR/niri) compositor, spawn the application on startup: ```kdl spawn-at-startup "wleave" "--service" ``` Currently, the configuration is determined by the background instance, and a restart is necessary to apply configuration changes. ### Dynamic layouts since 0.7.0 Choose one of the available layouts, using the `--button-layout` option. #### Grid Name: `"grid"`
Since: 0.7 The default mode, with buttons being simply laid out in a grid. ### Conditional actions since 0.7.0 The action field can be an object with exactly one of `shell` or `executable` properties set. The `shell` action executes the given command in the system shell, while the `executable` action executes the specified binary, assuming it is in `$PATH`. `executable` actions get filtered if the specified executable does not exist, allowing multiple possible options. Any action that is **a plain string is interpreted as a shell command** with no conditions set. Any extra properties in the `action` objects are interpreted as filters. Currently, only environment variable filtering is implemented, where the values of fields prefixed with `$` are matched as conditions for the given action. **The action field can also be an array where the first matching command is picked.** For example, in the following example, the `loginctl lock-session` command is executed on GNOME, while other desktop environments will try `gtklock` and then `swaylock`. ```json { "action": [ { "$DESKTOP_SESSION": "gnome", "shell": "loginctl lock-session" }, { "executable": "gtklock" }, "swaylock" ] } ``` ## Styling By default, `wleave` follows `libadwaita` colors and uses CSS variables. This allows following the system light/dark theme preference from GNOME settings. In other desktop environments, this may be changed with `gsettings set org.gnome.desktop.interface color-scheme "'prefer-dark'"` or `gsettings set org.gnome.desktop.interface color-scheme "'prefer-light'"` correspondingly. The stylesheet in `/etc/wleave/style.css` is fully customizable and can be edited. ### Colorized icons SVG icons are dynamically recolored if possible. (since 0.6.2) Each button has an identifier set in the layout file, which allows custom-styling each button one-by-one. Icon colors may be changed by modifying the CSS variable `--view-fg-color`, or by setting a custom `color` property entirely. ### Example recipe Example stylesheet that makes the selected icon colored with the `libadwaita` accent color: ```css window { background-color: rgba(12, 12, 12, 0.8); } button { color: var(--view-fg-color); background-color: var(--view-bg-color); border: none; padding: 10px; } button label.action-name { font-size: 24px; } button label.keybind { font-size: 20px; font-family: monospace; } button:hover label.keybind, button:focus label.keybind { opacity: 1; } button:hover, button:focus { color: var(--accent-color); background-color: var(--window-bg-color); } button:active { color: var(--accent-fg-color); background-color: var(--accent-bg-color); } ``` ## Keybinds reference See for a list of valid keybinds. ## Enhancements - A desktop file since 0.7 - Conditionally pick different actions on different desktop environments since 0.7 - SVG icons can be colorized via CSS `color` since 0.6 - Libadwaita accent colors since 0.6 - Automatic light theme by default since 0.6 - Natively GTK4 since 0.5 - New pretty icons by [@earth-walker](https://github.com/earth-walker) - Autoclose when window focus is lost (the `-f/--close-on-lost-focus` flag) - Mnemonic labels (the `-k/--show-keybinds` flag) - Pretty gaps by default - Less error-prone - Keybinds accept modifier keys and Unicode characters - Easier to extend ================================================ FILE: completions/_wleave ================================================ #compdef wleave autoload -U is-at-least _wleave() { typeset -A opt_args typeset -a _arguments_options local ret=1 if is-at-least 5.2; then _arguments_options=(-s -S -C) else _arguments_options=(-s -C) fi local context curcontext="$curcontext" state line _arguments "${_arguments_options[@]}" : \ '-s+[Run the application in service mode - it will stay in the background until triggered]' \ '--service=[Run the application in service mode - it will stay in the background until triggered]' \ '-l+[Specify a layout file, specifying - will read the layout config from stdin]:LAYOUT:_files' \ '--layout=[Specify a layout file, specifying - will read the layout config from stdin]:LAYOUT:_files' \ '--button-layout=[Specify the way the buttons should be laid out in the available space]:BUTTON_LAYOUT:(grid)' \ '-C+[Specify a custom CSS file]:CSS:_files' \ '--css=[Specify a custom CSS file]:CSS:_files' \ '-b+[Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows)]:BUTTONS_PER_ROW:_default' \ '--buttons-per-row=[Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows)]:BUTTONS_PER_ROW:_default' \ '-c+[Set space between buttons columns]:COLUMN_SPACING:_default' \ '--column-spacing=[Set space between buttons columns]:COLUMN_SPACING:_default' \ '-r+[Set space between buttons rows]:ROW_SPACING:_default' \ '--row-spacing=[Set space between buttons rows]:ROW_SPACING:_default' \ '-m+[Set the margin around buttons]:MARGIN:_default' \ '--margin=[Set the margin around buttons]:MARGIN:_default' \ '-L+[Set margin for the left of buttons]:MARGIN_LEFT:_default' \ '--margin-left=[Set margin for the left of buttons]:MARGIN_LEFT:_default' \ '-R+[Set margin for the right of buttons]:MARGIN_RIGHT:_default' \ '--margin-right=[Set margin for the right of buttons]:MARGIN_RIGHT:_default' \ '-T+[Set margin for the top of buttons]:MARGIN_TOP:_default' \ '--margin-top=[Set margin for the top of buttons]:MARGIN_TOP:_default' \ '-B+[Set the margin for the bottom of buttons]:MARGIN_BOTTOM:_default' \ '--margin-bottom=[Set the margin for the bottom of buttons]:MARGIN_BOTTOM:_default' \ '-A+[Set the aspect ratio of the buttons]:BUTTON_ASPECT_RATIO:_default' \ '--button-aspect-ratio=[Set the aspect ratio of the buttons]:BUTTON_ASPECT_RATIO:_default' \ '-d+[The delay (in milliseconds) between the window closing and executing the selected option]:DELAY_COMMAND_MS:_default' \ '--delay-command-ms=[The delay (in milliseconds) between the window closing and executing the selected option]:DELAY_COMMAND_MS:_default' \ '-f+[Close the menu on lost focus]' \ '--close-on-lost-focus=[Close the menu on lost focus]' \ '-k+[Show the associated key binds]' \ '--show-keybinds=[Show the associated key binds]' \ '-p+[Use layer-shell or xdg protocol]:PROTOCOL:(layer-shell none xdg)' \ '--protocol=[Use layer-shell or xdg protocol]:PROTOCOL:(layer-shell none xdg)' \ '-x+[Hide version information]' \ '--no-version-info=[Hide version information]' \ '-v[]' \ '--version[]' \ '-h[Print help]' \ '--help[Print help]' \ && ret=0 } (( $+functions[_wleave_commands] )) || _wleave_commands() { local commands; commands=() _describe -t commands 'wleave commands' commands "$@" } if [ "$funcstack[1]" = "_wleave" ]; then _wleave "$@" else compdef _wleave wleave fi ================================================ FILE: completions/wleave.bash ================================================ _wleave() { local i cur prev opts cmd COMPREPLY=() if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then cur="$2" else cur="${COMP_WORDS[COMP_CWORD]}" fi prev="$3" cmd="" opts="" for i in "${COMP_WORDS[@]:0:COMP_CWORD}" do case "${cmd},${i}" in ",$1") cmd="wleave" ;; *) ;; esac done case "${cmd}" in wleave) opts="-v -s -l -C -b -c -r -m -L -R -T -B -A -d -f -k -p -x -h --version --service --layout --button-layout --css --buttons-per-row --column-spacing --row-spacing --margin --margin-left --margin-right --margin-top --margin-bottom --button-aspect-ratio --delay-command-ms --close-on-lost-focus --show-keybinds --protocol --no-version-info --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in --service) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; -s) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; --layout) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -l) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --button-layout) COMPREPLY=($(compgen -W "grid" -- "${cur}")) return 0 ;; --css) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -C) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --buttons-per-row) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -b) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --column-spacing) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -c) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --row-spacing) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -r) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --margin) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -m) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --margin-left) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -L) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --margin-right) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -R) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --margin-top) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -T) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --margin-bottom) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -B) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --button-aspect-ratio) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -A) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --delay-command-ms) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; -d) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; --close-on-lost-focus) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; -f) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; --show-keybinds) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; -k) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; --protocol) COMPREPLY=($(compgen -W "layer-shell none xdg" -- "${cur}")) return 0 ;; -p) COMPREPLY=($(compgen -W "layer-shell none xdg" -- "${cur}")) return 0 ;; --no-version-info) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; -x) COMPREPLY=($(compgen -W "true false" -- "${cur}")) return 0 ;; *) COMPREPLY=() ;; esac COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; esac } if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then complete -F _wleave -o nosort -o bashdefault -o default wleave else complete -F _wleave -o bashdefault -o default wleave fi ================================================ FILE: completions/wleave.fish ================================================ complete -c wleave -s s -l service -d 'Run the application in service mode - it will stay in the background until triggered' -r -f -a "true\t'' false\t''" complete -c wleave -s l -l layout -d 'Specify a layout file, specifying - will read the layout config from stdin' -r -F complete -c wleave -l button-layout -d 'Specify the way the buttons should be laid out in the available space' -r -f -a "grid\t''" complete -c wleave -s C -l css -d 'Specify a custom CSS file' -r -F complete -c wleave -s b -l buttons-per-row -d 'Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows)' -r complete -c wleave -s c -l column-spacing -d 'Set space between buttons columns' -r complete -c wleave -s r -l row-spacing -d 'Set space between buttons rows' -r complete -c wleave -s m -l margin -d 'Set the margin around buttons' -r complete -c wleave -s L -l margin-left -d 'Set margin for the left of buttons' -r complete -c wleave -s R -l margin-right -d 'Set margin for the right of buttons' -r complete -c wleave -s T -l margin-top -d 'Set margin for the top of buttons' -r complete -c wleave -s B -l margin-bottom -d 'Set the margin for the bottom of buttons' -r complete -c wleave -s A -l button-aspect-ratio -d 'Set the aspect ratio of the buttons' -r complete -c wleave -s d -l delay-command-ms -d 'The delay (in milliseconds) between the window closing and executing the selected option' -r complete -c wleave -s f -l close-on-lost-focus -d 'Close the menu on lost focus' -r -f -a "true\t'' false\t''" complete -c wleave -s k -l show-keybinds -d 'Show the associated key binds' -r -f -a "true\t'' false\t''" complete -c wleave -s p -l protocol -d 'Use layer-shell or xdg protocol' -r -f -a "layer-shell\t'' none\t'' xdg\t''" complete -c wleave -s x -l no-version-info -d 'Hide version information' -r -f -a "true\t'' false\t''" complete -c wleave -s v -l version complete -c wleave -s h -l help -d 'Print help' ================================================ FILE: completions_gen/.gitignore ================================================ /target /.idea /.vscode ================================================ FILE: completions_gen/Cargo.toml ================================================ [package] name = "wleave_completions" version = "0.1.0" edition = "2021" [dependencies] clap_complete = "4.1" clap = "4.1" wleave = { path = ".." } ================================================ FILE: completions_gen/src/main.rs ================================================ use clap::CommandFactory; use clap_complete::{generate_to, shells}; use std::env; use std::io::Error; use wleave::cli_opt::Args; fn main() -> Result<(), Error> { let outdir = match env::var_os("OUT_DIR") { None => return Ok(()), Some(outdir) => outdir, }; let mut cmd = Args::command(); println!( "Bash completions generated: {:?}", generate_to(shells::Bash, &mut cmd, "wleave", &outdir)? ); println!( "Zsh completions generated: {:?}", generate_to(shells::Zsh, &mut cmd, "wleave", &outdir)? ); println!( "Fish completions generated: {:?}", generate_to(shells::Fish, &mut cmd, "wleave", &outdir)? ); Ok(()) } ================================================ FILE: icons/icon-attribution.md ================================================ # Icon Attribution All icons are licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) by Earth Walker 2023 Modified in version 0.6.0 by Natty to support colorization. ================================================ FILE: layout.json ================================================ { "buttons": [ { "label": "lock", "action": [ { "$DESKTOP_SESSION": "gnome", "shell": "loginctl lock-session" }, { "executable": "gtklock" }, { "executable": "swaylock" } ], "text": "Lock", "keybind": "l", "icon": "/usr/share/wleave/icons/lock.svg" }, { "label": "hibernate", "action": "systemctl hibernate", "text": "Hibernate", "keybind": "h", "icon": "/usr/share/wleave/icons/hibernate.svg" }, { "label": "logout", "action": [ { "$DESKTOP_SESSION": "niri", "shell": "niri msg action quit --skip-confirmation" }, { "$DESKTOP_SESSION": "sway", "shell": "swaymsg exit" }, { "$DESKTOP_SESSION": "plasma", "shell": "qdbus org.kde.KWin /Session org.kde.KWin.Session.quit" }, { "$DESKTOP_SESSION": "gnome", "shell": "gnome-session-quit --no-prompt" }, "loginctl terminate-user $USER" ], "text": "Logout", "keybind": "e", "icon": "/usr/share/wleave/icons/logout.svg" }, { "label": "shutdown", "action": "systemctl poweroff", "text": "Shutdown", "keybind": "s", "icon": "/usr/share/wleave/icons/shutdown.svg" }, { "label": "suspend", "action": "systemctl suspend", "text": "Suspend", "keybind": "u", "icon": "/usr/share/wleave/icons/suspend.svg" }, { "label": "reboot", "action": "systemctl reboot", "text": "Reboot", "keybind": "r", "icon": "/usr/share/wleave/icons/reboot.svg" } ] } ================================================ FILE: man/wleave.1.scd ================================================ wleave(1) # NAME wleave - A Wayland logout menu # SYNOPSIS *wleave* [options] [command] # OPTIONS *-h, --help* Show help message and stop *-l, --layout* Specify a custom layout file, specify - to read the config from stdin *--button-layout* "grid" Specify the way buttons should be laid out, "grid" being the default. Currently, only "grid" is supported. *-S, --service* Run the application as a service, with all instances of wleave opening this one. Allows faster startup at the cost of running in the background. *-v, --version* Show version number and stop *-C, -css* Specify a custom css file *-b, --buttons-per-row* Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows) *-c, --column-spacing* Set space between buttons columns *-r, --row-spacing* Set space between buttons rows *-m, --margin* Set margin on all sides *-L, --margin-left* Set margin for left of buttons *-R, --margin-right* Set margin for right of buttons *-T, --margin-top* Set margin for top of buttons *-B, --margin-bottom* Set margin for bottom of buttons *-A, --button-aspect-ratio* Set the aspect ratio of the buttons, either as a float (as a number or string) or a ratio (e.g. "5/4"). If unspecified, the buttons fill all available space between the margins. *-f, --close-on-lost-focus* Closes the menu if focus is lost *-k, --show-keybinds* Show the associated key binds for each button *-p, --protocol* Takes layer-shell, xdg, or none. The "layer-shell" allows transparency effects; however, only a few compositors correctly support it. The "xdg" protocol will work on almost all compositors, but does not allow for transparency. The "none" option shows the window as is, without full-screening it *-d, --delay-command-ms* The number of milliseconds to wait after an action before the associated command is executed *-x, --no-version-info* Hide the version label # DESCRIPTION wleave is a Wayland-native logout script. It is a modern rewrite of Wlogout and a drop-in replacement. # CONFIGURATION wleave searches for a layout and style.css file in the following locations, in this order: . $XDG_CONFIG_HOME/wleave/ . $XDG_CONFIG_HOME/wlogout/ . /etc/wleave/ . /etc/wlogout/ . /usr/local/etc/wleave . /usr/local/etc/wlogout If unset, $XDG_CONFIG_HOME defaults to *~/.config/*. An error is raised when no layout file is found; However, the style.css file is optional. If you would like to customise either it is recommended that you copy the defaults from */etc/wleave/* into *~/.config* and make any changes there. # AUTHORS Based on Wlogout by Haden Collins . For more information about wlogout, see . Rewrite by Natty , see . # SEE ALSO *wleave*(5) ================================================ FILE: man/wleave.5.scd ================================================ wleave(5) # NAME wleave - layout file and options # LAYOUT wleave buttons can have the following properties - label - action - text - keybind - icon - height \* - width \* - circular \* \* Optional values Label is the css selector by which the buttons may be referred to in a *style.css* file, action is the shell command to be executed when the button is clicked, text is the description displayed on the button, keybind is the key mapped to the button (note escape is reserved for exiting the application), height and width are values between 0.0 and 1.0 that control the location of where *text* is displayed the default width 0.5, height 0.9, and circular is a boolean value that makes a button round. # FILE The buttons values are specified in a JSON-like formatted file, wherein the values are used as keys and one button corresponds to one JSON object for example: ``` { "label" : "foo", "action" : "echo 'hello world'", "text" : "bar", "keybind" : "f", "height" : 1, "width" : 1, "circular" : true } ``` Would create a round button that has a css label of *foo*, prints "hello world" upon being clicked, displays "bar" on the button, be bound to the key 'f', and "bar" would be shown at the bottom right corner. To create multiple buttons simply create another JSON object. # AUTHORS Based on Wlogout by Haden Collins . For more information about wlogout, see . Rewrite by Natty , see . # SEE ALSO *wleave.json*(5) *wleave*(1) ================================================ FILE: man/wleave.json.5.scd ================================================ wleave.json(5) # NAME wleave.json - layout JSON file and configuration options # TOP-LEVEL-OPTIONS The top-level options directly correspond to their long-version command-line option counter parts. Button layout is specified in the extra *"button"* field. *"buttons"*: See section *LAYOUT* for structure *"button-layout"*: <*"grid"*> Specify the way buttons should be laid out, "grid" being the default. Currently, only "grid" is supported. *"css"*: Specify a custom CSS file instead of the default one *"service"*: Run the application as a service, with all instances of wleave opening this one. Allows faster startup at the cost of running in the background. ++ *Default*: false *"buttons-per-row"*: Set the number of buttons per row, or use a fraction to specify the number of rows to be used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows) ++ *Default*: "3" *"column-spacing"*: px" / "%"> Set space between button columns ++ *Default*: "8px" *"row-spacing"*: px" / "%"> Set space between button rows ++ *Default*: "8px" *"margin"*: px" / "%"> Set margin on all sides ++ *Default*: "20%" (of window size) *"margin-left"* px" / "%"> Set margin for left of buttons ++ Falls back to the value set by *margin* *"margin-right"* px" / "%"> Set margin for right of buttons ++ Falls back to the value set by *margin* *"margin-top"* px" / "%"> Set margin for top of buttons ++ Falls back to the value set by *margin* *"margin-bottom"* px" / "%"> Set margin for bottom of buttons ++ Falls back to the value set by *margin* *"button-aspect-ratio"* Set the aspect ratio of the buttons, either as a float (as a number or string) or a ratio (e.g. "5/4"). ++ If unspecified, the buttons fill all available space between the margins. *"close-on-lost-focus"*: <*true*/*false*> Closes the menu if focus is lost ++ *Default*: false *"show-keybinds"*: <*true*/*false*> Show the associated key binds for each button ++ *Default*: false *"protocol"*: <*"layer-shell"*/*"xdg"*/*"none"*> The layer-shell protocol is the intended protocol to be used with wleave, however it requires compositor support ++ *Default*: "layer-shell" *"no-version-info"*: <*true*/*false*> Hides the version label ++ *Default*: false *"delay-command-ms"*: The number of milliseconds to wait after an action before the associated command is executed ++ *Default*: 100 # LAYOUT wleave buttons may have the following properties: - *"label"* (string) - internal name of the button that can be used in CSS as an identifier - *"action"* (command string or object or list thereof) - the action that will be executed via shell upon pressing the button - *"text"* (string) - the visible label text of the button - *"keybind"* (string) - a GTK key name associated with the button - *"icon"* (path string) - filename to load an icon image from - *"height"* (*optional*, number) - value in the range 0.0 to 1.0 to override the placement of the label in the button - *"width"* (*optional*, number) - value in the range 0.0 to 1.0 to override the placement of the label in the button - *"circular"* (*optional*, boolean, default false) The action field can be an object with exactly one of `shell` or `executable` properties set. The `shell` action executes the given command in the system shell, while the `executable` action executes the specified binary, assuming it is in `$PATH`. `executable` actions get filtered if the specified executable does not exist, allowing multiple possible options. Any extra properties in the `action` objects are interpreted as filters. Currently, only environment variable filtering is implemented, where the values fields prefixed with `$` are matched as conditions for the given action. The action field can also be an array where the first matching command is picked. For example, in the following example, the `loginctl lock-session` command is executed on GNOME, while other desktop environments will try either `gtklock` or `swaylock` in that order. ``` "action": [ { "$DESKTOP_SESSION": "gnome", "shell": "loginctl lock-session" }, { "executable": "gtklock" }, { "executable": "swaylock" } ] ``` # FILE Example file: ``` { "close-on-lost-focus": true, "show-keybinds": true, "buttons-per-row": "1/1", "buttons": [ { "label": "lock", "action": "swaylock", "text": "Lock", "keybind": "l", "icon": "/usr/share/wleave/icons/lock.svg" } ] } ``` # AUTHORS Based on Wlogout by Haden Collins . For more information about wlogout, see . Rewrite by Natty , see . # SEE ALSO *wleave*(5) *wleave*(1) ================================================ FILE: sh.natty.Wleave.desktop ================================================ [Desktop Entry] # The version of the Desktop Entry spec, not of the application: Version=1.0 Type=Application Name=Wleave Comment=Open a system log-out menu Icon=wleave Exec=wleave Terminal=false StartupNotify=false Categories=System; ================================================ FILE: src/app/mod.rs ================================================ pub mod window; use crate::app::window::WleaveWindow; use crate::button::{WButton, WButtonActionList, WButtonJustify}; use crate::config::AppConfig; use crate::exec::run_command; use crate::layout::MenuLayout; use crate::paintable::svg_picture_colorized; use glib::object::Cast; use glib::timeout_add_local_once; use glib_macros::{clone, closure}; use gtk4::prelude::{BoxExt, ButtonExt, GObjectPropertyExpressionExt, GtkWindowExt, WidgetExt}; use gtk4::{EventControllerKey, GestureClick, PropagationPhase}; use gtk4_layer_shell::{KeyboardMode, LayerShell}; use libadwaita::prelude::AdwApplicationWindowExt; use std::sync::Arc; use std::time::Duration; use wleave::cli_opt::{ButtonLayout, Protocol}; use wleave::units::{AspectRatio, LengthArgs, LengthDimension}; fn do_exit(window: &WleaveWindow, _service_mode: bool) { window.close(); } fn on_option( command_list: &WButtonActionList, delay_ms: u32, service_mode: bool, window: WleaveWindow, ) { let Some(command) = command_list.enumerate().find(|w| w.is_applicable()) else { return; }; let command = command.clone(); window.connect_hide(clone!( #[strong] command, move |window| { timeout_add_local_once( Duration::from_millis(delay_ms.into()), clone!( #[strong] command, #[weak_allow_none] window, move || { run_command(command); window.inspect(move |w| do_exit(w, service_mode)); } ), ); } )); window.set_visible(false); } fn handle_key( config: &Arc, window: &WleaveWindow, key: >k4::gdk::Key, ) -> glib::Propagation { if let >k4::gdk::Key::Escape = key { do_exit(window, config.service); return glib::Propagation::Proceed; } let key = key .to_unicode() .map(|c| c.to_string()) .or_else(|| key.name().map(|s| s.to_string())); if let Some(ref key_name) = key { let button = config.buttons.iter().find(|b| b.keybind == *key_name); if let Some(WButton { action, .. }) = button { on_option( action, config.delay_command_ms, config.service, window.clone(), ); } } glib::Propagation::Proceed } pub fn create_app(config: &Arc, app: &libadwaita::Application) -> WleaveWindow { let service_mode = config.service; let container_box = gtk4::CenterBox::builder() .valign(gtk4::Align::Fill) .halign(gtk4::Align::Fill) .orientation(gtk4::Orientation::Vertical) .build(); let window = WleaveWindow::new(app); window.set_content(Some(&container_box)); let margin_left = config .margin_left .clone() .unwrap_or_else(|| config.margin.clone()); let margin_top = config .margin_top .clone() .unwrap_or_else(|| config.margin.clone()); let margin_right = config .margin_right .clone() .unwrap_or_else(|| config.margin.clone()); let margin_bottom = config .margin_top .clone() .unwrap_or_else(|| config.margin.clone()); window.connect_window_width_notify(clone!( #[weak_allow_none] container_box, move |w| { let Some(container_box) = container_box else { return; }; let arg = LengthArgs { viewport: (w.width() as f32, w.height() as f32), dimension: LengthDimension::Horizontal, }; container_box.set_margin_start(margin_left.0.for_args(&arg) as i32); container_box.set_margin_end(margin_right.0.for_args(&arg) as i32); } )); window.connect_window_height_notify(clone!( #[weak_allow_none] container_box, move |w| { let Some(container_box) = container_box else { return; }; let arg = LengthArgs { viewport: (w.width() as f32, w.height() as f32), dimension: LengthDimension::Vertical, }; container_box.set_margin_top(margin_top.0.for_args(&arg) as i32); container_box.set_margin_bottom(margin_bottom.0.for_args(&arg) as i32); } )); match config.protocol { Protocol::LayerShell => { window.init_layer_shell(); window.set_layer(gtk4_layer_shell::Layer::Overlay); window.set_namespace(Some("wleave")); window.set_exclusive_zone(-1); window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_anchor(gtk4_layer_shell::Edge::Left, true); window.set_anchor(gtk4_layer_shell::Edge::Right, true); window.set_anchor(gtk4_layer_shell::Edge::Top, true); window.set_anchor(gtk4_layer_shell::Edge::Bottom, true); } Protocol::Xdg => { window.fullscreen(); } Protocol::None => {} } if config.close_on_lost_focus { window.connect_is_active_notify(move |window| { if window.is_visible() && !window.is_active() && !service_mode { do_exit(window, service_mode); } }); } let click_away_controller = GestureClick::builder() .propagation_phase(PropagationPhase::Bubble) .button(gtk4::gdk::BUTTON_PRIMARY) .n_points(1) .build(); click_away_controller.connect_released(clone!( #[weak] window, #[upgrade_or_panic] move |_, _, _, _| { do_exit(&window, service_mode); } )); window.add_controller(click_away_controller); let key_controller = EventControllerKey::new(); key_controller.connect_key_pressed(clone!( #[strong] config, #[weak] window, #[upgrade_or_panic] move |_, key, _, _| handle_key(&config, &window, &key) )); window.add_controller(key_controller); let btn_count = config.buttons.len() as u32; let buttons_per_row = match config.buttons_per_row { ButtonLayout::Auto => None, ButtonLayout::PerRow(n) => Some(n), ButtonLayout::RowRatio(n, d) => Some(btn_count * n / d.min(btn_count * n)), }; let conf_column_spacing = config.column_spacing.clone(); let column_spacing = window .property_expression_weak("window-width") .chain_closure::(closure!(move |w: Option, width: i32| { conf_column_spacing.for_args(&LengthArgs { viewport: ( width as f32, w.as_ref() .map(WleaveWindow::window_width) .unwrap_or_default() as f32, ), dimension: LengthDimension::Horizontal, }) })) .upcast(); let conf_row_spacing = config.column_spacing.clone(); let row_spacing = window .property_expression_weak("window-height") .chain_closure::(closure!(move |w: Option, height: i32| { conf_row_spacing.for_args(&LengthArgs { viewport: ( w.as_ref() .map(WleaveWindow::window_width) .unwrap_or_default() as f32, height as f32, ), dimension: LengthDimension::Horizontal, }) })) .upcast(); let buttons_container = gtk4::Box::builder() .valign(gtk4::Align::Fill) .halign(gtk4::Align::Fill) .layout_manager(&MenuLayout::new( config.button_layout, config.button_aspect_ratio.map(AspectRatio::as_float), column_spacing, row_spacing, buttons_per_row, )) .build(); for bttn in config.buttons.iter() { let justify = match bttn.justify { WButtonJustify::Center => gtk4::Justification::Center, WButtonJustify::Fill => gtk4::Justification::Fill, WButtonJustify::Left => gtk4::Justification::Left, WButtonJustify::Right => gtk4::Justification::Right, }; let button = gtk4::Button::builder() .name(&bttn.label) .hexpand(true) .vexpand(true) .cursor(&gdk4::Cursor::from_name("pointer", None).expect("pointer cursor not found")) .build(); let overlay = gtk4::Overlay::builder().vexpand(true).hexpand(true).build(); if config.show_keybinds { let key_label = gtk4::Label::builder() .label(format!("[{}]", bttn.keybind)) .halign(gtk4::Align::Start) .valign(gtk4::Align::Start) .css_classes(["dimmed", "keybind"]) .build(); overlay.add_overlay(&key_label); } let inner = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) .valign(gtk4::Align::Center) .build(); let picture = if let Some(icon) = &bttn.icon { let picture = if icon.ends_with(".svg") { svg_picture_colorized(icon).upcast() } else { gtk4::Picture::for_filename(icon) }; picture.set_content_fit(gtk4::ContentFit::ScaleDown); picture.add_css_class("icon-dropshadow"); inner.append(&picture); Some(picture) } else { None }; let label = gtk4::Label::builder() .label(&bttn.text) .css_classes(["action-name"]) .use_markup(true) .justify(justify) .build(); // Picture being none means the old system to configure buttons is used if bttn.width.is_some() || bttn.height.is_some() || picture.is_none() { label.set_xalign(bttn.width.unwrap_or(0.5)); label.set_yalign(bttn.height.unwrap_or(0.9)); overlay.add_overlay(&label); } else { inner.insert_child_after(&label, picture.as_ref()); } overlay.set_child(Some(&inner)); if bttn.circular { button.add_css_class("circular"); } button.connect_clicked(clone!( #[weak] window, #[to_owned(rename_to = action)] &bttn.action, #[to_owned(rename_to = delay_ms)] &config.delay_command_ms, #[upgrade_or_panic] move |_| on_option(&action, delay_ms, service_mode, window) )); button.set_child(Some(&overlay)); buttons_container.append(&button); } container_box.set_shrink_center_last(false); container_box.set_center_widget(Some(&buttons_container)); if !config.no_version_info { let version_info = gtk4::Label::builder() .label(format!( "Wleave {}. Missing or broken icons?", env!("CARGO_PKG_VERSION") )) .use_markup(true) .can_focus(false) .css_classes(["dimmed", "version-info"]) .margin_top(12) .build(); container_box.set_end_widget(Some(&version_info)); } window } ================================================ FILE: src/app/window.rs ================================================ mod window_impl { use glib::object::ObjectExt; use glib::subclass::object::DerivedObjectProperties; use glib::subclass::object::{ObjectImpl, ObjectImplExt}; use glib::subclass::types::{ObjectSubclass, ObjectSubclassExt}; use glib_macros::Properties; use gtk4::prelude::WidgetExt; use gtk4::subclass::application_window::ApplicationWindowImpl; use gtk4::subclass::widget::{WidgetImpl, WidgetImplExt}; use gtk4::subclass::window::WindowImpl; use libadwaita::subclass::application_window::AdwApplicationWindowImpl; use std::cell::Cell; #[derive(Properties, Default)] #[properties(wrapper_type = super::WleaveWindow)] pub struct WleaveWindowImpl { #[property(name = "window-width", get, set)] pub window_width: Cell, #[property(name = "window-height", get, set)] pub window_height: Cell, } #[glib::object_subclass] impl ObjectSubclass for WleaveWindowImpl { const NAME: &'static str = "WleaveWindow"; type Type = super::WleaveWindow; type ParentType = libadwaita::ApplicationWindow; type Interfaces = (); } #[glib::derived_properties] impl ObjectImpl for WleaveWindowImpl { fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); obj.set_window_width(obj.width()); obj.set_window_height(obj.height()); } } impl WidgetImpl for WleaveWindowImpl { fn size_allocate(&self, width: i32, height: i32, baseline: i32) { self.parent_size_allocate(width, height, baseline); let obj = self.obj(); obj.set_window_width(obj.width()); obj.set_window_height(obj.height()); } } impl WindowImpl for WleaveWindowImpl {} impl ApplicationWindowImpl for WleaveWindowImpl {} impl AdwApplicationWindowImpl for WleaveWindowImpl {} } glib::wrapper! { pub struct WleaveWindow(ObjectSubclass) @extends libadwaita::ApplicationWindow, gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget, @implements gtk4::gio::ActionGroup, gtk4::gio::ActionMap, gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager; } impl WleaveWindow { pub fn new(app: &libadwaita::Application) -> Self { glib::Object::builder() .property("application", app) .property("title", "wleave") .property("window-width", 400) .property("window-height", 400) .build() } } ================================================ FILE: src/button.rs ================================================ use serde::de::{IntoDeserializer, SeqAccess, Visitor}; use serde::{Deserialize, Deserializer}; use std::collections::BTreeMap; use std::convert::Infallible; use std::str::FromStr; use tracing::warn; #[derive(Debug, Clone, Deserialize)] pub enum WButtonActionHandler { #[serde(rename = "shell")] Shell(String), #[serde(rename = "executable")] Executable(String), } #[derive(Debug, Clone, Deserialize)] pub struct WButtonAction { #[serde(flatten)] pub handler: WButtonActionHandler, #[serde(flatten)] pub conditions: BTreeMap, } impl WButtonAction { pub fn is_applicable(&self) -> bool { if let WButtonActionHandler::Executable(exe) = &self.handler && let Err(e) = which::which(exe) { warn!("Executable {} not available, skipping: {}", exe, e); return false; } for (key, value) in self.conditions.iter() { if let Some(env_var) = key.strip_prefix("$") { let Ok(var) = std::env::var(env_var) else { return false; }; if var != *value { return false; } } } true } } impl FromStr for WButtonAction { type Err = Infallible; fn from_str(action: &str) -> Result { Ok(WButtonAction { handler: WButtonActionHandler::Shell(action.to_string()), conditions: Default::default(), }) } } struct WButtonActionVisitor; impl<'de> Visitor<'de> for WButtonActionVisitor { type Value = WButtonAction; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("string or map") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { Ok(FromStr::from_str(value).expect("shell script always valid")) } fn visit_string(self, val: String) -> Result where E: serde::de::Error, { self.visit_str(&val) } fn visit_map(self, map: M) -> Result where M: serde::de::MapAccess<'de>, { Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(map)) } } struct WButtonActionWrapper(WButtonAction); impl<'de> Deserialize<'de> for WButtonActionWrapper { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { Ok(WButtonActionWrapper( deserializer.deserialize_any(WButtonActionVisitor)?, )) } } #[derive(Debug, Clone)] pub enum WButtonActionList { Single(WButtonAction), Multiple(Vec), } struct WButtonActionListVisitor; impl<'de> Visitor<'de> for WButtonActionListVisitor { type Value = WButtonActionList; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("one or multiple actions") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { Ok(WButtonActionList::Single( WButtonActionWrapper::deserialize(value.into_deserializer())?.0, )) } fn visit_seq(self, mut seq: M) -> Result where M: SeqAccess<'de>, { let mut actions = Vec::new(); while let Some(WButtonActionWrapper(value)) = seq.next_element::()? { actions.push(value); } Ok(WButtonActionList::Multiple(actions)) } fn visit_map(self, map: M) -> Result where M: serde::de::MapAccess<'de>, { Ok(WButtonActionList::Single(WButtonAction::deserialize( serde::de::value::MapAccessDeserializer::new(map), )?)) } } impl<'de> Deserialize<'de> for WButtonActionList { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(WButtonActionListVisitor) } } impl WButtonActionList { pub fn enumerate(&self) -> impl Iterator { match self { WButtonActionList::Single(action) => std::slice::from_ref(action).iter(), WButtonActionList::Multiple(actions) => actions.iter(), } } } #[derive(Debug, Default)] pub enum WButtonJustify { #[default] Center, Fill, Left, Right, } impl<'de> Deserialize<'de> for WButtonJustify { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { Ok(FromStr::from_str(<&str>::deserialize(deserializer)?).expect("never fails")) } } impl FromStr for WButtonJustify { type Err = Infallible; fn from_str(val: &str) -> Result { Ok(match val { "center" => WButtonJustify::Center, "fill" => WButtonJustify::Fill, "left" => WButtonJustify::Left, "right" => WButtonJustify::Right, _ => WButtonJustify::Center, }) } } #[derive(Debug, Deserialize)] pub struct WButton { pub label: String, pub action: WButtonActionList, pub text: String, pub keybind: String, #[serde(default)] pub justify: WButtonJustify, pub width: Option, pub height: Option, #[serde(default = "default_circular")] pub circular: bool, #[serde(default)] pub icon: Option, } fn default_circular() -> bool { false } ================================================ FILE: src/cli_opt.rs ================================================ use crate::units::{AspectRatio, LengthValue, Margin}; use clap::{ArgAction, Parser, ValueEnum}; use serde::{Deserialize, Deserializer}; use std::{ error::Error, fmt::{Debug, Display}, num::NonZeroU32, path::PathBuf, str::FromStr, }; #[derive(Debug, Copy, Clone, Default, ValueEnum, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Protocol { #[default] LayerShell, None, Xdg, } #[derive(Debug, Copy, Clone, Default, ValueEnum, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum MenuLayoutStrategy { #[default] Grid, } #[derive(Parser, Debug)] #[command(author, version, disable_version_flag = true, about, long_about = None)] pub struct Args { #[arg(short = 'v', long, action = ArgAction::Version)] pub version: Option, /// Run the application in service mode - it will stay in the background until triggered #[arg(short = 's', long, num_args = 0..=1, require_equals = true, default_missing_value = "true" )] pub service: Option, /// Specify a layout file, specifying - will read the layout config from stdin #[arg(short = 'l', long)] pub layout: Option, /// Specify the way the buttons should be laid out in the available space #[arg(long)] pub button_layout: Option, /// Specify a custom CSS file #[arg(short = 'C', long)] pub css: Option, /// Set the number of buttons per row, or use a fraction to specify the number of rows to be /// used (e.g. "1/1" for all buttons in a single row, "1/5" to distribute the buttons over 5 rows) #[arg(short = 'b', long, value_parser = clap::value_parser!(ButtonLayout))] pub buttons_per_row: Option, /// Set space between buttons columns #[arg(short = 'c', long)] pub column_spacing: Option, /// Set space between buttons rows #[arg(short = 'r', long)] pub row_spacing: Option, /// Set the margin around buttons #[arg(short = 'm', long)] pub margin: Option, /// Set margin for the left of buttons #[arg(short = 'L', long)] pub margin_left: Option, /// Set margin for the right of buttons #[arg(short = 'R', long)] pub margin_right: Option, /// Set margin for the top of buttons #[arg(short = 'T', long)] pub margin_top: Option, /// Set the margin for the bottom of buttons #[arg(short = 'B', long)] pub margin_bottom: Option, /// Set the aspect ratio of the buttons. #[arg(short = 'A', long)] pub button_aspect_ratio: Option, /// The delay (in milliseconds) between the window closing and executing the selected option #[arg(short = 'd', long)] pub delay_command_ms: Option, /// Close the menu on lost focus #[arg(short = 'f', long, num_args = 0..=1, require_equals = true, default_missing_value = "true" )] pub close_on_lost_focus: Option, /// Show the associated key binds #[arg(short = 'k', long, num_args = 0..=1, require_equals = true, default_missing_value = "true" )] pub show_keybinds: Option, /// Use layer-shell or xdg protocol #[arg(short = 'p', long, value_enum)] pub protocol: Option, /// Hide version information #[arg(short = 'x', long, num_args = 0..=1, require_equals = true, default_missing_value = "true" )] pub no_version_info: Option, } #[derive(Clone, Copy, Debug, Default)] pub enum ButtonLayout { #[default] Auto, PerRow(u32), RowRatio(u32, u32), } impl<'de> Deserialize<'de> for ButtonLayout { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; FromStr::from_str(&s).map_err(serde::de::Error::custom) } } impl FromStr for ButtonLayout { type Err = Box; fn from_str(s: &str) -> Result { if s == "auto" { return Ok(ButtonLayout::Auto); } if let Ok(per_row) = s.parse::() { return Ok(ButtonLayout::PerRow(per_row.into())); } if let Some((n, d)) = s.split_once("/") && let (Ok(n), Ok(d)) = (n.parse::(), d.parse::()) { return Ok(ButtonLayout::RowRatio(n.into(), d.into())); } Err("Value neither a number (1, 2, 3) nor a ratio (1/1, 2/3, ...)".into()) } } impl Display for ButtonLayout { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Auto => f.write_str("auto"), Self::PerRow(r) => write!(f, "{r}"), Self::RowRatio(n, d) => write!(f, "{n}/{d}"), } } } ================================================ FILE: src/config.rs ================================================ use crate::button::WButton; use crate::error::WError; use convert_case::ccase; use gdk4::gio; use gtk4::CssProvider; use miette::{NamedSource, Report, SourceOffset}; use serde::Deserialize; use std::borrow::Cow; use std::io::Read; use std::path::{Path, PathBuf}; use tracing::{Level, debug, enabled, error, info, warn}; use wleave::cli_opt::{Args, ButtonLayout, MenuLayoutStrategy, Protocol}; use wleave::units::{AspectRatio, LengthValue, Margin}; #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct AppConfig { #[serde(default)] pub service: bool, #[serde(default)] pub button_layout: MenuLayoutStrategy, pub margin_left: Option, pub margin_right: Option, pub margin_top: Option, pub margin_bottom: Option, #[serde(default = "default_margin")] pub margin: Margin, #[serde(default = "default_spacing")] pub column_spacing: LengthValue, #[serde(default = "default_spacing")] pub row_spacing: LengthValue, pub button_aspect_ratio: Option, #[serde(default = "default_delay")] pub delay_command_ms: u32, #[serde(default)] pub protocol: Protocol, #[serde(default)] pub buttons_per_row: ButtonLayout, #[serde(default)] pub close_on_lost_focus: bool, pub buttons: Vec, #[serde(default)] pub show_keybinds: bool, #[serde(default)] pub no_version_info: bool, pub css: Option, } impl Default for AppConfig { fn default() -> Self { AppConfig { service: false, button_layout: MenuLayoutStrategy::Grid, margin_left: None, margin_right: None, margin_top: None, margin_bottom: None, margin: default_margin(), column_spacing: default_spacing(), row_spacing: default_spacing(), button_aspect_ratio: None, delay_command_ms: default_delay(), protocol: Default::default(), buttons_per_row: Default::default(), close_on_lost_focus: false, buttons: vec![], show_keybinds: false, no_version_info: false, css: None, } } } fn default_margin() -> Margin { Margin(LengthValue::Percentage(0.2)) } fn default_spacing() -> LengthValue { LengthValue::Px(8.0) } fn default_delay() -> u32 { 100 } fn file_search_given(given_file: impl AsRef) -> Result { let file = given_file.as_ref(); if !file.is_file() { return Err(WError::SpecifiedPathNotAFile(file.to_owned())); } Ok(file.to_owned()) } pub fn file_search_path(file_name: impl AsRef) -> Result { let file_name = file_name.as_ref(); let user_config_dir = dirs::config_dir() .or_else(|| dirs::home_dir().map(|p| p.join(".config"))) .unwrap_or_else(|| Path::new("~/.config").to_owned()); for path in &[ &user_config_dir.join("wleave"), &user_config_dir.join("wlogout"), Path::new("/etc/wleave"), Path::new("/etc/wlogout"), Path::new("/usr/local/etc/wleave"), Path::new("/usr/local/etc/wlogout"), ] { let full_path = path.join(file_name); if full_path.is_file() { debug!("File found in: {}", full_path.display()); return Ok(full_path); } else { debug!("No file found in: {}", full_path.display()); } } Err(WError::FileNotInSearchPath(file_name.to_owned())) } fn parse_config(input: impl Read, source_path: Cow) -> Result { let path = source_path.into_owned(); let path_name = path.display().to_string(); info!("Reading options from: {}", path_name); let config = std::io::read_to_string(input).map_err(|e| WError::IoError(path, e))?; let new = serde_json::de::from_str::(&config).map_err(|e| { WError::FileParseFailed( NamedSource::new(path_name.clone(), config.to_owned()), SourceOffset::from_location(&config, e.line(), e.column()), e, ) }); let legacy = serde_json::Deserializer::from_str(&config) .into_iter::() .collect::, _>>() .map_err(|e| { WError::FileParseFailed( NamedSource::new(path_name, config.to_owned()), SourceOffset::from_location(&config, e.line(), e.column()), e, ) }) .map(|buttons| AppConfig { buttons, ..Default::default() }); match (new, legacy) { (Ok(conf), _) => { info!("Using the JSON layout format."); Ok(conf) } (Err(e), Ok(legacy)) => { debug!("The JSON format could not be parsed: {:?}", Report::from(e)); info!("Using the backwards-compatible layout format."); if !enabled!(Level::DEBUG) { warn!( "If this is not intended, run the application with RUST_LOG=debug to show the JSON parse error." ); } Ok(legacy) } (Err(e), Err(_)) => { error!("{:?}", e); Err(e) } } } pub fn load_config(file: Option<&impl AsRef>) -> Result { if let Some("-") = file.map(AsRef::as_ref).and_then(Path::to_str) { return parse_config(std::io::stdin(), Path::new("").into()); } let file_path = file.map(file_search_given).unwrap_or_else(|| { file_search_path("layout.json").or_else(|_| file_search_path("layout")) })?; let input = std::fs::File::open(&file_path).map_err(|e| WError::IoError(file_path.clone(), e))?; parse_config(input, file_path.into()) } pub fn load_css(file: Option>) -> Result { let path = file .map(file_search_given) .unwrap_or_else(|| file_search_path("style.css"))?; let provider = CssProvider::new(); provider.connect_parsing_error(|_provider, _section, error| { warn!("CSS Parse error: {:?}", error); }); provider.load_from_file(&gio::File::for_path(&path)); Ok(provider) } macro_rules! merge_option { ($conf:ident, $args:ident, $name:ident => $val:expr) => { if let Some($name) = $args.$name { info!( "\"{}\" specified from args: {:?}", ccase!(snake -> kebab, stringify!($name)), $name ); $conf.$name = $val; } else { info!( "\"{}\" specified from config: {:?}", ccase!(snake -> kebab, stringify!($name)), $conf.$name ); } }; ($conf:ident, $args:ident, $name:ident) => { merge_option!($conf, $args, $name => $name) }; } pub fn merge_with_args(config: &mut AppConfig, args: Args) { merge_option!(config, args, service); merge_option!(config, args, button_layout); merge_option!(config, args, margin_top => Some(margin_top)); merge_option!(config, args, margin_bottom => Some(margin_bottom)); merge_option!(config, args, margin_left => Some(margin_left)); merge_option!(config, args, margin_right => Some(margin_right)); merge_option!(config, args, margin); merge_option!(config, args, protocol); merge_option!(config, args, column_spacing); merge_option!(config, args, row_spacing); merge_option!(config, args, button_aspect_ratio => Some(button_aspect_ratio)); merge_option!(config, args, show_keybinds); merge_option!(config, args, close_on_lost_focus); merge_option!(config, args, buttons_per_row); merge_option!(config, args, no_version_info); merge_option!(config, args, delay_command_ms); if let Some(css) = args.css.clone() { info!( "\"css\" file specified from args: {:?}", Path::display(&css) ); config.css = Some(css); } else { info!( "\"css\" file specified from config: {:?}", config.css.as_deref().map(Path::display) ); } } ================================================ FILE: src/error.rs ================================================ use miette::{Diagnostic, NamedSource, SourceOffset}; use std::path::PathBuf; use thiserror::Error; #[derive(Error, Diagnostic, Debug)] pub enum WError { #[error("Failed to load the specified configuration file {0} as it does not exist")] SpecifiedPathNotAFile(PathBuf), #[error("Failed to find the configuration file {0} in the search path")] FileNotInSearchPath(PathBuf), #[error("An error has occurred while reading file {0}: {1}")] IoError(PathBuf, std::io::Error), #[error("JSON parsing failed")] #[diagnostic(code(wleave::parse_failed))] FileParseFailed( #[source_code] NamedSource, #[label("The parser failed here")] SourceOffset, #[source] serde_json::Error, ), } ================================================ FILE: src/exec.rs ================================================ use crate::button::{WButtonAction, WButtonActionHandler}; use std::process::Command; use tracing::error; pub fn run_command(command: WButtonAction) { match command.handler { WButtonActionHandler::Executable(exe) => { if let Err(e) = Command::new(exe).spawn() { error!("Execution error: {e}"); } } WButtonActionHandler::Shell(script) => { if let Err(e) = Command::new("sh").args(["-c", &script]).spawn() { error!("Execution error: {e}"); } } } } ================================================ FILE: src/layout.rs ================================================ use crate::layout::menu_layout::LayoutMenuImpl; use crate::layout::menu_layout_child::MenuLayoutChildImpl; use glib::object::Cast; use glib::subclass::types::ObjectSubclassIsExt; use gtk4::prelude::{LayoutManagerExt, WidgetExt}; use libadwaita::gtk; use wleave::cli_opt::MenuLayoutStrategy; mod menu_layout_child { use gdk4::prelude::ObjectExt; use glib::subclass::object::{DerivedObjectProperties, ObjectImpl}; use glib::subclass::types::ObjectSubclass; use glib_macros::Properties; use gtk4::subclass::layout_child::LayoutChildImpl; use std::cell::Cell; #[derive(Properties, Default)] #[properties(wrapper_type = super::MenuLayoutChild)] pub struct MenuLayoutChildImpl { #[property( name = "placement", get, set, builder(super::MenuLayoutChildPlacement::default()) )] pub placement: Cell, #[property(name = "grid-x", get, set)] pub grid_x: Cell, #[property(name = "grid-y", get, set)] pub grid_y: Cell, } #[glib::object_subclass] impl ObjectSubclass for MenuLayoutChildImpl { const NAME: &'static str = "MenuLayoutChild"; type Type = super::MenuLayoutChild; type ParentType = gtk4::LayoutChild; type Interfaces = (); } #[glib::derived_properties] impl ObjectImpl for MenuLayoutChildImpl {} impl LayoutChildImpl for MenuLayoutChildImpl {} } glib::wrapper! { pub struct MenuLayoutChild(ObjectSubclass) @extends gtk4::LayoutChild; } impl MenuLayoutChild { fn new( manager: &MenuLayout, child: >k4::Widget, placement: MenuLayoutChildPlacement, ) -> MenuLayoutChild { glib::Object::builder() .property("layout-manager", manager) .property("child-widget", child) .property("placement", placement) .build() } } #[derive(Default, Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, glib::Enum)] #[enum_type(name = "MenuLayoutChildPlacement")] pub enum MenuLayoutChildPlacement { #[default] Unknown, Grid, } mod menu_layout { use crate::layout::{MenuLayoutChild, MenuLayoutChildPlacement, MenuLayoutProvider}; use gdk4::prelude::ObjectExt; use gdk4::subclass::prelude::DerivedObjectProperties; use glib::object::Cast; use glib::subclass::object::ObjectImpl; use glib::subclass::prelude::ObjectSubclassExt; use glib::subclass::types::ObjectSubclass; use glib::types::StaticType; use glib_macros::Properties; use gtk4::prelude::WidgetExt; use gtk4::subclass::layout_manager::LayoutManagerImpl; use std::cell::{Cell, RefCell}; use tracing::instrument; #[derive(Properties, Default)] #[properties(wrapper_type = super::MenuLayout)] pub struct LayoutMenuImpl { #[property(name = "aspect-ratio", get, set)] aspect_ratio: Cell, #[property(name = "buttons-per-row", get, set)] buttons_per_row: Cell, #[property(name = "aspect-ratio-set", get, set)] aspect_ratio_set: Cell, #[property(name = "column-spacing", get, set)] column_spacing: Cell, #[property(name = "row-spacing", get, set)] row_spacing: Cell, pub(super) layout_strategy: RefCell, } #[glib::object_subclass] impl ObjectSubclass for LayoutMenuImpl { const NAME: &'static str = "MenuLayout"; type Type = super::MenuLayout; type ParentType = gtk4::LayoutManager; type Interfaces = (); } #[glib::derived_properties] impl ObjectImpl for LayoutMenuImpl {} impl LayoutManagerImpl for LayoutMenuImpl { #[instrument(skip(self, widget))] fn allocate(&self, widget: >k4::Widget, width: i32, height: i32, baseline: i32) { { let mut layout = self.layout_strategy.borrow_mut(); layout.column_spacing = self.column_spacing.get(); layout.row_spacing = self.row_spacing.get(); layout.aspect_ratio = self.aspect_ratio_set.get().then(|| self.aspect_ratio.get()); } let layout = self.layout_strategy.borrow(); let mut curr = widget.first_child(); let children = std::iter::from_fn(|| { let it = curr.take()?; curr = it.next_sibling(); Some(it) }) .collect::>(); layout.allocate(&self.obj(), &children, width, height, baseline); } fn create_layout_child( &self, _widget: >k4::Widget, for_child: >k4::Widget, ) -> gtk4::LayoutChild { MenuLayoutChild::new( &self.obj(), for_child, match self.layout_strategy.borrow().strategy { super::MenuLayoutStrategy::Grid => MenuLayoutChildPlacement::Grid, }, ) .upcast() } fn layout_child_type() -> Option { Some(MenuLayoutChild::static_type()) } fn request_mode(&self, _widget: >k4::Widget) -> gtk4::SizeRequestMode { gtk4::SizeRequestMode::HeightForWidth } #[instrument(skip(self, widget))] fn measure( &self, widget: >k4::Widget, orientation: gtk4::Orientation, for_size: i32, ) -> (i32, i32, i32, i32) { let mut curr = widget.first_child(); let children = std::iter::from_fn(|| { let it = curr.take()?; curr = it.next_sibling(); Some(it) }) .collect::>(); let layout = self.layout_strategy.borrow(); layout.measure(&self.obj(), &children, orientation, for_size) } } } glib::wrapper! { pub struct MenuLayout(ObjectSubclass) @extends gtk4::LayoutManager; } impl MenuLayout { pub fn new( button_layout: MenuLayoutStrategy, ratio: Option>, column_spacing: gtk4::Expression, row_spacing: gtk4::Expression, buttons_per_row: Option>, ) -> Self { let obj: MenuLayout = glib::Object::builder() .property("aspect-ratio-set", ratio.is_some()) .property("aspect-ratio", ratio.map(Into::into).unwrap_or(1.0)) .property( "buttons-per-row", buttons_per_row.map(Into::into).unwrap_or_default(), ) .build(); column_spacing.bind(&obj, "column-spacing", glib::Object::NONE); row_spacing.bind(&obj, "row-spacing", glib::Object::NONE); let imp = obj.imp(); imp.layout_strategy.borrow_mut().strategy = button_layout; obj } } #[derive(Default)] struct MenuLayoutProvider { strategy: MenuLayoutStrategy, column_spacing: f64, row_spacing: f64, aspect_ratio: Option, } impl MenuLayoutProvider { fn allocate( &self, obj: &MenuLayout, children: &[gtk4::Widget], width: i32, height: i32, baseline: i32, ) { if children.is_empty() { return; } match self.strategy { MenuLayoutStrategy::Grid => { let n = children.len(); let col_spacing = (self.column_spacing as i32).max(0) as usize; let row_spacing = (self.row_spacing as i32).max(0) as usize; let mut rows = 1; let mut cols = 1; let mut b_width = 0.0; let mut b_height = 0.0; let u_width = width as usize; let u_height = height as usize; let per_row = obj.buttons_per_row() as usize; // We use 0 for "auto" placement let cols_range = if per_row != 0 { // Try layouts where all buttons either fit into one row or exactly "per_row" per_row.min(n)..=per_row } else { // Try all possible layouts 1..=n }; // Axis-aligned rectangle packing // We brute-force the best layout, optimizing for max button area for i_rows in 1..=n { for j_cols in cols_range.clone() { if (i_rows * j_cols > n + i_rows || i_rows * j_cols > n + j_cols) && per_row == 0 || i_rows * j_cols < n { continue; } let col_gaps = j_cols - 1; let row_gaps = i_rows - 1; let (w, h) = match self.aspect_ratio { Some(aspect @ 1.0..) => { let mut w = u_width.saturating_sub(col_gaps * col_spacing) as f64 / j_cols as f64 * aspect; let h = (u_height.saturating_sub(row_gaps * row_spacing) as f64 / i_rows as f64) .min(w / aspect); w = h * aspect; (w, h) } Some(aspect @ ..1.0) => { let mut h = u_height.saturating_sub(row_gaps * row_spacing) as f64 / i_rows as f64; let w = (u_width.saturating_sub(col_gaps * col_spacing) as f64 / j_cols as f64 * aspect) .min(h * aspect); h = w / aspect; (w, h) } // Some(..) | None => { let w = u_width.saturating_sub(col_gaps * col_spacing) as f64 / j_cols as f64; let h = u_height.saturating_sub(row_gaps * row_spacing) as f64 / i_rows as f64; (w, h) } }; if w * h > b_width * b_height { rows = i_rows; cols = j_cols; b_width = w; b_height = h; } } } let base_x = (width as f64 - (cols - 1) as f64 * (col_spacing as f64 + b_width) - b_width) / 2.0; let base_y = (height as f64 - (rows - 1) as f64 * (row_spacing as f64 + b_height) - b_height) / 2.0; for (i, child) in children.iter().enumerate() { let child_layout = obj .layout_child(child) .downcast::() .expect("always MenuLayoutChild"); if child.should_layout() { let ix = i % cols; let iy = i / cols; child_layout.set_placement(MenuLayoutChildPlacement::Grid); child_layout.set_grid_x(ix as u32); child_layout.set_grid_y(iy as u32); let x_grid = ix as f64; let y_grid = iy as f64; let x = base_x + x_grid * b_width + x_grid * self.column_spacing * self.aspect_ratio.unwrap_or(1.0); let y = base_y + y_grid * b_height + y_grid * self.row_spacing; child.size_allocate( >k4::Allocation::new( x as i32, y as i32, b_width as i32, b_height as i32, ), baseline, ); } } } } } fn measure( &self, _obj: &MenuLayout, children: &[gtk4::Widget], orientation: gtk4::Orientation, for_size: i32, ) -> (i32, i32, i32, i32) { let gaps = match orientation { gtk::Orientation::Vertical => self.row_spacing as i32, gtk::Orientation::Horizontal => self.column_spacing as i32, _ => 0, }; let (mut min, mut nat) = (0, 0); match self.strategy { MenuLayoutStrategy::Grid => { for child in children.iter() { if !child.should_layout() { continue; } let (c_min, c_nat, _, _) = child.measure(orientation, for_size); min = min.max(c_min + gaps); nat = nat.max(c_nat + gaps); } // A bit of a messy heuristic if matches!(orientation, gtk::Orientation::Horizontal) { min *= children.len() as i32; nat *= children.len() as i32; } else { min *= children.len() as i32; min /= 2; nat *= children.len() as i32; nat /= 2; } } } (min, nat, -1, -1) } } ================================================ FILE: src/lib.rs ================================================ pub mod cli_opt; pub mod units; ================================================ FILE: src/main.rs ================================================ mod app; mod button; mod config; mod error; mod exec; mod layout; mod paintable; use clap::Parser; use glib::clone; use std::sync::Arc; use tracing::{Level, error}; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use crate::app::create_app; use crate::config::{AppConfig, load_config, load_css, merge_with_args}; use gtk4::gdk::Display; use gtk4::prelude::*; use wleave::cli_opt::Args; fn on_startup(config: &AppConfig) { let display = Display::default().expect("Could not connect to a display"); match load_css(config.css.as_ref()) { Ok(css) => gtk4::style_context_add_provider_for_display( &display, &css, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ), Err(e) => error!("Failed to load CSS: {e}"), }; } fn entry_point(config: Arc) -> miette::Result<()> { let mut flags = gtk4::gio::ApplicationFlags::empty(); flags.set(gtk4::gio::ApplicationFlags::IS_SERVICE, config.service); let app = libadwaita::Application::builder() .application_id("sh.natty.Wleave") .flags(flags) .build(); app.connect_startup(clone!( #[strong] config, move |_| on_startup(config.as_ref()) )); let hold_guard = if config.service { Some(app.hold()) } else { None }; app.connect_activate(move |app| { let _ = &hold_guard; let app_window = create_app(&config, app); app_window.present(); }); app.run_with_args(&[] as &[&str]); Ok(()) } fn main() -> miette::Result<()> { tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer().without_time()) .with( EnvFilter::builder() .with_default_directive(Level::INFO.into()) .from_env_lossy(), ) .init(); let args = Args::parse(); let mut config = load_config(args.layout.as_ref())?; merge_with_args(&mut config, args); let config = Arc::new(config); entry_point(config)?; Ok(()) } ================================================ FILE: src/paintable.rs ================================================ use gdk4::prelude::*; use gdk4::subclass::paintable::PaintableImpl; use glib::subclass::ObjectImplRef; use glib::subclass::prelude::*; use glib::translate::IntoGlib; use glib_macros::Properties; use gtk4::prelude::*; use gtk4::subclass::prelude::SymbolicPaintableImpl; use miette::miette; use std::cell::{Cell, RefCell}; use tracing::error; #[derive(Properties, Default)] #[properties(wrapper_type = PicturePaintable)] pub struct PicturePaintableImpl { #[property(name = "image-path", get, set)] image_path: RefCell, #[property(name = "scale-factor", get, set)] scale_factor: Cell, widget: RefCell>>, handle: RefCell>, texture: RefCell>, _symbolic_updated: Cell, } #[glib::object_subclass] impl ObjectSubclass for PicturePaintableImpl { const NAME: &'static str = "WleavePicturePaintable"; type Type = PicturePaintable; type ParentType = glib::Object; type Interfaces = (gdk4::Paintable, gtk4::SymbolicPaintable); } glib::wrapper! { pub struct PicturePaintable(ObjectSubclass) @implements gdk4::Paintable, gtk4::SymbolicPaintable; } #[glib::derived_properties] impl ObjectImpl for PicturePaintableImpl { fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); let impl_ref = ObjectImplRef::new(self).downgrade(); obj.connect_notify_local(Some("image-path"), move |pict, _| { let Some(obj) = impl_ref.upgrade() else { return; }; obj.texture.take(); *obj.handle.borrow_mut() = match rsvg::Loader::new() .read_path(pict.image_path()) .map_err(|e| miette!("Failed to read SVG: {}", e)) { Ok(handle) => Some(handle), Err(e) => { error!("{}", e); None } }; }); } } impl PicturePaintableImpl { fn draw(&self, width: f64, height: f64) { let scale: f64 = self.scale_factor.get().into(); let width = width * scale; let height = height * scale; let mut tex_borrow = self.texture.borrow_mut(); if tex_borrow.is_some() { return; }; let Some(handle_ref) = &*self.handle.borrow() else { return; }; let renderer = rsvg::CairoRenderer::new(handle_ref); let mut surface = match cairo::ImageSurface::create(cairo::Format::ARgb32, width as i32, height as i32) .map_err(|e| miette!("Failed to create a Cairo surface: {}", e)) { Ok(surf) => surf, Err(e) => { error!("{}", e); return; } }; let ctx = match cairo::Context::new(&surface) .map_err(|e| miette!("Failed to create a Cairo context: {}", e)) { Ok(ctx) => ctx, Err(e) => { error!("{}", e); return; } }; if let Err(e) = renderer .render_document(&ctx, &cairo::Rectangle::new(0.0, 0.0, width, height)) .map_err(|e| miette!("Failed to render SVG: {}", e)) { error!("{}", e); return; } drop(ctx); let data = match surface .data() .map_err(|e| miette!("Failed to take Cairo image data: {}", e)) { Ok(data) => data, Err(e) => { error!("{}", e); return; } }; let bytes = glib::Bytes::from(data.as_ref()); cfg_if::cfg_if! { if #[cfg(target_endian = "little")] { let format = gdk4::MemoryFormat::B8g8r8a8; } else { let format = gdk4::MemoryFormat::A8r8g8b8; } } *tex_borrow = Some(gdk4::MemoryTexture::new( width as i32, height as i32, format, &bytes, size_of::() * width as usize, )); } } impl PaintableImpl for PicturePaintableImpl { fn current_image(&self) -> gdk4::Paintable { self.draw( self.intrinsic_width() as f64, self.intrinsic_height() as f64, ); let Some(tex_ref) = &*self.texture.borrow() else { return gdk4::Paintable::new_empty(self.intrinsic_width(), self.intrinsic_height()); }; gdk4::Paintable::from(tex_ref.clone()) } fn flags(&self) -> gdk4::PaintableFlags { gdk4::PaintableFlags::empty() } fn intrinsic_width(&self) -> i32 { let Some(handle_ref) = &*self.handle.borrow() else { return 256; }; let renderer = rsvg::CairoRenderer::new(handle_ref); let size = renderer .intrinsic_size_in_pixels() .map(|(w, _)| w.ceil() as i32); size.unwrap_or(256) } fn intrinsic_height(&self) -> i32 { let Some(handle_ref) = &*self.handle.borrow() else { return 256; }; let renderer = rsvg::CairoRenderer::new(handle_ref); let size = renderer .intrinsic_size_in_pixels() .map(|(_, h)| h.ceil() as i32); size.unwrap_or(256) } fn intrinsic_aspect_ratio(&self) -> f64 { let Some(handle_ref) = &*self.handle.borrow() else { return 1.0; }; let renderer = rsvg::CairoRenderer::new(handle_ref); let size = renderer.intrinsic_size_in_pixels().map(|(w, h)| w / h); size.unwrap_or(1.0) } fn snapshot(&self, snapshot: &gdk4::Snapshot, width: f64, height: f64) { if !self._symbolic_updated.get() { if let Some(w) = &*self.widget.borrow() && let Some(widget) = w.upgrade() { self.snapshot_symbolic(snapshot, width, height, &[widget.color()]); } else { self.snapshot_symbolic(snapshot, width, height, &[]); } } self.draw(width, height); let Some(tex_ref) = &*self.texture.borrow() else { return; }; SnapshotExt::append_texture( snapshot, tex_ref, >k4::graphene::Rect::new(0.0, 0.0, width as f32, height as f32), ); self._symbolic_updated.set(false); } } impl SymbolicPaintableImpl for PicturePaintableImpl { fn snapshot_symbolic( &self, snapshot: &gdk4::Snapshot, width: f64, height: f64, colors: &[gdk4::RGBA], ) { let mut handle_borrow = self.handle.borrow_mut(); let Some(handle_ref) = &mut *handle_borrow else { return; }; let col_idx = gtk4::SymbolicColor::Foreground.into_glib(); let col = colors.get(col_idx as usize).unwrap_or(&gdk4::RGBA::BLACK); if let Err(e) = handle_ref .set_stylesheet(&format!( r#" svg {{ color: {col} !important; }} "# )) .map_err(|e| miette!("Failed to set stylesheet for SVG while loading: {}", e)) { error!("{}", e); return; } drop(handle_borrow); self.texture.take(); self._symbolic_updated.set(true); self.snapshot(snapshot, width, height); } } impl PicturePaintable { fn for_path(icon_path: impl Into) -> Self { glib::Object::builder() .property("image-path", icon_path.into()) .property("scale-factor", 1) .build() } } pub fn svg_picture_colorized(icon: &str) -> gtk4::Picture { let paintable = PicturePaintable::for_path(icon); let picture = gtk4::Picture::for_paintable(&paintable); paintable.set_scale_factor(picture.scale_factor()); picture.connect_scale_factor_notify(move |pict| { let Some(p) = pict.paintable() else { return; }; let Some(paintable) = p.downcast_ref::() else { return; }; paintable.set_scale_factor(pict.scale_factor()); }); let internals = paintable.imp(); *internals.widget.borrow_mut() = Some(ObjectExt::downgrade(picture.as_ref())); picture } ================================================ FILE: src/units.rs ================================================ use miette::miette; use serde::{Deserialize, Deserializer}; use serde_json::Value; use std::error::Error; use std::fmt::Display; use std::num::NonZeroU32; use std::str::FromStr; #[derive(Clone, Copy, Debug)] pub enum AspectRatio { Float(f32), Ratio(u32, u32), } impl Default for AspectRatio { fn default() -> Self { AspectRatio::Float(1.0) } } impl<'de> Deserialize<'de> for AspectRatio { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let v = Value::deserialize(deserializer)?; if let Some(f) = v.as_f64() { Ok(AspectRatio::Float(f as f32)) } else if let Some(s) = v.as_str() { FromStr::from_str(s).map_err(serde::de::Error::custom) } else { Err(serde::de::Error::custom( "Aspect ratio neither a positive float nor a ratio (1/1, 2/3, ...)", )) } } } impl FromStr for AspectRatio { type Err = Box; fn from_str(s: &str) -> Result { if let Ok(float) = s.parse::() { if float < 0.0 { return Err("Aspect ratio cannot be negative".into()); } return Ok(AspectRatio::Float(float)); } if let Some((n, d)) = s.split_once('/') && let (Ok(n), Ok(d)) = (n.parse::(), d.parse::()) { return Ok(AspectRatio::Ratio(n.into(), d.into())); } Err("Aspect ratio neither a float nor a ratio".into()) } } impl Display for AspectRatio { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Float(r) => write!(f, "{r}"), Self::Ratio(n, d) => write!(f, "{n}/{d}"), } } } impl AspectRatio { pub fn as_float(self) -> f32 { match self { Self::Float(f) => f, Self::Ratio(n, d) => (n as f32) / (d as f32), } } } #[derive(Debug, Clone)] pub enum LengthValue { Px(f32), Percentage(f32), } #[derive(Debug, Deserialize)] #[serde(untagged)] enum LengthSerialized { Number(f32), String(String), } #[derive(Debug, Copy, Clone)] pub enum LengthDimension { Vertical, Horizontal, } #[derive(Debug, Clone)] pub struct LengthArgs { pub viewport: (f32, f32), pub dimension: LengthDimension, } impl<'de> Deserialize<'de> for LengthValue { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { match LengthSerialized::deserialize(deserializer)? { LengthSerialized::Number(num) => Ok(LengthValue::Px(num)), LengthSerialized::String(val) => val.parse().map_err(serde::de::Error::custom), } } } impl LengthValue { pub fn for_args(&self, args: &LengthArgs) -> f32 { match self { LengthValue::Percentage(p) => match args.dimension { LengthDimension::Horizontal => *p * args.viewport.0, LengthDimension::Vertical => *p * args.viewport.1, }, LengthValue::Px(val) => *val, } } fn parse(val: &'_ str) -> Result> { let mut input = cssparser::ParserInput::new(val); let mut parser = cssparser::Parser::new(&mut input); let token = parser.next()?; let value = match token { cssparser::Token::Number { value, .. } => LengthValue::Px(*value), cssparser::Token::Percentage { unit_value, .. } => LengthValue::Percentage(*unit_value), cssparser::Token::Dimension { value, unit, .. } => match unit.as_ref() { "px" => LengthValue::Px(*value), _ => { let tok = token.clone(); return Err(parser .current_source_location() .new_basic_unexpected_token_error(tok)); } }, _ => { let tok = token.clone(); return Err(parser .current_source_location() .new_basic_unexpected_token_error(tok)); } }; parser.expect_exhausted()?; Ok(value) } } impl FromStr for LengthValue { type Err = miette::Report; fn from_str(val: &str) -> Result { LengthValue::parse(val).map_err(|e| miette!("Length parse error: {:?}", e)) } } #[derive(Debug, Clone, Deserialize)] #[serde(transparent)] pub struct Margin(pub LengthValue); impl FromStr for Margin { type Err = miette::Report; fn from_str(val: &str) -> Result { LengthValue::from_str(val) .map(Margin) .map_err(|e| miette!("Margin parse error: {:?}", e)) } } ================================================ FILE: style.css ================================================ /** * See https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/css-variables.html for more CSS variables. * Or use your own colors. Both work, but using your own colors may break the automatic light/dark theme function. */ window { background-color: rgba(12, 12, 12, 0.8); } button { color: oklab(from var(--view-fg-color) var(--standalone-color-oklab)); background-color: var(--view-bg-color); border: none; padding: 10px; } button label.action-name { font-size: 24px; font-weight: 400; } button label.keybind { font-size: 20px; font-family: monospace; } button:hover label.keybind, button:focus label.keybind { opacity: 1; } button:focus, button:hover { background-color: color-mix(in srgb, var(--accent-bg-color), var(--view-bg-color)); } button:active { color: var(--accent-fg-color); background-color: var(--accent-bg-color); } button#shutdown { --view-fg-color: #ff8d8d; } button#hibernate { --view-fg-color: #a8c0ff; } button#reboot { --view-fg-color: #84ffaa; } button#lock { --view-fg-color: #ffe8b6; } button#logout { --view-fg-color: #ffcca8; } button#suspend { --view-fg-color: #caaff9; }