Repository: Lyr-7D1h/swayest_workstyle Branch: master Commit: edf294287b23 Files: 15 Total size: 46.7 KB Directory structure: gitextract_gjk0f_2s/ ├── .gitignore ├── .vscode/ │ └── launch.json ├── Cargo.toml ├── LICENSE ├── README.md ├── default_config.toml ├── rust-toolchain ├── rustfmt.toml ├── script/ │ └── release └── src/ ├── config/ │ ├── config_error.rs │ └── parse_content_to_config.rs ├── config.rs ├── lib.rs ├── main.rs └── util.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /target ./tmp ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "lldb", "request": "launch", "name": "Debug executable 'sworkstyle'", "cargo": { "args": ["build", "--bin=sworkstyle", "--package=sworkstyle"], "filter": { "name": "sworkstyle", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}" }, { "type": "lldb", "request": "launch", "name": "Debug unit tests in executable 'sworkstyle'", "cargo": { "args": [ "test", "--no-run", "--bin=sworkstyle", "--package=sworkstyle" ], "filter": { "name": "sworkstyle", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}" } ] } ================================================ FILE: Cargo.toml ================================================ [package] name = "sworkstyle" version = "1.4.0" authors = ["Lyr-7D1h "] edition = "2021" license = "MIT" description = "Workspaces with the swayest style! This program will dynamically rename your workspaces to indicate which programs are running in each workspace. It uses the Sway IPC. In the absence of a config file, one will be generated automatically.See ${XDG_CONFIG_HOME}/workstyle/config.yml for details." repository = "https://github.com/Lyr-7D1h/swayest_workstyle" keywords = ["sway", "wayland"] readme = "README.md" [dependencies] swayipc-async = "3.0.0" async-io = "2.6" futures-lite = "2.6.1" toml = { version = "0.9.8", features = ["preserve_order"] } dirs = "6.0" log = "0.4.29" fslock = "0.2.1" ctrlc = "3.5.2" regex = "1.12.3" simple_logger = "5.1.0" inotify = "0.11" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Ivo Velthoven 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: README.md ================================================ # Swayest Workstyle [![AUR version](https://img.shields.io/aur/version/sworkstyle)](https://aur.archlinux.org/packages/sworkstyle) [![Crates.io](https://img.shields.io/crates/v/sworkstyle)](https://crates.io/crates/sworkstyle) Map workspace name to icons defined depending on the windows inside of the workspace. An executable similar to [workstyle](https://github.com/pierrechevalier83/workstyle). **Differences between `sworkstyle` and `workstyle`:** - Plug-and-play solution, build-in matching config, you can extend this config by creating/modifying `.config/sworkstyle/config.toml` or you can make a PR for your package manager or this repository with new matchers. - Way better matching: using regex, exact app names and generic app titles. - Specifically meant for Sway and Wayland - Fallback Icon - Deduplication Your workspace shall never contain an empty icon again! **An example of what it does (using waybar which also hides the workspace index):**
## Installation ### Cargo ```bash cargo install sworkstyle ``` ### Arch Linux You can install it manually or use a aur helper like Yay. ```bash yay -S sworkstyle ``` ## Usage ```bash sworkstyle ``` ## Sway Configuration Add the follow line to your sway config file (`~/.config/sway/config`). ```bash exec sworkstyle &> /tmp/sworkstyle.log ``` > **_NOTE:_** When using the cargo install make sure to add the `.cargo/bin` to the `PATH` environment variable before executing sway. You can do this by adding `export PATH="$HOME/.cargo/bin:$PATH"` to `.zprofile` or `.profile` You should configure anything mentioning a workspace (assign, keybinding) to use numbered workspaces. This is because sworkstyle will rename your workspaces many times so it needs a constant number that doesn't change in order to work correctly. Prefer ```bash assign [class="^Steam$"] number 1 bindsym $mod+1 workspace number 1 ``` over ```bash assign [class="^Steam$"] 1 bindsym $mod+1 workspace 1 ``` ## Sworkstyle Configuration The main configuration consists of deciding which icons to use for which applications. The config file is located at `${XDG_CONFIG_HOME}/sworkstyle/config.toml`. Its values will take precedence over the defaults. The syntax is in TOML and should be pretty self-explanatory. When an app isn't recognized in the config, `sworkstyle` will log the application name as a warning. Simply add that string to your config file, with an icon of your choice. Note that the crate [find_unicode](https://github.com/pierrechevalier83/find_unicode/) can help find a unicode character directly from the command line. It now supports all of nerdfonts unicode space. For a reference to the regex syntax see the [regex](https://docs.rs/regex/1.5.4/regex/#syntax) crate ### Matching #### Standard ```toml '{pattern}' = '{icon}' # pattern: Can either be the exact "app_name" (app_id/class) of the window or a regex string in the format of `"/{regex}/"` which will match the window "title". # icon: Your beautiful icon ``` #### Verbose ```toml '{pattern}' = { type = 'generic' | 'exact', value = '{icon}' } ``` #### Combined Fields ```toml '{name}' = { app_id = 'steam', title = '/Eve/', value = '{icon}' } ``` `{name}` is only a TOML key/identifier for the entry. It is not used for matching logic. Use any descriptive unique name (for example `steam_eve` or `browser_github`). All specified fields must match. You can combine any of `app_id`, `class`, and `title`. `title` supports the same matching behavior as generic rules (substring or regex with `/.../`). `app_id` and `class` support exact string matching or regex when wrapped in `/.../`. _**Note:**_ You'll only have to use the verbose format when matching generic with a case insensitive text. `'case insensitive title' = { type = 'generic', value = 'A' }` #### Troubleshooting If it couldn't match something it will print: ``` WARN [sworkstyle:config] No match for app_id="{app_id}" class="{class}" title="{title}" ``` You can use {title} to do a generic matching You can use {app_name} to do an exact match ### Default Config The default config uses [font-awesome](https://fontawesome.com/) for icon mappinigs. The default config is always appended to whatever custom config you define. You can overwrite any matching or make a PR if you feel like a matching should be a default. ```toml fallback = '' separator = ' ' [matching] 'discord' = '' 'balena-etcher' = '' 'Chia Blockchain' = '' 'Steam' = '' 'vlc' = '' 'org.qbittorrent.qBittorrent' = '' 'Thunderbird' = '' 'thunderbird' = '' 'Postman' = '' 'Insomnia' = '' 'Bitwarden' = '' 'Google-chrome' = '' 'google-chrome' = '' 'Chromium' = '' 'Slack' = '' 'Code' = '' 'code-oss' = '' 'jetbrains-studio' = '' 'Spotify' = '' 'GitHub Desktop' = '' '/(?i)Github.*Firefox/' = '' 'firefox' = '' 'Nightly' = '' 'firefoxdeveloperedition' = '' '/nvim ?\w*/' = '' '/npm/' = '' '/node/' = '' '/yarn/' = '' 'Alacritty' = '' ``` ## Package Maintainers If you want to change the build-in config, change `src/default_config.toml` with your config and install the project. You might also want [font-awesome](https://fontawesome.com/) as a dependency depending on your config. You can also make a PR to add a badge and add your install method under #Installation or to add matchers to the build-in config. See [aur](https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=sworkstyle) for an example build. ## Roadmap - An `--unique` param where you only have a single icon per workspace based on the matching with biggest priority. ## Known Issues - Using sway's alt-tab behavior can cause a workspace to be not named - Does not work on hyprland, use this instead: https://github.com/hyprland-community/hyprland-autoname-workspaces ================================================ FILE: default_config.toml ================================================ # Config for sworkstyle # # You can rename workspaces based on the exact application names or by generic pattern. # When it could not match anything it will use the fallback. # # format: # # '{pattern}' = '{icon}' # # pattern: Can either be the exact "app_name" (app_id/class) of the window or a regex string in the format of `"/{regex}/"` which will match the window "title". # icon: Your beatifull icon # # verbose format: # # '{pattern}' = { type = 'generic' | 'exact', value = '{icon}' } # # combined window matcher format: # # '{name}' = { app_id = 'steam', title = '/Eve/', value = '{icon}' } # # It matches when all specified fields match. You can combine any of app_id, class and title. # # # If it couldn't match something it will print: # # WARN [sworkstyle:config] No match for app_id="{app_id}" class="{class}" title="{title}" # # You can use {title} to do a generic matching # You can use {app_id} to do an exact match fallback = '' separator = ' ' [matching] 'discord' = '' 'WebCord' = '' 'vesktop' = '' 'Element' = '' 'Signal' = '' 'balena-etcher' = '' 'Chia Blockchain' = '' 'Steam' = '' 'vlc' = '' 'mpv' = '' 'Gimp' = '' 'darktable' = '' 'org.kde.digikam' = '' 'pavucontrol' = '' 'org.gnome.Nautilus' = '' 'eog' = '' 'org.qbittorrent.qBittorrent' = '' 'Thunderbird' = '' 'thunderbird' = '' 'Postman' = '' 'Insomnia' = '' 'Bitwarden' = '' 'Google-chrome' = '' 'google-chrome' = '' 'Chromium' = '' 'Slack' = '' 'Code' = '' 'code-oss' = '' 'Emacs' = '' 'org.gnome.Calculator' = '' 'jetbrains-studio' = '' "transmission-remote-gtk" = "" 'Spotify' = '' 'spotify' = '' 'GitHub Desktop' = '' '/(?i)^Github.*Firefox/' = '' 'firefox' = '' 'Nightly' = '' 'firefoxdeveloperedition' = '' '/nvim ?\w*/' = '' '/npm/' = '' '/node/' = '' '/yarn/' = '' 'Alacritty' = '' 'foot' = '' 'kitty' = '' 'VirtualBox Manager' = '' 'VirtualBox Machine' = '' 'VirtualBox' = '' 'openscad' = '' "org.freecadweb.FreeCAD"= "" "com/.https://ultimaker.UltiMaker-Cura"= "" "remarkhub" = "" ================================================ FILE: rust-toolchain ================================================ stable ================================================ FILE: rustfmt.toml ================================================ edition = "2021" ================================================ FILE: script/release ================================================ #!/usr/bin/env python3 # # A script that detect packages and creates a new release for them. # It makes a lot of assumptions so make sure all url's given in the # output makes sense before approving a release. # from abc import ABCMeta, abstractmethod from genericpath import exists import json from os import chdir import os import re from shutil import rmtree import subprocess import sys from tempfile import mkdtemp from typing import Dict, List, NoReturn, Optional from urllib.request import Request, urlopen def error(*args) -> NoReturn: print("\033[91m" + " ".join(args) + "\033[0m", file=sys.stderr) exit(1) def warn(*args) -> None: print("\033[93m" + " ".join(args) + "\033[0m", file=sys.stderr) def debug(*args: str) -> None: print("\033[94m" + " ".join(args) + "\033[0m") def info(*args: str) -> None: print("\033[92m" + " ".join(args) + "\033[0m") def exec(command: str) -> str: debug(f"Executing '{command}'") res = subprocess.run(command, capture_output=True, shell=True) if res.returncode != 0: error( f"Command failed with code {res.returncode}\n", res.stderr.decode("utf-8") ) return res.stdout.decode("utf-8").rstrip() def request(url: str, headers: Optional[Dict[str, str]] = None) -> str: if headers == None: headers = {} req = Request(url, headers=headers) with urlopen(req) as response: if response.status != 200: error(f"{url} returned {response.status}") return response.read().decode("utf-8") HOME = os.environ["HOME"] VERSION = sys.argv[1] pattern = "^[0-9]*\\.[0-9]*\\.[0-9]*$" if re.match(pattern, VERSION) == None: error("No version given") class Module(metaclass=ABCMeta): def name(self) -> str: return type(self).__name__ @abstractmethod def should_load(self) -> bool: "Should this module load" @abstractmethod def link(self) -> Optional[str]: """Give a link to the project""" @abstractmethod def validate(self) -> None: """Validate that the module has everything it needs to release, to ensure successful release""" def pre_release(self) -> None: pass @abstractmethod def release(self) -> None: """Make release on given module""" class Github(Module): def should_load(self): url = exec("git remote get-url origin") return "github.com" in url def link(self): return exec("gh browse -n") def validate(self): exec("gh auth status") def release(self): notes = input("\033[92mGithub Release notes: \033[0m") exec(f"gh release create {VERSION} --notes '{notes}'") class Aur(Module): def should_load(self): repo_name = exec("git remote get-url origin") git_username = exec("git config --get user.name") repo_name = repo_name.split("/")[-1].split(".")[0] data = request( f"https://aur.archlinux.org/rpc/?v=5&type=search&arg={repo_name}" ) data = json.loads(data) results = data["results"] filtered_results = [] for res in results: if res["Maintainer"].lower() == git_username.lower(): filtered_results.append(res) # Sort by popularity filtered_results.sort(key=lambda r: r["Popularity"]) if len(filtered_results) == 0: return False self.package = filtered_results[0].get("Name") self.maintainer = filtered_results[0].get("Maintainer") return True def link(self): return f"https://aur.archlinux.org/packages/{self.package}" def validate(self): # Check if can push to AUR tmp = mkdtemp() root_path = os.getcwd() chdir(tmp) exec(f"git clone ssh://aur@aur.archlinux.org/{self.package}.git .") exec("git push --dry-run") exec("makepkg --help") chdir(root_path) rmtree(tmp) def _sha256sums(self, pkgbuild: str) -> str: debug("Generating sha256sums") pkgbuild_lines = pkgbuild.splitlines() sha_lines = [] # all lines containing sha's check_ending = False for i, line in enumerate(pkgbuild_lines): if check_ending: sha_lines.append(i) if ")" in line: check_ending = False if line.startswith("sha256sums="): sha_lines.append(i) if ")" in line: break check_ending = True sum = exec("makepkg -g") # generate new sums # remove old sums sha_lines.reverse() # remove from big to small for i in sha_lines: del pkgbuild_lines[i] pkgbuild = "" # inject new sum in new pkgbuild for i, line in enumerate(pkgbuild_lines): if i == sha_lines[0] - 1: pkgbuild += sum + "\n" pkgbuild += line + "\n" return pkgbuild.rstrip() def release(self): tmp = mkdtemp() debug(f"Created {tmp}") root_path = os.getcwd() chdir(tmp) exec(f"git clone ssh://aur@aur.archlinux.org/{self.package}.git .") with open("PKGBUILD", "r") as file: pkgbuild: str = file.read() pkgbuild = re.sub( "^pkgver\\s*=.*", f"pkgver={VERSION}", pkgbuild, 1, re.MULTILINE ) pkgbuild = re.sub("^pkgrel\\s*=.*", f"pkgrel=1", pkgbuild, 1, re.MULTILINE) # write before generating sums with open("PKGBUILD", "w") as file: file.write(pkgbuild) if re.search("^sha256sums\\s*=", pkgbuild, re.MULTILINE): pkgbuild = self._sha256sums(pkgbuild) # write with sums with open("PKGBUILD", "w") as file: file.write(pkgbuild) exec("makepkg --printsrcinfo > .SRCINFO") exec("makepkg --check") # Ensure install works exec("git add PKGBUILD .SRCINFO") exec(f"git commit -m 'Release {VERSION}'") exec("git push") chdir(root_path) rmtree(tmp) class Cargo(Module): def should_load(self): return exists("Cargo.toml") def link(self): with open("Cargo.toml", "r") as file: name = re.search("^name\\s*=.*", file.read(), re.MULTILINE) if name is None: error("Could not find crate name") name = str(name.group(0)).split("=")[1].replace(" ", "").replace('"', "") return f"https://crates.io/crates/{name}" def validate(self): if not exists(f"{HOME}/.cargo/credentials"): error(f"{HOME}/.cargo/credentials does not exist") exec("cargo test") exec("cargo build --release --locked") exec("cargo publish --dry-run") def pre_release(self) -> None: debug("Updating version in Cargo.toml") with open("Cargo.toml", "r") as file: cargo_toml: str = file.read() cargo_toml = re.sub( "^version\\s*=.*", f'version = "{VERSION}"', cargo_toml, 1, re.MULTILINE ) with open("Cargo.toml", "w") as file: file.write(cargo_toml) if exec("git diff") != "": # Update lock file exec("cargo build --release --offline") exec("git add Cargo.toml Cargo.lock") exec(f"git commit -m 'Release {VERSION}'") exec("git push") def release(self): exec(f"cargo publish") def prepare_branch() -> str: """Returns the remote""" branch = "master" if "master" not in exec("git branch"): branch = "main" remote = exec(f"git config branch.{branch}.remote") exec(f"git switch {branch}") exec(f"git pull {remote} {branch}") exec(f"git push -u {remote} {branch}") return remote DEFAULT_MODULES = [Github(), Cargo(), Aur()] def release(): root = exec("git rev-parse --show-toplevel") debug(f"Moving to root '{root}'") chdir(root) if exec("git status --short") != "": error("Git branch is dirty") remote = prepare_branch() modules: List[Module] = [] for module in DEFAULT_MODULES: if module.should_load(): debug(f"Found {module.name()}") modules.append(module) for module in modules: module.validate() for module in modules: info(f"Will release on {module.name()} ({module.link()})") answer = input("\033[92mProceed with release? [Y/n] \033[0m") if answer.lower() != "y": error("") for module in modules: module.pre_release() exec(f"git tag {VERSION}") exec(f"git push {remote} --tags") for module in modules: module.release() info(f"Release {VERSION} finished") release() ================================================ FILE: src/config/config_error.rs ================================================ use std::{error::Error, fmt::Display}; #[derive(Debug)] pub struct ConfigError { message: String, } impl ConfigError { pub fn new>(message: S) -> ConfigError { ConfigError { message: message.into(), } } } impl<'n> Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return write!(f, "{}", self.message); } } impl From for ConfigError { fn from(e: toml::de::Error) -> Self { ConfigError::new(e.to_string()) } } impl Error for ConfigError {} ================================================ FILE: src/config/parse_content_to_config.rs ================================================ use std::convert::TryFrom; use toml::Value; use super::{config_error::ConfigError, Config, Match, Pattern}; fn parse_pattern_field( table: &toml::map::Map, key: &str, ) -> Result, ConfigError> { match table.get(key) { Some(value) => { let value = value .as_str() .ok_or(ConfigError::new(format!("Value of {key} is not a string")))?; Ok(Some(Pattern::try_from(value.to_string()).map_err(|e| { ConfigError::new(format!("Invalid pattern given for '{value}': {e}")) })?)) } None => Ok(None), } } /// Parse toml config content to icon_map pub fn parse_content_to_config(content: &String) -> Result { let map: Value = toml::from_str(content)?; let map_to_match = |k: (&String, &Value)| -> Result { if let Some(value) = k.1.as_str() { let value = value.to_string(); let pattern = Pattern::try_from(k.0.to_string()).map_err(|e| ConfigError::new(format!( "Invalid pattern given for '{}': {}", k.0, e )))?; match pattern { Pattern::Regex(_) => return Ok(Match::Generic { pattern, value }), Pattern::String(pattern) => return Ok(Match::Exact { pattern, value }), }; } if let Some(table) = k.1.as_table() { let value = table .get("value") .ok_or(ConfigError::new(format!("Could not parse: {}", k.0)))? .as_str() .ok_or(ConfigError::new(format!( "Value of {} is not a string", k.0 )))? .to_string(); if let Some(match_type) = table.get("type") { let match_type = match_type.as_str().ok_or(ConfigError::new(format!( "Value of {} is not a string", k.0 )))?; let m = match &match_type[..] { "exact" => Match::Exact { pattern: k.0.to_string(), value, }, "generic" => Match::Generic { pattern: Pattern::try_from(k.0.to_string()).map_err(|e| ConfigError::new( format!("Invalid pattern given for '{}': {}", k.0, e), ))?, value, }, _ => return Err(ConfigError::new(format!("Invalid match type: {}", k.1))), }; return Ok(m); } let app_id = parse_pattern_field(table, "app_id")?; let class = parse_pattern_field(table, "class")?; let title = parse_pattern_field(table, "title")?; if app_id.is_none() && class.is_none() && title.is_none() { return Err(ConfigError::new(format!( "Could not parse: {}. Expected one of app_id, class or title", k.0 ))); } return Ok(Match::Window { app_id, class, title, value, }); } Err(ConfigError::new(format!( "{} could not be parsed as a table", k.1 ))) }; match map { Value::Table(root) => { let matching: Vec = root .get("matching") .ok_or(ConfigError::new("Matching table not found"))? .as_table() .ok_or(ConfigError::new("Could not parse matching table"))? .iter() .map(map_to_match) .collect::, ConfigError>>()? .into_iter() .collect(); let fallback: Option = match root.get("fallback") { Some(value) => { let f = value .as_str() .ok_or(ConfigError::new("Fallback is not a string"))?; Some(f.to_string()) } None => None, }; let separator: Option = match root.get("separator") { Some(value) => { let f = value .as_str() .ok_or(ConfigError::new("Separator is not a string"))?; Some(f.to_string()) } None => None, }; Ok(Config { matchings: matching, fallback, separator, }) } _ => Err(ConfigError::new("No root table found")), } } #[test] fn test_parse_content_to_config() { use regex::Regex; let no_match_table = parse_content_to_config(&String::from("fallback = 'c'")); assert_eq!( no_match_table.unwrap_err().to_string(), "Matching table not found" ); let content = " [matching] a = b "; let invalid_match = parse_content_to_config(&content.to_string()); let e = invalid_match.unwrap_err(); assert!( e.to_string().starts_with("TOML parse error"), "error message not as expected: {e:?}" ); let content = " [matching] 'fdsa' = 'a' '/asdf/' = 'b' test = { type = 'generic', value = 'c' } qwer = { type = 'exact', value = 'd' } "; let icon_map = parse_content_to_config(&content.to_string()).unwrap(); assert_eq!( icon_map.matchings[0], Match::Exact { value: "a".to_string(), pattern: "fdsa".to_string() } ); assert_eq!( icon_map.matchings[1], Match::Generic { value: "b".to_string(), pattern: Pattern::Regex(Regex::new("asdf").unwrap()) } ); assert_eq!( icon_map.matchings[2], Match::Generic { value: "c".to_string(), pattern: Pattern::String("test".to_string()) } ); assert_eq!( icon_map.matchings[3], Match::Exact { value: "d".to_string(), pattern: "qwer".to_string() } ); let content = " [matching] steam_eve = { app_id = 'steam', title = '/Eve/', value = 'steam-icon' } "; let icon_map = parse_content_to_config(&content.to_string()).unwrap(); assert_eq!( icon_map.matchings[0], Match::Window { app_id: Some(Pattern::String("steam".to_string())), class: None, title: Some(Pattern::Regex(Regex::new("Eve").unwrap())), value: "steam-icon".to_string(), } ); } ================================================ FILE: src/config.rs ================================================ use std::{convert::TryFrom, fs::read_to_string, path::Path, str::from_utf8}; use log::{debug, error, info, warn}; use regex::Regex; use crate::util::prettify_option; mod config_error; mod parse_content_to_config; use parse_content_to_config::parse_content_to_config; pub const DEFAULT_MATCH_CONFIG: &'static [u8] = include_bytes!("../default_config.toml"); #[derive(Clone, Debug)] pub enum Pattern { Regex(Regex), String(String), } impl PartialEq for Pattern { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::String(r0), Self::Regex(l0)) => l0.to_string() == r0.to_string(), (Self::Regex(l0), Self::String(r0)) => l0.to_string() == r0.to_string(), (Self::Regex(l0), Self::Regex(r0)) => l0.to_string() == r0.to_string(), (Self::String(l0), Self::String(r0)) => l0 == r0, } } } impl TryFrom for Pattern { type Error = regex::Error; fn try_from(mut value: String) -> Result { if value.starts_with("/") && value.ends_with("/") { value.remove(value.len() - 1); value.remove(0); let regex = Regex::new(&value)?; Ok(Pattern::Regex(regex)) } else { Ok(Pattern::String(value)) } } } #[derive(Clone, Debug, PartialEq)] pub enum Match { Generic { pattern: Pattern, value: String }, Exact { pattern: String, value: String }, Window { app_id: Option, class: Option, title: Option, value: String, }, } #[derive(Clone, Debug)] pub struct Config { pub matchings: Vec, pub fallback: Option, pub separator: Option, } impl Config { pub fn new>(config_path: &Option

) -> Config { if let Some(config_path) = config_path { match read_to_string(&config_path) { Ok(content) => return Config::from(content), Err(e) => { debug!( "Could not create config from path: {:?} {e}", config_path.as_ref() ) } } } else { warn!("Default config could not have been found") } return Config::default(); } fn matches_generic_pattern(pattern: &Pattern, value: &String) -> bool { match pattern { Pattern::Regex(r) => r.is_match(value), Pattern::String(p) => value.to_lowercase().contains(&p.to_lowercase()), } } fn matches_exact_pattern(pattern: &Pattern, value: &String) -> bool { match pattern { Pattern::Regex(r) => r.is_match(value), Pattern::String(p) => value == p, } } pub fn fetch_icon( &self, app_id: Option<&String>, class: Option<&String>, title: Option<&String>, ) -> String { for m in &self.matchings { match m { Match::Generic { pattern, value } => { if let Some(title) = &title { if Self::matches_generic_pattern(pattern, title) { return value.clone(); } } } Match::Exact { pattern, value } => { if let Some(class_name) = class { if class_name == pattern { return value.clone(); } } if let Some(app_id_name) = app_id { if app_id_name == pattern { return value.clone(); } } } Match::Window { app_id: app_id_pattern, class: class_pattern, title: title_pattern, value, } => { let app_id_matches = app_id_pattern.as_ref().map_or(true, |pattern| { app_id .as_ref() .is_some_and(|app_id| Self::matches_exact_pattern(pattern, app_id)) }); let class_matches = class_pattern.as_ref().map_or(true, |pattern| { class .as_ref() .is_some_and(|class| Self::matches_exact_pattern(pattern, class)) }); let title_matches = title_pattern.as_ref().map_or(true, |pattern| { title .as_ref() .is_some_and(|title| Self::matches_generic_pattern(pattern, title)) }); if app_id_matches && class_matches && title_matches { return value.clone(); } } } } warn!( "No match for app_id=\"{}\" class=\"{}\" title=\"{}\"", prettify_option(app_id), prettify_option(class), prettify_option(title), ); self.fallback() } pub fn fallback(&self) -> String { match &self.fallback { Some(fallback) => { info!("Using fallback: {}", fallback); fallback.clone() } None => { warn!("No fallback set using empty string"); String::from("") } } } } impl> From for Config { /// Parse a string to a config enriching it with the default config fn from(value: S) -> Self { let value = value.into(); let mut default = Config::default(); match parse_content_to_config(&value) { Ok(mut user_config) => { user_config.matchings.append(&mut default.matchings); if user_config.separator.is_none() { user_config.separator = default.separator } if user_config.fallback.is_none() { warn!( "No fallback set using default: {}", prettify_option(default.fallback.as_ref()) ); user_config.fallback = default.fallback } user_config } Err(e) => { error!("Invalid config format: {}", e); return default; } } } } impl Default for Config { fn default() -> Self { let default_config_content = from_utf8(DEFAULT_MATCH_CONFIG).unwrap().to_string(); return parse_content_to_config(&default_config_content).unwrap(); } } #[test] fn test_default() { let config = Config::default(); assert_eq!(config.fallback.unwrap(), "") } #[test] fn test_from_string() { let config = Config::from( " fallback = 'c' [matching] a = 'b' b = 'c' '/(?i)A title/' = 'd' ", ); assert_eq!(config.fallback(), "c"); assert_eq!( config.fetch_icon( Some(&String::from("application")), None, Some(&String::from("a title")) ), "d" ); } #[test] fn test_window_match() { let config = Config::from( " fallback = 'x' [matching] steam_eve = { app_id = 'steam', title = '/Eve/', value = 's' } ", ); assert_eq!( config.fetch_icon( Some(&String::from("steam")), None, Some(&String::from("Eve Online")) ), "s" ); assert_eq!( config.fetch_icon( Some(&String::from("steam")), None, Some(&String::from("Counter-Strike")) ), "x" ); } ================================================ FILE: src/lib.rs ================================================ use futures_lite::prelude::*; use async_io::Async; use futures_lite::stream; use inotify::{Inotify, WatchMask}; use std::{ collections::BTreeSet, error::Error, path::{Path, PathBuf}, }; use log::{debug, error, info, warn}; use swayipc_async::{Connection, Event, EventType, Node, NodeType, WindowChange}; pub mod config; mod util; use config::Config; pub type SworkstyleError = Box; struct ConfigSource { path: PathBuf, inotify: Inotify, } impl ConfigSource { fn new(path: impl AsRef) -> ConfigSource { let inotify = Inotify::init().expect("Error while initializing inotify instance"); inotify .watches() .add(&path, WatchMask::CLOSE_WRITE) .expect("Failed to watch config file"); ConfigSource { path: path.as_ref().to_path_buf(), inotify, } } } pub struct Sworkstyle { config: Config, config_source: Option, deduplicate: bool, } impl Sworkstyle { pub fn new>(config_path: Option

, deduplicate: bool) -> Sworkstyle { let config = Config::new(&config_path); let config_source = config_path.and_then(|path| path.as_ref().exists().then(|| ConfigSource::new(path))); Sworkstyle { config, config_source, deduplicate, } } // Takes `self` by value because we consume `config_source`. pub async fn run(mut self) -> Result<(), SworkstyleError> { enum Message { Event(Event), Config(Config), } let mut events = Connection::new() .await? .subscribe(&[EventType::Window]) .await? .map(|r| r.map(Message::Event)) .boxed(); let mut connection = Connection::new().await?; if let Some(source) = self.config_source.take() { events = events .or(stream::try_unfold(source, |source| async { let path = source.path; let anotify = Async::new(source.inotify)?; anotify.readable().await?; let mut inotify = anotify.into_inner()?; let mut inotify_events_buffer = [0; 1024]; inotify.read_events(&mut inotify_events_buffer)?; info!("Detected config change, reloading config.."); let config = Config::new(&Some(&path)); // Reset watcher inotify .watches() .add(&path, WatchMask::CLOSE_WRITE) .expect("Failed to watch config file"); Ok(Some(( Message::Config(config), ConfigSource { path, inotify }, ))) })) .boxed(); } if let Err(e) = self.update_workspaces(&mut connection).await { error!("Could not initialize workspace name: {}", e); } while let Some(msg) = events.next().await { match msg { Ok(Message::Event(Event::Window(e))) => { if matches!( e.change, WindowChange::Focus | WindowChange::FullscreenMode | WindowChange::Floating | WindowChange::Urgent | WindowChange::Mark ) { // Event not relevant to us: skip the update_workspaces_call below. continue; } } // Should not be reachable: we are only subscribed to window events. Ok(Message::Event(_)) => {} Ok(Message::Config(config)) => { self.config = config; } Err(e) => { warn!("Error while waiting for Sway or config events, exiting: {e}"); return Err(Box::new(e)); } } if let Err(e) = self.update_workspaces(&mut connection).await { error!("Could not update workspace name: {}", e); } } Ok(()) } async fn update_workspaces(&self, conn: &mut Connection) -> Result<(), SworkstyleError> { let tree = conn.get_tree().await?; let mut workspaces = vec![]; get_workspaces_recurse(&tree, &mut workspaces); for workspace in workspaces { self.update_workspace_name(conn, workspace).await?; } Ok(()) } async fn update_workspace_name( &self, conn: &mut Connection, workspace: &Node, ) -> Result<(), SworkstyleError> { let mut windows = vec![]; get_windows(workspace, &mut windows); let mut window_names: Vec<(Option<&String>, Option<&String>, Option)> = windows .iter() .map(|node| { // Wayland Exact app let app_id = node.app_id.as_ref(); // X11 Exact let class = node.window_properties.as_ref().and_then(|props| props.class.as_ref()); (app_id, class, node.name.clone()) }) .collect(); if self.deduplicate { window_names = window_names .into_iter() .collect::, Option<&String>, Option)>>() .into_iter() .collect(); } let mut icons: Vec = window_names .into_iter() .map(|(app_id, class, title)| { if app_id.is_none() && class.is_none() { error!("No app_id/class found for window with title={:?}", title); } self.config.fetch_icon(app_id, class, title.as_ref()) }) .filter(|icon| !icon.is_empty()) // Overwrite right to left characters: https://www.unicode.org/versions/Unicode12.0.0/UnicodeStandard-12.0.pdf#G26.16327 .map(|icon| format!("\u{202D}{icon}\u{202C}")) .collect(); let name = match &workspace.name { Some(name) => name, None => { return Err( format!("Could not get name for workspace with id: {}", workspace.id).into(), ) } }; let index = match workspace.num { Some(num) => num, None => return Err(format!("Could not fetch index for: {}", name).into()), }; if self.deduplicate { icons.dedup(); } let delim = self.config.separator.as_deref().unwrap_or(" "); let mut icons = icons.join(delim); if icons.len() > 0 { icons.push_str(" ") } let new_name = if icons.len() > 0 { format!("{}: {}", index, icons) } else if let Some(num) = workspace.num { format!("{}", num) } else { error!("Could not fetch workspace num for: {:?}", workspace.name); " ".to_string() }; if *name != new_name { debug!("rename workspace \"{}\" to \"{}\"", name, new_name); conn.run_command(format!("rename workspace \"{}\" to \"{}\"", name, new_name)) .await?; } return Ok(()); } } fn get_workspaces_recurse<'a>(node: &'a Node, workspaces: &mut Vec<&'a Node>) { if node.node_type == NodeType::Workspace && node.name != Some("__i3_scratch".to_string()) { workspaces.push(node); return; } for child in node.nodes.iter() { get_workspaces_recurse(child, workspaces) } } /// Rescursively add nodes with node type floatingCon and con to windows fn get_windows<'a>(node: &'a Node, windows: &mut Vec<&'a Node>) { if node.node_type == NodeType::FloatingCon || node.node_type == NodeType::Con { if let Some(_) = node.name { windows.push(node) } }; for node in node.nodes.iter().chain(node.floating_nodes.iter()) { get_windows(node, windows); } } ================================================ FILE: src/main.rs ================================================ use std::process; use sworkstyle::Sworkstyle; use fslock::LockFile; use log::{debug, error}; use simple_logger::SimpleLogger; use std::{env, path::PathBuf}; use log::LevelFilter; const VERSION: &str = env!("CARGO_PKG_VERSION"); pub struct Args { pub log_level: LevelFilter, pub config_path: Option, pub deduplicate: bool, } /// Get the xdg default config path fn default_config_path() -> Option { Some(dirs::config_dir()?.join("sworkstyle/config.toml")) } impl Args { pub fn from_cli() -> Args { let mut log_level = LevelFilter::Warn; let mut config_path = default_config_path(); let mut deduplicate = false; let mut args = env::args().skip(1); while let Some(arg) = args.next() { match &arg[..] { "-h" | "--help" => { println!( "Swayest Workstyle v{VERSION} This tool will rename workspaces to the icons configured. Config can be found in $HOME/.config/sworkstyle SYNOPSIS sworkstyle [FLAGS] FLAGS -h, --help Display a description of this program. -v, --version Print the current version -l, --log-level Either \"error\", \"warn\", \"info\", \"debug\", \"off\". Uses \"warn\" by default -c, --config Specifies the config file to use. Uses \"`XDG_CONFIG_HOME`/sworkstyle/config\" by default -d, --deduplicate Deduplicate the same icons in your workspace " ); process::exit(0); } "-v" | "--version" => { println!("{VERSION}"); process::exit(0) } "-l" | "--log-level" => { if let Some(level) = args.next() { log_level = match &level[..] { "error" => LevelFilter::Error, "warn" => LevelFilter::Warn, "info" => LevelFilter::Info, "debug" => LevelFilter::Debug, "off" => LevelFilter::Off, _ => { eprintln!("Invalid logging option: {}", level); process::exit(1); } } } else { eprintln!("No logging option given"); process::exit(1); } } "-c" | "--config" => { if let Some(path) = args.next() { let path = PathBuf::from(path); if !path.exists() { eprintln!("Config file does not exist or couldn't be accessed"); process::exit(1); } config_path = Some(path); } else { eprintln!("No path given"); process::exit(1); } } "-d" | "--deduplicate" => { deduplicate = true; } _ => { eprintln!("Did not recognize \"{}\" as an option", arg); process::exit(1); } } } return Args { log_level, config_path, deduplicate, }; } } fn acquire_lock() { let mut file = match LockFile::open("/tmp/sworkstyle.lock") { Ok(f) => f, _ => return, }; let locked = file.try_lock().unwrap(); if locked == false { error!("Sworkstyle already running"); process::exit(1) } ctrlc::set_handler(move || { debug!("Unlocking /tmp/sworkstyle.lock"); file.unlock().unwrap(); process::exit(0) }) .expect("Could not set ctrlc handler") } fn main() { let args = Args::from_cli(); SimpleLogger::new() .with_level(args.log_level) .init() .expect("Could not load simple logger"); acquire_lock(); let app = Sworkstyle::new(args.config_path, args.deduplicate); if let Err(e) = async_io::block_on(app.run()) { error!("{e}"); process::exit(1) } } ================================================ FILE: src/util.rs ================================================ use std::fmt::Display; /// Map an option to a printable string pub fn prettify_option(option: Option) -> String { match option { Some(a) => a.to_string(), None => "-".to_string(), } }