├── .github └── workflows │ └── ci.yaml ├── LICENSE ├── README.md ├── autoname-workspaces.py ├── firefox-focus-monitor.py ├── float-window-manager ├── float-window-managerd-readme.txt └── float-window-managerd.sh ├── grimpicker ├── Makefile ├── completion.bash ├── completion.fish ├── completion.zsh ├── grimpicker └── grimpicker.1.scd ├── grimshot ├── functional-helpers ├── grimshot ├── grimshot-completion.bash ├── grimshot.1 └── grimshot.1.scd ├── inactive-windows-transparency.py ├── layout-per-window.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── sway-session.target ├── swaystack.py └── switch-top-level.py /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | linux: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v4 11 | with: 12 | python-version: '3.10' 13 | cache: 'pip' 14 | - run: pip install -U -r requirements.txt -r requirements-dev.txt 15 | - name: Lint Python 16 | run: ruff check --output-format=github **.py 17 | - name: Check Python type annotations 18 | run: mypy **.py 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Sungjoon Moon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sway-Contrib 2 | [![CI](https://github.com/OctopusET/sway-contrib/actions/workflows/ci.yaml/badge.svg)](https://github.com/OctopusET/sway-contrib/actions/workflows/ci.yaml) 3 | 4 | > This repository is a collection of user contributions for the Sway window manager. 5 | 6 | [Sway](https://github.com/swaywm/sway/) is an i3-compatible tiling window manager for Wayland, offering a lightweight, efficient, and customizable environment. Sway-Contrib is a community-driven effort to share and showcase various user-created configurations, scripts, themes, and other resources that enhance and help the Sway experience. 7 | 8 | ## Tools 9 | | Name | Description | 10 | | :---: | :---: | 11 | | autoname-workspaces.py | Adds icons to the workspace name for each open window | 12 | | firefox-focus-monitor.py | Utility to selectively disable keypresses to specific windows | 13 | | grimpicker | A simple color picker for wlroots | 14 | | grimshot | A helper for screenshots within sway | 15 | | inactive-windows-transparency.py | Makes inactive windows transparent | 16 | | layout-per-window.py | A script keeps track of the active layout for each window | 17 | | switch-top-level.py | A script allows you to define two new bindings | 18 | | swaystack.py | A script for hoarding workspaces in stacks | 19 | 20 | 21 | ## Contributing 22 | 23 | We encourage everyone to contribute to Sway-Contrib! Whether you have a new script, a theme, a configuration file, or any other enhancement for Sway, your contributions are valuable to the community. To contribute, follow these steps: 24 | 25 | 1. Fork the repository to your GitHub account. 26 | 2. Create a new branch for your changes. 27 | 3. Make your changes and commit them. 28 | 4. Push the changes to your forked repository. 29 | 5. Open a pull request to the main Sway-Contrib repository. 30 | 31 | ## Resources 32 | 33 | - [Sway Website](https://swaywm.org/): Official website for the Sway window manager. 34 | - [Sway Wiki](https://github.com/swaywm/sway/wiki): Official wiki for Sway. 35 | 36 | ## Support and Issues 37 | 38 | If you encounter any issues with Sway-Contrib or have questions, feel free to open an issue on the [issue tracker](https://github.com/OctopusET/sway-contrib/issues). 39 | 40 | ## See also 41 | [Sway-Contrib Wiki](https://github.com/OctopusET/sway-contrib/wiki) 42 | 43 | ## License 44 | 45 | The Sway-Contrib repository is licensed under the [MIT License](LICENSE). By contributing to this project, you agree that your contributions will be licensed under the same license. 46 | 47 | ## Packaging status 48 | [![Packaging status](https://repology.org/badge/vertical-allrepos/sway-contrib.svg)](https://repology.org/project/sway-contrib/versions) 49 | -------------------------------------------------------------------------------- /autoname-workspaces.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This script requires i3ipc-python package (install it from a system package manager 4 | # or pip). 5 | # It adds icons to the workspace name for each open window. 6 | # Set your keybindings like this: set $workspace1 workspace number 1 7 | # Add your icons to WINDOW_ICONS. 8 | # Based on https://github.com/maximbaz/dotfiles/blob/master/bin/i3-autoname-workspaces 9 | 10 | import argparse 11 | import logging 12 | import re 13 | import signal 14 | import sys 15 | 16 | import i3ipc 17 | 18 | WINDOW_ICONS = { 19 | "firefox": "", 20 | } 21 | 22 | DEFAULT_ICON = "󰀏" 23 | 24 | 25 | def icon_for_window(window): 26 | name = None 27 | if window.app_id is not None and len(window.app_id) > 0: 28 | name = window.app_id.lower() 29 | elif window.window_class is not None and len(window.window_class) > 0: 30 | name = window.window_class.lower() 31 | 32 | if name in WINDOW_ICONS: 33 | return WINDOW_ICONS[name] 34 | 35 | logging.info("No icon available for window with name: %s" % str(name)) 36 | return DEFAULT_ICON 37 | 38 | def rename_workspaces(ipc): 39 | for workspace in ipc.get_tree().workspaces(): 40 | name_parts = parse_workspace_name(workspace.name) 41 | icon_tuple = () 42 | for w in workspace: 43 | if w.app_id is not None or w.window_class is not None: 44 | icon = icon_for_window(w) 45 | if not ARGUMENTS.duplicates and icon in icon_tuple: 46 | continue 47 | icon_tuple += (icon,) 48 | name_parts["icons"] = " ".join(icon_tuple) + " " 49 | new_name = construct_workspace_name(name_parts) 50 | ipc.command('rename workspace "%s" to "%s"' % (workspace.name, new_name)) 51 | 52 | 53 | def undo_window_renaming(ipc): 54 | for workspace in ipc.get_tree().workspaces(): 55 | name_parts = parse_workspace_name(workspace.name) 56 | name_parts["icons"] = None 57 | new_name = construct_workspace_name(name_parts) 58 | ipc.command('rename workspace "%s" to "%s"' % (workspace.name, new_name)) 59 | ipc.main_quit() 60 | sys.exit(0) 61 | 62 | 63 | def parse_workspace_name(name): 64 | return re.match( 65 | "(?P[0-9]+):?(?P\w+)? ?(?P.+)?", name 66 | ).groupdict() 67 | 68 | 69 | def construct_workspace_name(parts): 70 | new_name = str(parts["num"]) 71 | if parts["shortname"] or parts["icons"]: 72 | new_name += ":" 73 | 74 | if parts["shortname"]: 75 | new_name += parts["shortname"] 76 | 77 | if parts["icons"]: 78 | new_name += " " + parts["icons"] 79 | 80 | return new_name 81 | 82 | 83 | if __name__ == "__main__": 84 | parser = argparse.ArgumentParser( 85 | description="This script automatically changes the workspace name in sway depending on your open applications." 86 | ) 87 | parser.add_argument( 88 | "--duplicates", 89 | "-d", 90 | action="store_true", 91 | help="Set it when you want an icon for each instance of the same application per workspace.", 92 | ) 93 | parser.add_argument( 94 | "--logfile", 95 | "-l", 96 | type=str, 97 | default="/tmp/sway-autoname-workspaces.log", 98 | help="Path for the logfile.", 99 | ) 100 | args = parser.parse_args() 101 | global ARGUMENTS 102 | ARGUMENTS = args 103 | 104 | logging.basicConfig( 105 | level=logging.INFO, 106 | filename=ARGUMENTS.logfile, 107 | filemode="w", 108 | format="%(message)s", 109 | ) 110 | 111 | ipc = i3ipc.Connection() 112 | 113 | for sig in [signal.SIGINT, signal.SIGTERM]: 114 | signal.signal(sig, lambda signal, frame: undo_window_renaming(ipc)) 115 | 116 | def window_event_handler(ipc, e): 117 | if e.change in ["new", "close", "move"]: 118 | rename_workspaces(ipc) 119 | 120 | ipc.on("window", window_event_handler) 121 | 122 | rename_workspaces(ipc) 123 | 124 | ipc.main() 125 | 126 | -------------------------------------------------------------------------------- /firefox-focus-monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility to selectively disable keypresses to specific windows. 3 | 4 | This program was written due to Firefox's pop-out video player closing when 5 | the Escape key is pressed. I use a modal text editor (Helix) that encourages 6 | regularly pressing that key and it had a habit of going to the wrong window. 7 | 8 | The easiest way I could find to make this window specific key-binding change was 9 | via this code. Specifically it watches focus changes until the "right" windowds 10 | is focused, and then causes Sway to bind the Escape key before Firefox can see 11 | it. It continues to watch focus changes so that this binding can be disabled 12 | when another window is selected. 13 | 14 | This feels like a potentially useful pattern, please let us know: 15 | https://github.com/OctopusET/sway-contrib 16 | of any other programs that this functionality would benefit. 17 | """ 18 | 19 | import argparse 20 | import logging 21 | import signal 22 | from dataclasses import dataclass 23 | from typing import Any 24 | 25 | import i3ipc 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | @dataclass(slots=True) 31 | class Watch: 32 | container_props: dict[str,str] 33 | binds: set[str] 34 | 35 | 36 | class Monitor: 37 | _bound: set[str] 38 | watched: list[Watch] 39 | 40 | def __init__(self) -> None: 41 | self.ipc = i3ipc.Connection() 42 | self.ipc.on("window::focus", self.on_window_event) 43 | # firefox creates PIP window without title, so need to watch for changes 44 | self.ipc.on("window::title", self.on_window_event) 45 | self._bound = set() 46 | self.watched = [] 47 | 48 | def bind(self, *binds: str, **props: str) -> None: 49 | self.watched.append(Watch(props, set(binds))) 50 | 51 | def run(self) -> None: 52 | "run main i3ipc event loop" 53 | ipc = self.ipc 54 | def sighandler(signum: int, frame: Any) -> None: 55 | logger.debug("exit signal received, stopping event loop") 56 | ipc.main_quit() 57 | 58 | # stop event loop when we get one of these 59 | for sig in signal.SIGINT, signal.SIGTERM: 60 | signal.signal(sig, sighandler) 61 | 62 | try: 63 | ipc.main() 64 | finally: 65 | # clean up 66 | self.bound = set() 67 | 68 | def on_window_event(self, ipc: i3ipc.Connection, event: i3ipc.WindowEvent) -> None: 69 | "respond to window events" 70 | container = event.container 71 | if not container.focused: 72 | return 73 | data = container.ipc_data 74 | logger.debug("window event %s", data) 75 | binds = set() 76 | for watch in self.watched: 77 | if all(data.get(k) == v for k, v in watch.container_props.items()): 78 | binds.update(watch.binds) 79 | 80 | self.bound = binds 81 | 82 | @property 83 | def bound(self) -> set[str]: 84 | return self._bound 85 | 86 | @bound.setter 87 | def bound(self, binds: set[str]) -> None: 88 | if binds == self._bound: 89 | return 90 | to_del = self._bound - binds 91 | to_add = binds - self._bound 92 | if to_del: 93 | logger.info(f"removing binds {', '.join(to_del)}") 94 | if to_add: 95 | logger.info(f"adding binds {', '.join(to_add)}") 96 | for bind in to_del: 97 | self.ipc.command(f"unbindsym {bind}") 98 | for bind in to_add: 99 | msg = f"{bind} ignored due to focus monitor" 100 | self.ipc.command(f"bindsym {bind} exec echo '{msg}'") 101 | self._bound = binds 102 | 103 | 104 | def parse_args() -> argparse.Namespace: 105 | parser = argparse.ArgumentParser( 106 | description="track active window to disable Esc key in Firefox popout media player", 107 | ) 108 | parser.add_argument( 109 | "--verbose", "-v", help="Increase verbosity", action="store_true" 110 | ) 111 | args = parser.parse_args() 112 | args.loglevel = logging.DEBUG if args.verbose else logging.INFO 113 | return args 114 | 115 | 116 | KEY_ESCAPE = "Escape" 117 | 118 | 119 | def main() -> None: 120 | args = parse_args() 121 | logging.basicConfig( 122 | level=args.loglevel, 123 | format="%(asctime)s %(levelname)s %(message)s", 124 | ) 125 | 126 | mon = Monitor() 127 | # block Escape key from reaching Firefox's popout window 128 | mon.bind(KEY_ESCAPE, app_id="firefox", name="Picture-in-Picture") 129 | mon.run() 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | -------------------------------------------------------------------------------- /float-window-manager/float-window-managerd-readme.txt: -------------------------------------------------------------------------------- 1 | FloatWindowManager is designed to remember where you put floating windows, so that they appear 2 | there the next time they appear - thus they are not always appearing dead center in the screen. 3 | 4 | This is accomplished by subscribing to swaymsg's window events, and storing all window events that 5 | are new, close, or float. 6 | When a float window is opened, its position is stored. When it closes, if the position is different, 7 | the window's position as a percentage is stored in a file, the filename being the window title. 8 | Percentages in move commands started in Sway version 1.6, so this script will not work 9 | with Sway versions before that. 10 | 11 | When floatwindowmanager starts up, it looks through all files in a specified directory 12 | ("$HOME/.config/sway/float_window_store/"), and creates "for_window" rules for each one 13 | instructing sway to move the window to x and y percentages of the output's width/height. 14 | Thus Sway itself moves the windows. 15 | 16 | FloatWindowManager also remembers where you place windows that HAD BEEN tiling, but that 17 | you have converted to floating. 18 | Since a window that changes from tiling to floating (or from floating to tiling) is not 19 | a new window, Sway will not automatically move the windows; the script does that "manually". 20 | 21 | If a window was moved accidentally, that you would rather just leave centered, you can delete 22 | the file from the above directory, or leave it there and erase the percentages within. 23 | 24 | This script uses 4 commands (at least) that must be present in order to run: 25 | swaymsg, inotifywait, jq, and notify-send. 26 | 27 | If you're using Sway, then you'll have swaymsg, but the others may need to be installed. 28 | On Debian, inotifywait is available in the package inotify-tools, 29 | jq is from the identically-named package jq, and notify-send is from libnotify-bin 30 | (for notifications, I use emersion's mako, from the mako-notifier package on Debian) 31 | 32 | How to use: 33 | 34 | Move/copy the script to somewhere on your path. 35 | Add a line to the config file similar to the following, which I use: 36 | exec float-window-managerd.sh > $LOGS/$(date +"%Y%m%d:%H%M%S_")float-window-managerd.log 2>&1 37 | (I don't know if there would be any advantage with using exec_always - I don't know if 38 | Sway throws out all existing for_window rules for a config-reload.) 39 | If you wish to look at the log file, then use the re-directs, and define $LOGS to be where 40 | you want to find logs, or use an already defined location. 41 | Presumably, you'll want to have the line earlier in the config than when where you call the 42 | first app that has a floating window that you want to move... (cannot rule out possible 43 | race conditions, of course...), 'though after Mako (or similar notification app). 44 | 45 | I have NOT included a command to turn FloatWindowManager off. 46 | While I could call the same process-killing code that is part of the program, Sway's 47 | for_window rules would still be in play. I don't know of any way to remove for_window rules, 48 | and while I could issue new for_window rules with "move position center", the old rules will 49 | still be there. If there are a lot, it could be slow, maybe ending up with the window moving 50 | twice. 51 | Turning it off can be done with commenting-out/deleting the config-file line, and logging 52 | out/in again. 53 | 54 | How it works: 55 | FloatWindowManager is a daemon (I believe how it works qualifies for that term) that sits 56 | and waits for a certain things to happen. 57 | inotifywait is called to notify when a certain file changes. 58 | A request is made to subscribe to Sway's window events, and only new/close/floating events are 59 | added to a file - the file that inotifywait is monitoring. 60 | This file is '/tmp/sway_win_events.txt', and (being in /tmp) will be deleted when rebooting; 61 | On startup, if the file is found, it is reset to empty. 62 | Those window events are read and handled, and then a different inotifywait call is used to 63 | wait until the first inotifywait notices again that are new events. Then the new events are 64 | handled, etc. 65 | This system ensures that no events are missed while earlier events are being handled. 66 | -------------------------------------------------------------------------------- /float-window-manager/float-window-managerd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # $HOME/.local/bin/float-window-managerd.sh 4 | 5 | # This version will attempt to also handle windows that were tiling, but are now floating. 6 | # The type of window event is: "change": "floating" 7 | # This is the same whether changing to floating, or changing from floating to tiling. 8 | # The "container" "type" is what the window is changing TO: "floating_con" if changing to floating, "con" if changing to tiling 9 | # Since this does not involve a window's creation, one cannot use a for_window to automatically move the window. 10 | # The event's container's rect.x and rect.y appear to be the position of the floating window, whether it is just becoming floating 11 | # or just becoming tiling. This means we DO have the ability to see the original position when floating, and the last position 12 | # before tiling. 13 | 14 | 15 | cmds_all_found=true 16 | for cmd in swaymsg inotifywait jq notify-send; do 17 | output=$(command -v "$cmd") 18 | if [[ "$output" =~ ^alias ]]; then 19 | echo cmd \'"$cmd"\' found as an alias. 20 | else 21 | if [[ "$output" == "" ]]; then 22 | echo cmd \'"$cmd"\' not found. 23 | cmds_all_found=false 24 | else 25 | if ! [[ -x "$output" ]]; then 26 | echo cmd \'"$cmd"\' found, but is not executable. 27 | cmds_all_found=false 28 | else 29 | echo cmd \'"$cmd"\' found and is executable. 30 | fi 31 | fi 32 | fi 33 | 34 | done 35 | if ! "$cmds_all_found"; then 36 | echo Not all commands were found. 37 | notify-send "Not all required commands for float-window-manager were found." 38 | exit 1 39 | else 40 | echo All commands were found. 41 | fi 42 | 43 | 44 | version=$(swaymsg -t get_version | jq -r '.human_readable') 45 | if [[ "$version" < "1.6" ]]; then 46 | echo This version of Sway is earlier than supports moving windows with percentages. 47 | echo Version is \'"$version"\'. Version needed: \'1.6\'. 48 | exit 1 49 | fi 50 | 51 | 52 | winpath="$HOME/.config/sway/float_window_store/" 53 | [ -d "$winpath" ] || mkdir -p -v "$winpath" # " -p no error if existing, make parent directories as needed" 54 | 55 | # https://stackoverflow.com/questions/15783701/which-characters-need-to-be-escaped-when-using-bash 56 | doublequote=\" 57 | singlequote=\' 58 | # I noticed that the GIMP main window ends with '– GIMP', where the dash is not '-'. but '–', so I have added it to "special", in case it's a problem for regexp 59 | special=$'`!@#$%^&*()-–_+={}|[]\\;\:,.<>?/ '$doublequote$singlequote # featherpad's syntax highlighting seems to get messed up with these actual quotes in the special string... 60 | backslash=$'\\' 61 | 62 | escape_bad_chars_in_winname() { 63 | local winname="$1" 64 | 65 | winname_escape="" 66 | for ((i=0; i<"${#winname}"; i++)); do 67 | c="${winname:i:1}" 68 | if [[ $special =~ "$c" ]]; then # if not in quotes, some are missed, like '*' 69 | # if [[ $special == *"$c"* ]]; then # another way to do this (like with case statements) 70 | c1="$backslash$c" 71 | else 72 | c1="$c" 73 | fi 74 | winname_escape+="$c1" 75 | done 76 | echo "$winname_escape" 77 | } 78 | 79 | 80 | unset_arrays() { 81 | unset already_processed["$con_id"] 82 | unset was_tiling["$con_id"] 83 | unset outputwidth["$con_id"] 84 | unset outputheight["$con_id"] 85 | unset orig_win_x["$con_id"] 86 | unset orig_win_y["$con_id"] 87 | unset ws_x["$con_id"] 88 | unset ws_y["$con_id"] 89 | unset win_deco_height["$con_id"] 90 | unset ignore_me["$con_id"] 91 | echo Arrays unset... 92 | } 93 | 94 | # Make a Sway for_window rule for each file in $winpath, using the filename as title, and the contents as x and y percentages 95 | for file in $winpath/*; do 96 | filename=$(basename "$file") 97 | winname="$filename" 98 | 99 | read -r xperc yperc < "$file" > /dev/null 2>&1 100 | result=$? 101 | if (( "$result" != 0 )); then 102 | echo file \'"$file"\' could not be read... 103 | continue 104 | fi 105 | echo winname= \'"$winname"\' xperc= \'"$xperc"\' yperc= \'"$yperc"\' 106 | if [[ ("$xperc" == "") && ("$yperc" == "") ]]; then # "ignore me" 107 | echo winname= \'"$winname"\' --\> \"Ignore Me\" 108 | continue 109 | fi 110 | if [[ ( ("$xperc" != "") && ("$yperc" != "") ) && 111 | ( ! ("$xperc" =~ ^[+-]?[0-9]{1,2}$) && ("$yperc" =~ ^[+-]?[0-9]{1,2}$) ) ]]; then # sign (or not) plus 1-2 digits 112 | echo Window \'"$winname"\' has invalid percentage[s]. Ignoring. 113 | continue 114 | fi 115 | if [[ ( "$xperc" -lt 0 ) || ( "$xperc" -gt 100 ) || ( "$yperc" -lt 0 ) || ( "$yperc" -gt 100 ) ]]; then 116 | echo Window \'"$winname"\' has percentage[s] outside 0-100. Ignoring. 117 | continue 118 | fi 119 | 120 | winname="$(escape_bad_chars_in_winname "$winname")" 121 | 122 | swaymsg_output=$(swaymsg -- for_window \[title="$doublequote$winname$doublequote"\] "move position "$xperc" ppt "$yperc" ppt") 123 | result=$? 124 | # "swaymsg_output" is always non-blank, as a jq result with { "success": true } is printed if we are successful. 125 | if [[ ! "$result" == 0 ]]; then 126 | echo Window \'"$winname"\': for_window fails with message: \'"$swaymsg_output"\' 127 | fi 128 | done 129 | 130 | 131 | 132 | 133 | declare -i con_id opw oph 134 | declare -a PIDarray we_array outputwidth outputheight orig_win_x orig_win_y ignore_me 135 | declare -a already_processed was_tiling ws_x ws_y win_deco_height 136 | 137 | we_file='/tmp/sway_win_events.txt' 138 | if [[ -s "$we_file" ]]; then # ...if we find "$we_file", and it has non-zero size... 139 | truncate -s 0 "$we_file" 140 | else touch "$we_file" 141 | fi 142 | 143 | echo 144 | echo 145 | echo '=============================================================' 146 | echo Now killing any other window-placement processes... 147 | 148 | 149 | echo BASHPID= \'"$BASHPID"\' 150 | BASH_SID=$(ps -p "$BASHPID" -o sid=) 151 | echo BASH_SID= \'"$BASH_SID"\' 152 | 153 | #PS=$(pgrep -f -d ' ' "float-window-placement[0-9]*d.sh") #instead of \n, -d elimit with a space. 154 | PS=$(pgrep -f -d ' ' "float-window-managerd.sh") #instead of \n, -d elimit with a space. 155 | # -f "match against the ...full command line, not just the process name." 156 | echo PS= \'"$PS"\' 157 | IFS=" " read -r -a PIDarray <<< "$PS" 158 | for PID in "${PIDarray[@]}" 159 | do 160 | echo PID= \'"$PID"\' 161 | SID=$(ps -p "$PID" -o sid=) 162 | if [[ $SID == "" ]]; then #...already killed this one... 163 | echo ...must already have killed the SID for PID= \'"$PID"\' 164 | continue 165 | fi 166 | if [[ "$SID" != "$BASH_SID" ]]; then 167 | echo killing session ID \'$SID\' 168 | pkill -s $SID #not quoted, as $SID as generated by PS has a space at the beginning... 169 | else 170 | echo oops! That\'s OUR SID! \($BASH_SID\) Not killing... 171 | fi 172 | 173 | done 174 | echo '=============================================================' 175 | echo 176 | echo 177 | notify-send "...starting Float Window Manager..." 178 | 179 | 180 | # We will constantly monitor (-m) we_file, and when it changes, write stuff (we don't care what) to inw_file 181 | # Later, instead of constantly looping to see if we_file has had more lines added to it (and thus use 20-25% CPU), 182 | # we will call inotifywait again (but NOT with -m) and have it wake us when something changes. 183 | inw_file='/tmp/sway_win_events_inotifywait.txt' 184 | inotifywait -m -e modify "$we_file" > "$inw_file" 2>&1 & 185 | 186 | 187 | swaymsg -t subscribe -m '[ "window" ]' | grep --line-buffered -E '"change": "new"|"change": "close"|"change": "floating"' >> "$we_file" & 188 | 189 | echo now starting to read window new and close events from file 190 | 191 | # We will read events from we_file until we cannot, and then inotifywait until there are events again 192 | 193 | i=1 194 | while true; do 195 | while true; do 196 | IFS=$'\n' read -d '\n' -a we_array < <(tail -n +"$i" "$we_file" ) # read from line $i to [current] EOF 197 | num_events="${#we_array[@]}" 198 | if [[ "$num_events" == 0 ]]; then 199 | break; # while true - inner loop - go back to waiting in outer 'while true' loop 200 | fi 201 | echo 202 | echo i=$i read $num_events events 203 | 204 | 205 | for we in "${we_array[@]}"; do 206 | echo '==================================================================================' 207 | echo \'"$we"\' 208 | echo 209 | 210 | # $con.name is sometimes null, and messes up the read; we change null to bogus name here, and will deal with it later... 211 | IFS=$'\t' read -r event_type con_id event_win_name event_win_type event_win_x event_win_y \ 212 | < <(echo "$we" | jq -r '. as $root | 213 | $root.container as $con | [$root.change, $con.id, $con.name // "NuLlnUlLNuLlnUlL", 214 | $con.type, $con.rect.x, $con.rect.y] | @tsv ') 215 | 216 | event_win_name=${event_win_name//NuLlnUlLNuLlnUlL/} 217 | 218 | if [[ "$event_type" == "floating" ]]; then 219 | if [[ "$event_win_type" == "floating_con" ]]; then 220 | event_type="to_floating" 221 | echo event_type "floating" ---> "to_floating" 222 | else 223 | event_type="to_tiling" 224 | echo event_type "floating" ---> "to_tiling" 225 | fi 226 | else 227 | # handle a closed originally-tiling window as if it reverted to tiling 228 | if [[ ("$event_type" == "close") && ("${was_tiling["$con_id"]}" == "yes") ]]; then 229 | event_type="to_tiling" 230 | echo event_type "close" ---> "to_tiling" 231 | fi 232 | fi 233 | 234 | echo \'"$event_type"\' \'"$con_id"\' \'"$event_win_type"\' \'"$event_win_name"\' 235 | echo Window position: "$event_win_x" , "$event_win_y" 236 | 237 | 238 | case "$event_type" in 239 | 240 | *"new"* | *"to_floating"*) 241 | 242 | if [[ "$event_type" == "new" ]]; then 243 | sleep 1 # delay long enough for name to get into win_name, and win_type to be set to "floating_con" (or not) 244 | else # "to_floating" 245 | if [[ -v already_processed["$con_id"] ]]; then 246 | echo We already processed a \'new\' event for window \'"$con_id"\' 247 | echo 248 | continue # for we in "${we_array[@]}" 249 | fi 250 | fi 251 | 252 | # $win.name is sometimes null, and messes up the read; we change null to bogus name here, and will deal with it later... 253 | IFS=$'\t' read -r win_name win_type win_x win_y win_deco_height ws_name ws_x ws_y output_width output_height < <(swaymsg -t get_tree | \ 254 | jq -r --arg CONID "$con_id" '. as $root | $root.nodes[] as $output | $output.nodes[] as $ws | 255 | $ws.floating_nodes[] as $win | select ( $win.id == ($CONID | tonumber) ) | 256 | [$win.name // "NuLlnUlLNuLlnUlL", $win.type, $win.rect.x, $win.rect.y, $win.deco_rect.height, $ws.name, 257 | $ws.rect.x, $ws.rect.y, $output.rect.width, $output.rect.height] | @tsv') 258 | result=$? 259 | if [[ "$result" != 0 ]]; then 260 | echo result=\'"$result"\' ...apparently window matching con_id=\'"$con_id"\' is not floating - aborting this event... 261 | unset_arrays 262 | continue # for we in "${we_array[@]}" 263 | fi 264 | win_name=${win_name//NuLlnUlLNuLlnUlL/} # change bogus "name" back into empty string 265 | echo \'$event_type\' \'$con_id\' \'$win_name\' 266 | echo \'"$win_name"\' type=\'"$win_type"\' x:y $win_x : $win_y win_deco_height=$win_deco_height ws:$ws_name x:$ws_x y:$ws_y $output_width x $output_height 267 | 268 | already_processed["$con_id"]="yes" # this will never be "no" - we just test for set/unset ... 269 | if [[ "$event_type" == "new" ]]; then 270 | was_tiling["$con_id"]="no" 271 | else 272 | was_tiling["$con_id"]="yes" 273 | fi 274 | outputwidth["$con_id"]="$output_width" 275 | outputheight["$con_id"]="$output_height" 276 | orig_win_x["$con_id"]="$win_x" 277 | orig_win_y["$con_id"]="$win_y" 278 | ws_x["$con_id"]="$ws_x" 279 | ws_y["$con_id"]="$ws_y" 280 | win_deco_height["$con_id"]="$win_deco_height" 281 | ignore_me["$con_id"]="no" 282 | 283 | if [[ "$win_name" != "" ]]; then # $win_name often 'null'-->"" for 'to_floating' 284 | wn="$win_name" 285 | else 286 | wn="$event_win_name" 287 | fi 288 | file="$winpath""$wn" 289 | read -r xperc yperc < "$file" > /dev/null 2>&1 290 | result=$? 291 | echo file-read result: \'$result\' 292 | if (( "$result" == 0 )); then 293 | echo -n 'xperc:yperc' \'"$xperc"\' : \'"$yperc"\' # echo line finished with the below '--->' \"Ignore me\", or with other text. 294 | if [[ ("$xperc" == "") && ("$yperc" == "") ]]; then 295 | ignore_me["$con_id"]="yes" 296 | notify-send "Position of window \"$wn\" ignored." 297 | echo '--->' \"Ignore me\" 298 | else 299 | if [[ ( ("$xperc" != "") && ("$yperc" != "") ) && 300 | ( ("$xperc" =~ ^[+-]?[0-9]{1,2}$) && ("$yperc" =~ ^[+-]?[0-9]{1,2}$) ) ]]; then # sign (or not) plus 1-2 digits 301 | if [[ ( "$xperc" -ge 0 ) || ( "$xperc" -le 100 ) || ( "$yperc" -ge 0 ) || ( "$yperc" -le 100 ) ]]; then 302 | echo '---> percentages present and valid.' 303 | if [[ "$event_type" == "to_floating" ]]; then # we have to move the window ourselves. 304 | swaymsg_output=$(swaymsg -- \[con_id=$con_id\] "move position "$xperc" ppt "$yperc" ppt") 305 | result=$? 306 | # "swaymsg_output" is always non-blank, as a jq result with { "success": true } is printed if we are successful. 307 | if [[ "$result" != 0 ]]; then 308 | echo Window \'"$wn"\': \"move position\" fails with message: \'"$swaymsg_output"\' 309 | unset_arrays 310 | continue # for we in "${we_array[@]}" 311 | fi 312 | # Now, we'll have to get the window from swaymsg, as the position changed, and we will need to know later for "close"/"to_tiling 313 | # if its position later changed from the saved position. 314 | IFS=$'\t' read -r win_x win_y < <(swaymsg -t get_tree | \ 315 | jq -r --arg CONID "$con_id" '. as $root | $root.nodes[] as $output | $output.nodes[] as $ws | 316 | $ws.floating_nodes[] as $win | select ( $win.id == ($CONID | tonumber) ) | 317 | [$win.rect.x, $win.rect.y] | @tsv') 318 | result=$? 319 | if [[ "$result" != 0 ]]; then 320 | echo result=\'"$result"\' ...apparently window matching con_id=\'"$con_id"\' is not floating - aborting this event... 321 | unset_arrays 322 | continue # for we in "${we_array[@]}" 323 | fi 324 | 325 | orig_win_x["$con_id"]="$win_x" 326 | orig_win_y["$con_id"]="$win_y" 327 | echo window moved to "$win_x" "$win_y" 328 | echo window moved to $xperc % $yperc % 329 | notify-send "Position of window \"$wn\" moved." 330 | fi # "$event_type" == "to_floating" 331 | else 332 | echo '---> percentage[s] out of bounds. Will fix later, if moved.' 333 | fi # xperc/yperc present and correctly formed, but out of bounds ? 334 | else 335 | echo '---> missing/malformed percentage[s]. Will fix later, if moved.' 336 | fi # xperc/yperc present and valid ? 337 | 338 | fi # xperc AND yperc present ? ? 339 | fi # file read successful ? 340 | 341 | ;; 342 | 343 | 344 | 345 | 346 | *"close"* | *"to_tiling"*) 347 | 348 | echo \'"$event_type"\' \'"$con_id"\' \'"$event_win_name"\' 349 | 350 | if [[ ("$event_type" == "close") && ("$event_win_type" != "floating_con") ]]; then 351 | echo 'Not a floating-con...' 352 | continue # for we in "${we_array[@]}" 353 | fi 354 | 355 | opw=${outputwidth["$con_id"]} 356 | oph=${outputheight["$con_id"]} 357 | 358 | echo \(i.m.: ${ignore_me["$con_id"]}\) \(x: new:$event_win_x : old:${orig_win_x["$con_id"]}\) \(y: new:$event_win_y : old:${orig_win_y["$con_id"]}\) 359 | echo outputwidth of "$con_id" is: $opw outputheight of "$con_id" is: $oph 360 | if [[ ($event_win_x -ge 0 ) ]]; then echo event-win-x IS GE 0; else echo event-win-x IS NOT GE 0; fi 361 | if [[ ($event_win_y -ge 0 ) ]]; then echo event-win-y IS GE 0; else echo event-win-y IS NOT GE 0; fi 362 | if [[ ($event_win_x -lt $opw) ]]; then echo event-win-x IS LT outputwidth; else echo event-win-x IS NOT LT outputwidth; fi 363 | if [[ ($event_win_y -lt $oph) ]]; then echo event-win-y IS LT outputheight; else echo event-win-y IS NOT LT outputheight; fi 364 | 365 | 366 | if [[ (${ignore_me["$con_id"]} == "no") && 367 | (($event_win_x != ${orig_win_x["$con_id"]}) || ($event_win_y != ${orig_win_y["$con_id"]})) && 368 | (($event_win_x -ge 0 ) && ($event_win_y -ge 0 )) && 369 | (($event_win_x -lt $opw) && ($event_win_y -lt $oph)) ]]; then 370 | 371 | #NOTE: We now store percentage as 0-100 so we multiply by 100, and round to the nearest pixel by adding .5 and truncating ("scale=0") 372 | # We have to do scale=4, and then take the result and do scale=0 to truncate, as bc won't do it all in one, for some reason... 373 | # (and won't do scale=0 if no division in calc, so " / 1") 374 | 375 | # sway does not use the whole output width/height when it performs its percentage window placement, but offsets by the workspace x/y 376 | # and deco-rect height. Currently, only ws_y and win-deco-height seem not to be non-zero, but ... 377 | 378 | opw_m_wsx=$(echo "$opw-${ws_x["$con_id"]}" | bc -l) 379 | xperc=$(echo "scale=4; $event_win_x / $opw_m_wsx * 100 + .5" | bc -l) 380 | xperc=$(echo "scale=0; $xperc / 1" | bc -l) 381 | 382 | oph_m_wsy_m_wdh=$(echo "($oph-${ws_y["$con_id"]})-${win_deco_height["$con_id"]}" | bc -l) 383 | yperc=$(echo "scale=4; $event_win_y / $oph_m_wsy_m_wdh * 100 + .5" | bc -l) 384 | yperc=$(echo "scale=0; $yperc / 1" | bc -l) 385 | echo xperc: $xperc, yperc: $yperc 386 | file="$winpath""$event_win_name" 387 | echo $xperc $yperc > "$file" 388 | result=$? 389 | if [[ "$result" != 0 ]]; then 390 | notify-send "Could not save window \"$event_win_name\" position."; 391 | else 392 | notify-send "Window \"$event_win_name\" position saved." 393 | 394 | if [[ "$event_type" == "close" ]]; then 395 | echo window \"$event_win_name\" - position saved. 396 | echo Making new/changed "for_window" for \'"$event_win_name"\' ... 397 | echo wn before: \'"$event_win_name"\' xperc= \'"$xperc"\' yperc= \'"$yperc"\' 398 | event_win_name="$(escape_bad_chars_in_winname "$event_win_name")" 399 | echo wn after: \'"$event_win_name"\' 400 | 401 | swaymsg_output=$(swaymsg -- for_window \[title="$doublequote$event_win_name$doublequote"\] "move position "$xperc" ppt "$yperc" ppt") 402 | result=$? 403 | # "swaymsg_output" is always non-blank, as a jq result with { "success": true } is printed if we are successful. 404 | if [[ "$result" != 0 ]]; then 405 | echo Window \'"$event_win_name"\': for_window fails with message: \'"$swaymsg_output"\' 406 | fi 407 | else 408 | echo originally-tiled window \"$event_win_name\" - position saved. 409 | fi 410 | fi # file write successful ? 411 | else 412 | notify-send "Window \"$event_win_name\" ignored (or not moved)." 413 | echo Nothing changed here for \'"$event_win_name"\', or we are ignoring it, or it is offscreen. 414 | fi # ignore?/unchanged?/off-screen? 415 | 416 | unset_arrays 417 | 418 | ;; 419 | 420 | 421 | *) 422 | 423 | ;; 424 | esac # "$event_type" 425 | 426 | echo 427 | 428 | done # for we in "${we_array[@]}" 429 | 430 | ((i+="$num_events")) 431 | 432 | done # while true - inner loop - read from we_file until num_events == 0 433 | 434 | echo nothing to read, so we go to sleep with inotifywait 435 | 436 | inotifywait -e modify "$inw_file" 437 | done # while true - inotifywait 438 | 439 | exit 0 # probably never reached, but ... 440 | 441 | -------------------------------------------------------------------------------- /grimpicker/Makefile: -------------------------------------------------------------------------------- 1 | PKGNAME = "grimpicker" 2 | DESTDIR ?= "" 3 | PREFIX ?= "/usr" 4 | 5 | .PHONY: build install 6 | 7 | build: 8 | scdoc <"${PKGNAME}.1.scd" >"${PKGNAME}.1" 9 | 10 | install: 11 | # Not installing zsh completion here as its destination depends on the distribution 12 | install -D -m 755 "${PKGNAME}" "${DESTDIR}${PREFIX}/bin/${PKGNAME}" 13 | install -D -m 644 "completion.bash" "${DESTDIR}${PREFIX}/share/bash-completion/completions/${PKGNAME}" 14 | install -D -m 644 "completion.fish" "${DESTDIR}${PREFIX}/share/fish/vendor_completions.d/${PKGNAME}.fish" 15 | install -D -m 644 "${PKGNAME}.1" "${DESTDIR}${PREFIX}/share/man/man1/${PKGNAME}.1" 16 | -------------------------------------------------------------------------------- /grimpicker/completion.bash: -------------------------------------------------------------------------------- 1 | _grimpicker() { 2 | local cur="${COMP_WORDS[COMP_CWORD]}" 3 | 4 | short=(-p -d -e -c -n -h -v) 5 | long=(--print --draw --escape --copy --notify --help --version) 6 | 7 | if [[ $cur == --* ]]; then 8 | COMPREPLY=($(compgen -W "${long[*]}" -- "$cur")) 9 | else 10 | COMPREPLY=($(compgen -W "${short[*]}" -- "$cur")) 11 | COMPREPLY+=($(compgen -W "${long[*]}" -- "$cur")) 12 | fi 13 | } 14 | 15 | complete -F _grimpicker grimpicker 16 | -------------------------------------------------------------------------------- /grimpicker/completion.fish: -------------------------------------------------------------------------------- 1 | complete -c grimpicker -f 2 | complete -c grimpicker -s p -l print -d "Print to stdout" 3 | complete -c grimpicker -s d -l draw -d "Draw a colored block" 4 | complete -c grimpicker -s e -l escape -d "Print shell escape sequences" 5 | complete -c grimpicker -s c -l copy -d "Copy to clipboard" 6 | complete -c grimpicker -s n -l notify -d "Send a notification" 7 | complete -c grimpicker -s h -l help -d "Show help message and quit" 8 | complete -c grimpicker -s v -l version -d "Show version number and quit" 9 | -------------------------------------------------------------------------------- /grimpicker/completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef grimpicker 2 | 3 | _arguments -s \ 4 | {-d,--print}'[Print to stdout]' \ 5 | {-d,--draw}'[Draw a colored block]' \ 6 | {-e,--escape}'[Print shell escape sequences]' \ 7 | {-c,--copy}'[Copy to clipboard]' \ 8 | {-n,--notify}'[Send a notification]' \ 9 | {-h,--help}'[Show help message and quit]' \ 10 | {-v,--version}'[Show version number and exit]' \ 11 | -------------------------------------------------------------------------------- /grimpicker/grimpicker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | grimpicker: a simple color picker for wlroots 5 | 6 | Dependencies: 7 | `slurp`: utility to select a region 8 | `grim`: utility to make screenshots 9 | 10 | Recommendations: 11 | `wl-copy`: clipboard utility 12 | `notify-send`: desktop notifications sender 13 | ''' 14 | 15 | __pkgname__ = 'grimpicker' 16 | __version__ = '1.0.0' 17 | 18 | import argparse 19 | import subprocess 20 | import sys 21 | 22 | 23 | class Color: 24 | escape = b'\x1b' 25 | reset_fg = escape + b'[39m' 26 | reset_bg = escape + b'[49m' 27 | reset_all = escape + b'[0m' 28 | 29 | escape_str = '\\e' 30 | reset_fg_str = escape_str + '[39m' 31 | reset_bg_str = escape_str + '[49m' 32 | reset_all_str = escape_str + '[0m' 33 | 34 | def __init__(self, r: int, g: int, b: int): 35 | (self.r, self.g, self.b) = (r, g, b) 36 | 37 | @classmethod 38 | def decode_ppm_pixel(cls, ppm: bytes): 39 | ppm_lines = ppm.splitlines() 40 | scales = ppm_lines[1].split(b' ') 41 | scale = int(scales[0]) 42 | 43 | if not scale == int(scales[1]): 44 | raise ValueError("Unknown output scaling used") 45 | 46 | if not (len(ppm_lines) == 4 and ppm_lines[0] == b'P6' and ppm_lines[2] == b'255' and len(ppm_lines[3]) == scale * scale * 3): 47 | raise ValueError('only 1x1 pixel ppm P6 format without comments is supported, no HDR') 48 | 49 | #if we are dealing with multiple pixels, average them. 50 | r = 0 51 | g = 0 52 | b = 0 53 | for s in range(0, scale * scale): 54 | r += ppm_lines[3][s * 3 + 0] 55 | g += ppm_lines[3][s * 3 + 1] 56 | b += ppm_lines[3][s * 3 + 2] 57 | 58 | r /= scale * scale 59 | g /= scale * scale 60 | b /= scale * scale 61 | 62 | return cls(int(r), int(g), int(b)) 63 | 64 | def to_hex(self) -> str: 65 | return '#{:0>2X}{:0>2X}{:0>2X}'.format(self.r, self.g, self.b) 66 | 67 | def to_escape_fg(self) -> bytes: 68 | return b'%b[38;2;%d;%d;%dm' % (self.escape, self.r, self.g, self.b) 69 | 70 | def to_escape_bg(self) -> bytes: 71 | return b'%b[48;2;%d;%d;%dm' % (self.escape, self.r, self.g, self.b) 72 | 73 | def to_escape_fg_str(self) -> str: 74 | return '{}[38;2;{};{};{}m'.format(self.escape_str, self.r, self.g, self.b) 75 | 76 | def to_escape_bg_str(self) -> str: 77 | return '{}[48;2;{};{};{}m'.format(self.escape_str, self.r, self.g, self.b) 78 | 79 | 80 | def run(args) -> None: 81 | slurp = subprocess.check_output(('slurp', '-p')) 82 | grim = subprocess.check_output(('grim', '-g', '-', '-t', 'ppm', '-'), input=slurp) 83 | color = Color.decode_ppm_pixel(grim) 84 | 85 | if not (args.print or args.draw or args.escape or args.copy or args.notify): 86 | args.print = True 87 | args.draw = True 88 | 89 | if args.print: 90 | print(color.to_hex()) 91 | if args.draw: 92 | sys.stdout.buffer.write(color.to_escape_bg() + b' ' * 7 + color.reset_bg + b'\n') 93 | if args.escape: 94 | sys.stdout.buffer.write( 95 | b'Truecolor terminal shell escape sequences:\n' + 96 | 97 | b'%bTo change foreground:%b ' % (color.to_escape_fg(), color.reset_fg) + 98 | b'echo -e "%b", to reset: ' % color.to_escape_fg_str().encode() + 99 | b'echo -e "%b"\n' % color.reset_fg_str.encode() + 100 | 101 | b'%bTo change background:%b ' % (color.to_escape_bg(), color.reset_bg) + 102 | b'echo -e "%b", to reset: ' % color.to_escape_bg_str().encode() + 103 | b'echo -e "%b"\n' % color.reset_bg_str.encode() + 104 | 105 | b'To reset all attributes: echo -e "%b"\n' % color.reset_all_str.encode() 106 | ) 107 | if args.copy: 108 | subprocess.run(('wl-copy', color.to_hex()), check=True) 109 | if args.notify: 110 | subprocess.run(('notify-send', color.to_hex()), check=True) 111 | 112 | 113 | def parse_args() -> argparse.Namespace: 114 | usage = '{} [OPTIONS]'.format(__pkgname__) 115 | version = '{} {}'.format(__pkgname__, __version__) 116 | epilog = 'See `man 1 grimpicker` for further details' 117 | parser = argparse.ArgumentParser(usage=usage, add_help=False, epilog=epilog) 118 | parser.add_argument('-p', '--print', dest='print', action='store_true', help='Print to stdout') 119 | parser.add_argument('-d', '--draw', dest='draw', action='store_true', help='Draw a colored block') 120 | parser.add_argument('-e', '--escape', dest='escape', action='store_true', help='Print shell escape sequences') 121 | parser.add_argument('-c', '--copy', dest='copy', action='store_true', help='Copy to clipboard') 122 | parser.add_argument('-n', '--notify', dest='notify', action='store_true', help='Send a notification') 123 | parser.add_argument('-h', '--help', action='help', help='Show help message and quit') 124 | parser.add_argument('-v', '--version', action='version', version=version, help='Show version number and quit') 125 | return parser.parse_args() 126 | 127 | 128 | if __name__ == '__main__': 129 | run(parse_args()) 130 | -------------------------------------------------------------------------------- /grimpicker/grimpicker.1.scd: -------------------------------------------------------------------------------- 1 | GRIMPICKER(1) 2 | 3 | # NAME 4 | 5 | grimpicker - a simple color picker for wlroots 6 | 7 | # SYNOPSIS 8 | 9 | *grimpicker* [_OPTIONS_] 10 | 11 | # OPTIONS 12 | 13 | *-p*, *--print* 14 | Print to stdout 15 | 16 | *-d*, *--draw* 17 | Draw a colored block 18 | 19 | *-e*, *--escape* 20 | Print shell escape sequences 21 | 22 | *-c*, *--copy* 23 | Copy to clipboard 24 | 25 | *-n*, *--notify* 26 | Send a notification 27 | 28 | *-h*, *--help* 29 | Show help message and quit 30 | 31 | *-v*, *--version* 32 | Show version number and quit 33 | 34 | # DESCRIPTION 35 | 36 | *grimpicker* is a color picker that uses *slurp* and *grim*. 37 | These programs rely on _zwlr_layer_shell_v1_ and _wlr-screencopy-unstable-v1_ 38 | (maybe be replaced with _ext-image-capture-source-v1_ and 39 | _ext-image-copy-capture-v1_ in the future) wayland protocols 40 | (implemented in wlroots-based compositors, e.g. *sway*). 41 | 42 | It has several output options, they can be combined. 43 | 44 | _--copy_ needs *wl-clipboard* to be installed. 45 | 46 | _--draw_ and _--escape_ need a terminal with truecolor support (e.g. *foot*). 47 | 48 | _--notify_ needs *libnotify* to be installed 49 | and a notification daemon (e.g. *mako* or *fnott*) to be running. 50 | 51 | _--print_ and _--draw_ are selected by default if no arguments are provided. 52 | 53 | # EXAMPLES 54 | 55 | An example usage pattern is to add this binding to your sway config: 56 | 57 | ``` 58 | # Super+Print: color picker 59 | bindsym --to-code $mod+Print exec grimpicker --notify 60 | 61 | ``` 62 | 63 | # SEE ALSO 64 | 65 | *slurp*(1), *grim*(1), *grimshot*(1) 66 | -------------------------------------------------------------------------------- /grimshot/functional-helpers: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | when() { 3 | condition=$1 4 | action=$2 5 | 6 | if eval "$condition"; then 7 | eval "$action" 8 | fi 9 | } 10 | 11 | whenOtherwise() { 12 | condition=$1 13 | true_action=$2 14 | false_action=$3 15 | 16 | if eval "$condition"; then 17 | eval "$true_action" 18 | else 19 | eval "$false_action" 20 | fi 21 | } 22 | 23 | any() { 24 | for tuple in "$@"; do 25 | condition=$(echo "$tuple" | cut -d: -f1) 26 | action=$(echo "$tuple" | cut -d: -f2-) 27 | if eval "$condition"; then 28 | eval "$action" 29 | return 0 30 | fi 31 | done 32 | return 1 # No conditions matched 33 | } 34 | -------------------------------------------------------------------------------- /grimshot/grimshot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ## Grimshot: a helper for screenshots within sway 4 | ## Requirements: 5 | ## - `grim`: screenshot utility for wayland 6 | ## - `slurp`: to select an area 7 | ## - `swaymsg`: to read properties of current window 8 | ## - `wl-copy`: clipboard utility 9 | ## - `jq`: json utility to parse swaymsg output 10 | ## - `notify-send`: to show notifications 11 | ## Those are needed to be installed, if unsure, run `grimshot check` 12 | ## 13 | ## See `man 1 grimshot` or `grimshot usage` for further details. 14 | 15 | when() { 16 | condition=$1 17 | action=$2 18 | 19 | if eval "$condition"; then 20 | eval "$action" 21 | fi 22 | } 23 | 24 | whenOtherwise() { 25 | condition=$1 26 | true_action=$2 27 | false_action=$3 28 | 29 | if eval "$condition"; then 30 | eval "$true_action" 31 | else 32 | eval "$false_action" 33 | fi 34 | } 35 | 36 | any() { 37 | for tuple in "$@"; do 38 | condition=$(echo "$tuple" | cut -d: -f1) 39 | action=$(echo "$tuple" | cut -d: -f2-) 40 | if eval "$condition"; then 41 | eval "$action" 42 | return 0 43 | fi 44 | done 45 | return 1 # No conditions matched 46 | } 47 | 48 | NOTIFY=no 49 | CURSOR= 50 | WAIT=no 51 | 52 | getTargetDirectory() { 53 | test -f "${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" && 54 | . "${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" 55 | 56 | echo "${XDG_SCREENSHOTS_DIR:-${XDG_PICTURES_DIR:-$HOME}}" 57 | } 58 | 59 | parseArgs() { 60 | POSITIONAL_ARGS="" 61 | 62 | while [ $# -gt 0 ]; do 63 | case "$1" in 64 | -n | --notify) 65 | NOTIFY=yes 66 | shift 67 | ;; 68 | -c | --cursor) 69 | CURSOR=yes 70 | shift 71 | ;; 72 | -w | --wait) 73 | shift 74 | WAIT="$1" 75 | if echo "$WAIT" | grep "[^0-9]" -q; then 76 | echo "invalid value for wait '$WAIT'" >&2 77 | exit 3 78 | fi 79 | shift 80 | ;; 81 | *) # Treat anything else as a positional argument 82 | POSITIONAL_ARGS="$POSITIONAL_ARGS $1" # Add positional argument to the string 83 | shift 84 | ;; 85 | esac 86 | done 87 | 88 | set -- $POSITIONAL_ARGS # Re-assign positional arguments 89 | ACTION=${1:-usage} 90 | SUBJECT=${2:-screen} 91 | FILE=${3:-$(getTargetDirectory)/$(date -Ins).png} 92 | 93 | } 94 | 95 | printUsageMsg() { 96 | echo "Usage:" 97 | echo " grimshot [--notify] [--cursor] [--wait N] (copy|save) [active|screen|output|area|window|anything] [FILE|-]" 98 | echo " grimshot check" 99 | echo " grimshot usage" 100 | echo "" 101 | echo "Commands:" 102 | echo " copy: Copy the screenshot data into the clipboard." 103 | echo " save: Save the screenshot to a regular file or '-' to pipe to STDOUT." 104 | echo " savecopy: Save the screenshot to a regular file and copy the data into the clipboard." 105 | echo " check: Verify if required tools are installed and exit." 106 | echo " usage: Show this message and exit." 107 | echo "" 108 | echo "Targets:" 109 | echo " active: Currently active window." 110 | echo " screen: All visible outputs." 111 | echo " output: Currently active output." 112 | echo " area: Manually select a region." 113 | echo " window: Manually select a window." 114 | echo " anything: Manually select an area, window, or output." 115 | exit 116 | } 117 | 118 | notify() { 119 | notify-send -t 3000 -a grimshot "$@" 120 | } 121 | 122 | notifyOk() { 123 | notify_disabled='[ "$NOTIFY" = "no" ]' 124 | action_involves_saving='[ "$ACTION" = "save" ] || [ "$ACTION" = "savecopy" ]' 125 | 126 | if eval $notify_disabled; then 127 | return 128 | fi 129 | 130 | TITLE=${2:-"Screenshot"} 131 | MESSAGE=${1:-"OK"} 132 | 133 | whenOtherwise "$action_involves_saving" \ 134 | 'notify "$TITLE" "$MESSAGE" -i "$FILE"' \ 135 | 'notify "$TITLE" "$MESSAGE"' 136 | } 137 | 138 | notifyError() { 139 | notify_enabled='[ "$NOTIFY" = "yes" ]' 140 | TITLE=${2:-"Screenshot"} 141 | errorMssg=$1 142 | MESSAGE=${errorMssg:-"Error taking screenshot with grim"} 143 | 144 | whenOtherwise "$notify_enabled" \ 145 | 'notify "$TITLE" "$MESSAGE" -u critical' \ 146 | 'echo "$errorMssg"' 147 | } 148 | 149 | die() { 150 | MSG=${1:-Bye} 151 | notifyError "Error: $MSG" 152 | exit 2 153 | } 154 | 155 | check() { 156 | COMMAND=$1 157 | command_exists='command -v "$COMMAND" > /dev/null 2>&1' 158 | 159 | whenOtherwise "$command_exists" \ 160 | 'RESULT="OK"' \ 161 | 'RESULT="NOT FOUND"' 162 | 163 | echo " $COMMAND: $RESULT" 164 | } 165 | 166 | takeScreenshot() { 167 | FILE=$1 168 | GEOM=$2 169 | OUTPUT=$3 170 | 171 | output_provided='[ -n "$OUTPUT" ]' 172 | geom_not_provided='[ -z "$GEOM" ]' 173 | 174 | output_action='grim ${CURSOR:+-c} -o "$OUTPUT" "$FILE" || die "Unable to invoke grim"' 175 | full_screenshot_action='grim ${CURSOR:+-c} "$FILE" || die "Unable to invoke grim"' 176 | geometry_screenshot_action='grim ${CURSOR:+-c} -g "$GEOM" "$FILE" || die "Unable to invoke grim"' 177 | 178 | any \ 179 | "$output_provided:$output_action" \ 180 | "$geom_not_provided:$full_screenshot_action" \ 181 | "true:$geometry_screenshot_action" 182 | } 183 | checkRequiredTools() { 184 | echo "Checking if required tools are installed. If something is missing, install it to your system and make it available in PATH..." 185 | check grim 186 | check slurp 187 | check swaymsg 188 | check wl-copy 189 | check jq 190 | check notify-send 191 | exit 192 | } 193 | 194 | selectArea() { 195 | GEOM=$(slurp -d) 196 | geomIsEmpty='[ -z "$GEOM" ]' 197 | when "$geomIsEmpty" "exit 1" 198 | WHAT="Area" 199 | } 200 | 201 | selectActiveWindow() { 202 | FOCUSED=$(swaymsg -t get_tree | jq -r 'recurse(.nodes[]?, .floating_nodes[]?) | select(.focused)') 203 | GEOM=$(echo "$FOCUSED" | jq -r '.rect | "\(.x),\(.y) \(.width)x\(.height)"') 204 | APP_ID=$(echo "$FOCUSED" | jq -r '.app_id') 205 | WHAT="$APP_ID window" 206 | } 207 | 208 | selectScreen() { 209 | GEOM="" 210 | WHAT="Screen" 211 | } 212 | 213 | selectOutput() { 214 | GEOM="" 215 | OUTPUT=$(swaymsg -t get_outputs | jq -r '.[] | select(.focused)' | jq -r '.name') 216 | WHAT="$OUTPUT" 217 | } 218 | 219 | selectWindow() { 220 | GEOM=$(swaymsg -t get_tree | jq -r '.. | select(.pid? and .visible?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"' | slurp -r) 221 | geomIsEmpty='[ -z "$GEOM" ]' 222 | when "$geomIsEmpty" "exit 1" 223 | WHAT="Window" 224 | } 225 | 226 | selectAnything() { 227 | GEOM=$(swaymsg -t get_tree | jq -r '.. | select(.pid? and .visible?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"' | slurp -o) 228 | geomIsEmpty='[ -z "$GEOM" ]' 229 | when "$geomIsEmpty" "exit 1" 230 | WHAT="Selection" 231 | } 232 | handleSaveCopy() { 233 | wl-copy --type image/png <"$FILE" || die "Clipboard error" 234 | MESSAGE="$MESSAGE and clipboard" 235 | } 236 | 237 | handleScreenshotSuccess() { 238 | TITLE="Screenshot of $SUBJECT" 239 | MESSAGE=$(basename "$FILE") 240 | isSaveCopy='[ "$ACTION" = "savecopy" ]' 241 | when "$isSaveCopy" "handleSaveCopy" 242 | notifyOk "$MESSAGE" "$TITLE" 243 | echo "$FILE" 244 | } 245 | 246 | handleScreenshotFailure() { 247 | notifyError "Error taking screenshot with grim" 248 | } 249 | 250 | handleCopy() { 251 | takeScreenshot - "$GEOM" "$OUTPUT" | wl-copy --type image/png || die "Clipboard error" 252 | notifyOk "$WHAT copied to clipboard" 253 | } 254 | 255 | handleSave() { 256 | screenshotTaken="takeScreenshot \"$FILE\" \"$GEOM\" \"$OUTPUT\"" 257 | whenOtherwise "$screenshotTaken" \ 258 | "handleScreenshotSuccess" \ 259 | "handleScreenshotFailure" 260 | } 261 | handleUnknownSubject() { 262 | die "Unknown subject to take a screenshot from" "$SUBJECT" 263 | } 264 | handleScreenshot() { 265 | actionIsInvalid='[ "$ACTION" != "save" ] && [ "$ACTION" != "copy" ] && [ "$ACTION" != "savecopy" ] && [ "$ACTION" != "check" ]' 266 | actionIsCheck='[ "$ACTION" = "check" ]' 267 | subjectIsArea='[ "$SUBJECT" = "area" ]' 268 | subjectIsActiveWindow='[ "$SUBJECT" = "active" ]' 269 | subjectIsScreen='[ "$SUBJECT" = "screen" ]' 270 | subjectIsOutput='[ "$SUBJECT" = "output" ]' 271 | subjectIsWindow='[ "$SUBJECT" = "window" ]' 272 | subjectIsAnything='[ "$SUBJECT" = "anything" ]' 273 | subjectIsUnknown=true 274 | any \ 275 | "$actionIsInvalid:printUsageMsg" \ 276 | "$actionIsCheck:checkRequiredTools" \ 277 | "$subjectIsArea:selectArea" \ 278 | "$subjectIsActiveWindow:selectActiveWindow" \ 279 | "$subjectIsScreen:selectScreen" \ 280 | "$subjectIsOutput:selectOutput" \ 281 | "$subjectIsWindow:selectWindow" \ 282 | "$subjectIsAnything:selectAnything" \ 283 | "$subjectIsUnknown:handleUnknownSubject" 284 | 285 | wait='[ "$WAIT" != "no" ]' 286 | when "$wait" "sleep $WAIT" 287 | 288 | actionIsCopy='[ "$ACTION" = "copy" ]' 289 | 290 | whenOtherwise "$actionIsCopy" \ 291 | "handleCopy" \ 292 | "handleSave" 293 | } 294 | 295 | parseArgs "$@" 296 | handleScreenshot 297 | -------------------------------------------------------------------------------- /grimshot/grimshot-completion.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # To make use of this script simply add: source path/to/grimshot-completion.bash 4 | # to your .bashrc. 5 | 6 | _grimshot_bash_comp() { 7 | local req_target="copy save savecopy" 8 | local first_char=$(cut -c -1 <<< "${COMP_WORDS[1]}") 9 | 10 | local target_pos=3 11 | local cmd_index=1 12 | local cmd_pos=2 13 | if [[ $first_char == "-" ]]; then 14 | target_pos=4 15 | cmd_index=2 16 | cmd_pos=3 17 | fi 18 | 19 | # Complete options 20 | if [[ $first_char == "-" && ${#COMP_WORDS[@]} -eq 2 ]]; then 21 | COMPREPLY=( $(compgen -W "--notify --cursor --wait" -- "${COMP_WORDS[COMP_CWORD]}") ) 22 | 23 | # Complete commands 24 | elif [[ ${#COMP_WORDS[@]} -eq $cmd_pos ]]; then 25 | COMPREPLY=( $(compgen -W "check usage $req_target" -- "${COMP_WORDS[COMP_CWORD]}") ) 26 | 27 | # Complete targets 28 | elif [[ $req_target =~ "${COMP_WORDS[$cmd_index]}" && ${#COMP_WORDS[@]} -eq $target_pos ]]; then 29 | COMPREPLY=( $(compgen -W "active screen output area window anything" -- "${COMP_WORDS[COMP_CWORD]}") ) 30 | fi 31 | } 32 | 33 | complete -F _grimshot_bash_comp grimshot 34 | -------------------------------------------------------------------------------- /grimshot/grimshot.1: -------------------------------------------------------------------------------- 1 | .\" Generated by scdoc 1.11.2 2 | .\" Complete documentation for this program is not available as a GNU info page 3 | .ie \n(.g .ds Aq \(aq 4 | .el .ds Aq ' 5 | .nh 6 | .ad l 7 | .\" Begin generated content: 8 | .TH "grimshot" "1" "2024-01-01" 9 | .P 10 | .SH NAME 11 | .P 12 | grimshot - a helper for screenshots within sway 13 | .P 14 | .SH SYNOPSIS 15 | .P 16 | \fBgrimshot\fR [--notify] [--cursor] [--wait N] (copy|save) [TARGET] [FILE] 17 | .br 18 | \fBgrimshot\fR check 19 | .br 20 | \fBgrimshot\fR usage 21 | .P 22 | .SH OPTIONS 23 | .P 24 | \fB--notify\fR 25 | .RS 4 26 | Show notifications to the user that a screenshot has been taken.\& 27 | .P 28 | .RE 29 | \fB--cursor\fR 30 | .RS 4 31 | Include cursors in the screenshot.\& 32 | .P 33 | .RE 34 | \fB--wait N\fR 35 | .RS 4 36 | Wait for N seconds before taking a screenshot.\& Waits after any 37 | manual selection is made.\& Recommended to combine with --notify in 38 | order to know when the screenshot has been taken.\& 39 | .P 40 | .RE 41 | \fBsave\fR 42 | .RS 4 43 | Save the screenshot into a regular file.\& Grimshot will write image 44 | files to \fBXDG_SCREENSHOTS_DIR\fR if this is set (or defined 45 | in \fBuser-dirs.\&dir\fR), or otherwise fall back to \fBXDG_PICTURES_DIR\fR.\& 46 | Set FILE to '\&-'\& to pipe the output to STDOUT.\& 47 | .P 48 | .RE 49 | \fBcopy\fR 50 | .RS 4 51 | Copy the screenshot data (as image/png) into the clipboard.\& 52 | .P 53 | .RE 54 | \fB\fRsavecopy\fB\fR 55 | .RS 4 56 | Save the screenshot into a regular file (see \fIsave\fR documentation) and 57 | copy the screenshot data into the clipboard (see \fIcopy\fR documentation).\& 58 | .P 59 | .RE 60 | .SH DESCRIPTION 61 | .P 62 | Grimshot is an easy-to-use screenshot utility for sway.\& It provides a 63 | convenient interface over grim, slurp and jq, and supports storing the 64 | screenshot either directly to the clipboard using wl-copy or to a file.\& 65 | .P 66 | .SH EXAMPLES 67 | .P 68 | An example usage pattern is to add these bindings to your sway config: 69 | .P 70 | .nf 71 | .RS 4 72 | # Screenshots: 73 | # Super+P: Current window 74 | # Super+Shift+p: Select area 75 | # Super+Alt+p Current output 76 | # Super+Ctrl+p Select a window 77 | 78 | bindsym Mod4+p exec grimshot save active 79 | bindsym Mod4+Shift+p exec grimshot save area 80 | bindsym Mod4+Mod1+p exec grimshot save output 81 | bindsym Mod4+Ctrl+p exec grimshot save window 82 | .fi 83 | .RE 84 | .P 85 | .SH TARGETS 86 | .P 87 | grimshot can capture the following named targets: 88 | .P 89 | \fIactive\fR 90 | .RS 4 91 | Captures the currently active window.\& 92 | .P 93 | .RE 94 | \fIscreen\fR 95 | .RS 4 96 | Captures the entire screen.\& This includes all visible outputs.\& 97 | .P 98 | .RE 99 | \fIarea\fR 100 | .RS 4 101 | Allows manually selecting a rectangular region, and captures that.\& 102 | .P 103 | .RE 104 | \fIwindow\fR 105 | .RS 4 106 | Allows manually selecting a single window (by clicking on it), and 107 | captures it.\& 108 | .P 109 | .RE 110 | \fIoutput\fR 111 | .RS 4 112 | Captures the currently active output.\& 113 | .P 114 | .RE 115 | \fIanything\fR 116 | .RS 4 117 | Allows manually selecting a single window (by clicking on it), an output (by 118 | clicking outside of all windows, e.\&g.\& on the status bar), or an area (by 119 | using click and drag).\& 120 | .P 121 | .RE 122 | .SH OUTPUT 123 | .P 124 | Grimshot will print the filename of the captured screenshot to stdout if called 125 | with the \fIsave\fR or \fIsavecopy\fR subcommands.\& 126 | .P 127 | .SH SEE ALSO 128 | .P 129 | \fBgrim\fR(1) 130 | -------------------------------------------------------------------------------- /grimshot/grimshot.1.scd: -------------------------------------------------------------------------------- 1 | grimshot(1) 2 | 3 | # NAME 4 | 5 | grimshot - a helper for screenshots within sway 6 | 7 | # SYNOPSIS 8 | 9 | *grimshot* [--notify] [--cursor] [--wait N] (copy|save) [TARGET] [FILE]++ 10 | *grimshot* check++ 11 | *grimshot* usage 12 | 13 | # OPTIONS 14 | 15 | *--notify* 16 | Show notifications to the user that a screenshot has been taken. 17 | 18 | *--cursor* 19 | Include cursors in the screenshot. 20 | 21 | *--wait N* 22 | Wait for N seconds before taking a screenshot. Waits after any 23 | manual selection is made. Recommended to combine with --notify in 24 | order to know when the screenshot has been taken. 25 | 26 | *save* 27 | Save the screenshot into a regular file. Grimshot will write image 28 | files to *XDG_SCREENSHOTS_DIR* if this is set (or defined 29 | in *user-dirs.dir*), or otherwise fall back to *XDG_PICTURES_DIR*. 30 | Set FILE to '-' to pipe the output to STDOUT. 31 | 32 | *copy* 33 | Copy the screenshot data (as image/png) into the clipboard. 34 | 35 | **savecopy** 36 | Save the screenshot into a regular file (see _save_ documentation) and 37 | copy the screenshot data into the clipboard (see _copy_ documentation). 38 | 39 | # DESCRIPTION 40 | 41 | Grimshot is an easy-to-use screenshot utility for sway. It provides a 42 | convenient interface over grim, slurp and jq, and supports storing the 43 | screenshot either directly to the clipboard using wl-copy or to a file. 44 | 45 | # EXAMPLES 46 | 47 | An example usage pattern is to add these bindings to your sway config: 48 | 49 | ``` 50 | # Screenshots: 51 | # Super+P: Current window 52 | # Super+Shift+p: Select area 53 | # Super+Alt+p Current output 54 | # Super+Ctrl+p Select a window 55 | 56 | bindsym Mod4+p exec grimshot save active 57 | bindsym Mod4+Shift+p exec grimshot save area 58 | bindsym Mod4+Mod1+p exec grimshot save output 59 | bindsym Mod4+Ctrl+p exec grimshot save window 60 | ``` 61 | 62 | # TARGETS 63 | 64 | grimshot can capture the following named targets: 65 | 66 | _active_ 67 | Captures the currently active window. 68 | 69 | _screen_ 70 | Captures the entire screen. This includes all visible outputs. 71 | 72 | _area_ 73 | Allows manually selecting a rectangular region, and captures that. 74 | 75 | _window_ 76 | Allows manually selecting a single window (by clicking on it), and 77 | captures it. 78 | 79 | _output_ 80 | Captures the currently active output. 81 | 82 | _anything_ 83 | Allows manually selecting a single window (by clicking on it), an output (by 84 | clicking outside of all windows, e.g. on the status bar), or an area (by 85 | using click and drag). 86 | 87 | # OUTPUT 88 | 89 | Grimshot will print the filename of the captured screenshot to stdout if called 90 | with the _save_ or _savecopy_ subcommands. 91 | 92 | # SEE ALSO 93 | 94 | *grim*(1) 95 | -------------------------------------------------------------------------------- /inactive-windows-transparency.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This script requires i3ipc-python package (install it from a system package manager 4 | # or pip). 5 | # It makes inactive windows transparent. Use `transparency_val` variable to control 6 | # transparency strength in range of 0…1 or use the command line argument -o. 7 | 8 | import argparse 9 | import signal 10 | import sys 11 | from functools import partial 12 | 13 | import i3ipc 14 | 15 | 16 | def on_window(args, ipc, event): 17 | global focused_set 18 | 19 | # To get the workspace for a container, we need to have received its 20 | # parents, so fetch the whole tree 21 | tree = ipc.get_tree() 22 | 23 | focused = tree.find_focused() 24 | if focused is None: 25 | return 26 | 27 | focused_workspace = focused.workspace() 28 | 29 | focused.command("opacity " + args.focused) 30 | focused_set.add(focused.id) 31 | 32 | to_remove = set() 33 | for window_id in focused_set: 34 | if window_id == focused.id: 35 | continue 36 | window = tree.find_by_id(window_id) 37 | if window is None: 38 | to_remove.add(window_id) 39 | elif args.global_focus or window.workspace() == focused_workspace: 40 | window.command("opacity " + args.opacity) 41 | to_remove.add(window_id) 42 | 43 | focused_set -= to_remove 44 | 45 | def remove_opacity(ipc, focused_opacity): 46 | for workspace in ipc.get_tree().workspaces(): 47 | for w in workspace: 48 | w.command("opacity " + focused_opacity) 49 | ipc.main_quit() 50 | sys.exit(0) 51 | 52 | 53 | if __name__ == "__main__": 54 | parser = argparse.ArgumentParser( 55 | description="This script allows you to set the transparency of unfocused windows in sway." 56 | ) 57 | parser.add_argument( 58 | "--opacity", 59 | "-o", 60 | type=str, 61 | default="0.80", 62 | help="set inactive opacity value in range 0...1", 63 | ) 64 | parser.add_argument( 65 | "--focused", 66 | "-f", 67 | type=str, 68 | default="1.0", 69 | help="set focused opacity value in range 0...1", 70 | ) 71 | parser.add_argument( 72 | "--global-focus", 73 | "-g", 74 | action="store_true", 75 | help="only have one opaque window across all workspaces", 76 | ) 77 | args = parser.parse_args() 78 | 79 | ipc = i3ipc.Connection() 80 | focused_set = set() 81 | 82 | for window in ipc.get_tree(): 83 | if window.focused: 84 | focused_set.add(window.id) 85 | window.command("opacity " + args.focused) 86 | else: 87 | window.command("opacity " + args.opacity) 88 | for sig in [signal.SIGINT, signal.SIGTERM]: 89 | signal.signal(sig, lambda signal, frame: remove_opacity(ipc, args.focused)) 90 | ipc.on("window", partial(on_window, args)) 91 | ipc.main() 92 | -------------------------------------------------------------------------------- /layout-per-window.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This script keeps track of the active layout for each window. 4 | # Optional argument defines numeric layout index for new windows (counted from 0) 5 | # 6 | # This script requires i3ipc-python package (install it from a system package 7 | # manager or pip). 8 | 9 | import sys 10 | from typing import Optional 11 | 12 | import i3ipc 13 | 14 | 15 | def on_window_focus(ipc: i3ipc.connection.Connection, event: i3ipc.events.WindowEvent): 16 | global windows, prev_focused, default_layout 17 | 18 | # Get current layouts 19 | layouts = { 20 | input.identifier: input.xkb_active_layout_index for input in ipc.get_inputs() 21 | } 22 | 23 | # Save layouts for previous window 24 | windows[prev_focused] = layouts 25 | 26 | # Restore layout of the newly focused known window 27 | if event.container.id in windows: 28 | for kdb_id, layout_index in windows[event.container.id].items(): 29 | if layout_index != layouts[kdb_id]: 30 | ipc.command(f'input "{kdb_id}" xkb_switch_layout {layout_index}') 31 | break 32 | 33 | # Set default layout for a fresh window 34 | elif default_layout is not None: 35 | for kdb_id, layout_index in layouts.items(): 36 | if layout_index is not None and layout_index != default_layout: 37 | ipc.command(f'input "{kdb_id}" xkb_switch_layout {default_layout}') 38 | break 39 | 40 | prev_focused = event.container.id 41 | 42 | 43 | def on_window_close(ipc: i3ipc.connection.Connection, event: i3ipc.events.WindowEvent): 44 | global windows 45 | if event.container.id in windows: 46 | del windows[event.container.id] 47 | 48 | 49 | def on_window(ipc: i3ipc.connection.Connection, event: i3ipc.events.WindowEvent): 50 | if event.change == "focus": 51 | on_window_focus(ipc, event) 52 | elif event.change == "close": 53 | on_window_close(ipc, event) 54 | 55 | 56 | if __name__ == "__main__": 57 | default_layout: Optional[int] = None 58 | if len(sys.argv) == 2: 59 | if sys.argv[1].isnumeric(): 60 | default_layout = int(sys.argv[1]) 61 | else: 62 | print(f"Expected an integer, got: {sys.argv[1]}", file=sys.stderr) 63 | sys.exit(2) 64 | elif len(sys.argv) > 2: 65 | print("Too many arguments", file=sys.stderr) 66 | sys.exit(2) 67 | 68 | ipc = i3ipc.Connection() 69 | focused = ipc.get_tree().find_focused() 70 | if focused: 71 | prev_focused = focused.id 72 | else: 73 | prev_focused = None 74 | windows: dict = {} 75 | 76 | ipc.on("window", on_window) 77 | ipc.main() 78 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | select = ["E", "F", "B", "Q", "I"] 3 | target-version = "py310" 4 | line-length = 120 5 | 6 | [[tool.mypy.overrides]] 7 | module = "i3ipc" 8 | ignore_missing_imports = true 9 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | ruff 2 | mypy -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | i3ipc 2 | -------------------------------------------------------------------------------- /sway-session.target: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Sway session 3 | Documentation=man:systemd.special(7) 4 | BindsTo=graphical-session.target 5 | Wants=graphical-session-pre.target 6 | After=graphical-session-pre.target 7 | -------------------------------------------------------------------------------- /swaystack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This script requires i3ipc-python package (install it from a system package 4 | # manager or pip). 5 | # The script "stacks" numbered workspaces 1-10 onto 11-20, 21-30, etc 6 | # Useful for hoarding workspaces, or if you're working on a project and need 7 | # to focus on something else, but don't wanna close your workspaces 8 | # This script doesn't proivde a way to view workspaces in the stack, nor does it 9 | # expect you to provide one in your sway config. The stack is for storage; pop 10 | # workspaces onto the "home row" (1-10) to view them. 11 | 12 | # Example sway config: 13 | # 14 | # set $mod Mod4 15 | # set $alt Mod1 16 | # bindsym $mod+$alt+s exec /path/to/swaystack.py --up 17 | # bindsym $mod+$alt+d exec /path/to/swaystack.py --down 18 | # bindsym $mod+$alt+r exec /path/to/swaystack.py --rot-down 19 | # bindsym $mod+$alt+e exec /path/to/swaystack.py --rot-up 20 | 21 | import argparse 22 | 23 | import i3ipc 24 | 25 | 26 | def shift_up(workspace): 27 | workspace_num = workspace.num 28 | 29 | # only call from "home row" 30 | if not (workspace_num >= 1 and workspace_num <= 10): 31 | return 32 | 33 | stack_num = workspace_num % 10 34 | stack = (w for w in ipc.get_workspaces() 35 | if w.num % 10 == stack_num and w.num != 0) 36 | sorted_stack = sorted(stack, key=lambda w: w.num, reverse=True) 37 | 38 | if workspace.leaves(): 39 | # if not empty, push up into stack 40 | for ws in sorted_stack: 41 | num = ws.num 42 | ipc.command(f"rename workspace {num} to {num+10}") 43 | ipc.command(f"workspace {workspace_num}") 44 | # else, do nothing 45 | 46 | 47 | def shift_down(workspace): 48 | workspace_num = workspace.num 49 | 50 | # only call from "home row" 51 | if not (workspace_num >= 1 and workspace_num <= 10): 52 | return 53 | 54 | stack_num = workspace_num % 10 55 | stack = (w for w in ipc.get_workspaces() 56 | if w.num % 10 == stack_num and w.num > 10) 57 | sorted_stack = sorted(stack, key=lambda w: w.num) 58 | 59 | if not workspace.leaves(): 60 | bottom_num = sorted_stack[0].num 61 | ipc.command(f"workspace {bottom_num}") 62 | # if empty, pop down from the stack 63 | for ws in sorted_stack: 64 | num = ws.num 65 | ipc.command(f"rename workspace {num} to {num-10}") 66 | # else, do nothing 67 | 68 | 69 | def rotate_down(workspace): 70 | workspace_num = workspace.num 71 | 72 | # only call from "home row" 73 | if not (workspace_num >= 1 and workspace_num <= 10): 74 | return 75 | 76 | stack_num = workspace_num % 10 77 | 78 | # TODO: cleanup repeat code 79 | if workspace.leaves(): 80 | # if workspace not empty, place on opposite end of stack 81 | stack = (w for w in ipc.get_workspaces() 82 | if w.num % 10 == stack_num and w.num != 0) 83 | top_stack = max(stack, key=lambda w: w.num) 84 | top_num = top_stack.num 85 | 86 | ipc.command(f"rename workspace {workspace_num} to {top_num+10}") 87 | 88 | stack = (w for w in ipc.get_workspaces() 89 | if w.num % 10 == stack_num and w.num > 10) 90 | sorted_stack = sorted(stack, key=lambda w: w.num) 91 | 92 | bottom_num = sorted_stack[0].num 93 | ipc.command(f"workspace {bottom_num}") 94 | for ws in sorted_stack: 95 | num = ws.num 96 | ipc.command(f"rename workspace {num} to {num-10}") 97 | 98 | 99 | def rotate_up(workspace): 100 | workspace_num = workspace.num 101 | 102 | # only call from "home row" 103 | if not (workspace_num >= 1 and workspace_num <= 10): 104 | return 105 | 106 | stack_num = workspace_num % 10 107 | 108 | # TODO: cleanup repeat code 109 | if workspace.leaves(): 110 | stack = (w for w in ipc.get_workspaces() 111 | if w.num % 10 == stack_num and w.num != 0) 112 | sorted_stack = sorted(stack, key=lambda w: w.num, reverse=True) 113 | 114 | for ws in sorted_stack: 115 | num = ws.num 116 | ipc.command(f"rename workspace {num} to {num+10}") 117 | 118 | stack = (w for w in ipc.get_workspaces() 119 | if w.num % 10 == stack_num and w.num > 10) 120 | top_stack = max(stack, key=lambda w: w.num) 121 | top_num = top_stack.num 122 | 123 | ipc.command(f"workspace {top_num}") 124 | ipc.command(f"rename workspace {top_num} to {workspace_num}") 125 | 126 | 127 | if __name__ == "__main__": 128 | parser = argparse.ArgumentParser( 129 | description="Workspace stacking, for hoarding workspaces. Requires " 130 | "numerical workspaces 1-10." 131 | ) 132 | action = parser.add_mutually_exclusive_group() 133 | action.add_argument( 134 | "--up", 135 | action="store_true", 136 | help="Push non-empty focused workspace onto stack (default)", 137 | ) 138 | action.add_argument( 139 | "--down", 140 | action="store_true", 141 | help="Pop top of stack onto empty focused workspace", 142 | ) 143 | action.add_argument( 144 | "--rot-down", 145 | action="store_true", 146 | help="Rotate down along the focused workspace stack", 147 | ) 148 | action.add_argument( 149 | "--rot-up", 150 | action="store_true", 151 | help="Rotate up along the focused workspace stack", 152 | ) 153 | args = parser.parse_args() 154 | 155 | ipc = i3ipc.Connection() 156 | focused = ipc.get_tree().find_focused().workspace() 157 | 158 | if args.down: 159 | shift_down(focused) 160 | elif args.rot_down: 161 | rotate_down(focused) 162 | elif args.rot_up: 163 | rotate_up(focused) 164 | else: 165 | shift_up(focused) 166 | -------------------------------------------------------------------------------- /switch-top-level.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import i3ipc 3 | 4 | # 5 | # This script requires i3ipc-python package (install it from a system package manager 6 | # or pip). 7 | # 8 | # The scripts allows you to define two new bindings: 9 | # bindsym $mod+bracketright nop top_next 10 | # bindsym $mod+bracketleft nop top_prev 11 | # 12 | # The purpose of it is to switch between top-level containers (windows) in a workspace. 13 | # One possible usecase is having a workspace with two (or more on large displays) 14 | # columns of tabs: one on left and one on right. In such setup, "move left" and 15 | # "move right" will only switch tabs inside the column. 16 | # 17 | # You can add a systemd user service to run this script on startup: 18 | # 19 | # ~> cat .config/systemd/user/switch-top-level.service 20 | # [Install] 21 | # WantedBy=graphical-session.target 22 | 23 | # [Service] 24 | # ExecStart=path/to/switch-top-level.py 25 | # Restart=on-failure 26 | # RestartSec=1 27 | 28 | # [Unit] 29 | # Requires=graphical-session.target 30 | 31 | 32 | class TopLevelSwitcher: 33 | def __init__(self): 34 | self.top_to_selected = {} # top i3ipc.Con -> selected container id 35 | self.con_to_top = {} # container id -> top i3ipc.Con 36 | self.prev = None # previously focused container id 37 | 38 | self.i3 = i3ipc.Connection() 39 | self.i3.on("window::focus", self.on_window_focus) 40 | self.i3.on(i3ipc.Event.BINDING, self.on_binding) 41 | 42 | self.update_top_level() 43 | self.i3.main() 44 | 45 | def top_level(self, node): 46 | if len(node.nodes) == 1: 47 | return self.top_level(node.nodes[0]) 48 | return node.nodes 49 | 50 | def update_top_level(self): 51 | tree = self.i3.get_tree() 52 | for ws in tree.workspaces(): 53 | for con in self.top_level(ws): 54 | self.update_top_level_rec(con, con.id) 55 | 56 | def update_top_level_rec(self, con: i3ipc.Con, top: i3ipc.Con): 57 | self.con_to_top[con.id] = top 58 | for child in con.nodes: 59 | self.update_top_level_rec(child, top) 60 | 61 | if len(con.nodes) == 0 and top not in self.top_to_selected: 62 | self.top_to_selected[top] = con.id 63 | 64 | def save_prev(self): 65 | if not self.prev: 66 | return 67 | prev_top = self.con_to_top.get(self.prev) 68 | if not prev_top: 69 | return 70 | self.top_to_selected[prev_top] = self.prev 71 | 72 | 73 | def on_window_focus(self, _i3, event): 74 | self.update_top_level() 75 | self.save_prev() 76 | self.prev = event.container.id 77 | 78 | def on_top(self, _i3, _event, diff: int): 79 | root = self.i3.get_tree() 80 | if not self.prev: 81 | return 82 | top = self.con_to_top[self.prev] 83 | ws = [top.id for top in self.top_level(root.find_focused().workspace())] 84 | 85 | top_idx = ws.index(top) 86 | top_idx = (top_idx + diff + len(ws)) % len(ws) 87 | next_top = ws[top_idx] 88 | next_window = self.top_to_selected.get(next_top) 89 | self.i3.command("[con_id=%s] focus" % next_window) 90 | 91 | def on_binding(self, i3, event): 92 | if event.binding.command.startswith("nop top_next"): 93 | self.on_top(i3, event, 1) 94 | elif event.binding.command.startswith("nop top_prev"): 95 | self.on_top(i3, event, -1) 96 | 97 | 98 | if __name__ == "__main__": 99 | TopLevelSwitcher() 100 | --------------------------------------------------------------------------------