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 <lyr-7d1h@pm.me>"]
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
[](https://aur.archlinux.org/packages/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):**
<img src="./screenshots/bar.png">
<br />
<img src="./screenshots/desktop.png" width="1000">
## 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<S: Into<String>>(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<toml::de::Error> 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<String, Value>,
key: &str,
) -> Result<Option<Pattern>, 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<Config, ConfigError> {
let map: Value = toml::from_str(content)?;
let map_to_match = |k: (&String, &Value)| -> Result<Match, ConfigError> {
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<Match> = 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::<Result<Vec<Match>, ConfigError>>()?
.into_iter()
.collect();
let fallback: Option<String> = 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<String> = 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<String> for Pattern {
type Error = regex::Error;
fn try_from(mut value: String) -> Result<Self, Self::Error> {
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<Pattern>,
class: Option<Pattern>,
title: Option<Pattern>,
value: String,
},
}
#[derive(Clone, Debug)]
pub struct Config {
pub matchings: Vec<Match>,
pub fallback: Option<String>,
pub separator: Option<String>,
}
impl Config {
pub fn new<P: AsRef<Path>>(config_path: &Option<P>) -> 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<S: Into<String>> From<S> 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<dyn Error>;
struct ConfigSource {
path: PathBuf,
inotify: Inotify,
}
impl ConfigSource {
fn new(path: impl AsRef<Path>) -> 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<ConfigSource>,
deduplicate: bool,
}
impl Sworkstyle {
pub fn new<P: AsRef<Path>>(config_path: Option<P>, 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<String>)> = 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::<BTreeSet<(Option<&String>, Option<&String>, Option<String>)>>()
.into_iter()
.collect();
}
let mut icons: Vec<String> = 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<PathBuf>,
pub deduplicate: bool,
}
/// Get the xdg default config path
fn default_config_path() -> Option<PathBuf> {
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 <level>
Either \"error\", \"warn\", \"info\", \"debug\", \"off\". Uses \"warn\" by default
-c, --config <file>
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<T: Display>(option: Option<T>) -> String {
match option {
Some(a) => a.to_string(),
None => "-".to_string(),
}
}
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
SYMBOL INDEX (41 symbols across 6 files)
FILE: src/config.rs
constant DEFAULT_MATCH_CONFIG (line 13) | pub const DEFAULT_MATCH_CONFIG: &'static [u8] = include_bytes!("../defau...
type Pattern (line 16) | pub enum Pattern {
type Error (line 33) | type Error = regex::Error;
method try_from (line 35) | fn try_from(mut value: String) -> Result<Self, Self::Error> {
method eq (line 22) | fn eq(&self, other: &Self) -> bool {
type Match (line 48) | pub enum Match {
type Config (line 60) | pub struct Config {
method new (line 67) | pub fn new<P: AsRef<Path>>(config_path: &Option<P>) -> Config {
method matches_generic_pattern (line 85) | fn matches_generic_pattern(pattern: &Pattern, value: &String) -> bool {
method matches_exact_pattern (line 92) | fn matches_exact_pattern(pattern: &Pattern, value: &String) -> bool {
method fetch_icon (line 99) | pub fn fetch_icon(
method fallback (line 165) | pub fn fallback(&self) -> String {
method from (line 181) | fn from(value: S) -> Self {
method default (line 212) | fn default() -> Self {
function test_default (line 219) | fn test_default() {
function test_from_string (line 225) | fn test_from_string() {
function test_window_match (line 248) | fn test_window_match() {
FILE: src/config/config_error.rs
type ConfigError (line 4) | pub struct ConfigError {
method new (line 9) | pub fn new<S: Into<String>>(message: S) -> ConfigError {
method from (line 23) | fn from(e: toml::de::Error) -> Self {
method fmt (line 17) | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
FILE: src/config/parse_content_to_config.rs
function parse_pattern_field (line 7) | fn parse_pattern_field(
function parse_content_to_config (line 26) | pub fn parse_content_to_config(content: &String) -> Result<Config, Confi...
function test_parse_content_to_config (line 146) | fn test_parse_content_to_config() {
FILE: src/lib.rs
type SworkstyleError (line 20) | pub type SworkstyleError = Box<dyn Error>;
type ConfigSource (line 22) | struct ConfigSource {
method new (line 28) | fn new(path: impl AsRef<Path>) -> ConfigSource {
type Sworkstyle (line 42) | pub struct Sworkstyle {
method new (line 49) | pub fn new<P: AsRef<Path>>(config_path: Option<P>, deduplicate: bool) ...
method run (line 61) | pub async fn run(mut self) -> Result<(), SworkstyleError> {
method update_workspaces (line 137) | async fn update_workspaces(&self, conn: &mut Connection) -> Result<(),...
method update_workspace_name (line 150) | async fn update_workspace_name(
function get_workspaces_recurse (line 238) | fn get_workspaces_recurse<'a>(node: &'a Node, workspaces: &mut Vec<&'a N...
function get_windows (line 250) | fn get_windows<'a>(node: &'a Node, windows: &mut Vec<&'a Node>) {
FILE: src/main.rs
constant VERSION (line 12) | const VERSION: &str = env!("CARGO_PKG_VERSION");
type Args (line 14) | pub struct Args {
method from_cli (line 26) | pub fn from_cli() -> Args {
function default_config_path (line 21) | fn default_config_path() -> Option<PathBuf> {
function acquire_lock (line 114) | fn acquire_lock() {
function main (line 135) | fn main() {
FILE: src/util.rs
function prettify_option (line 4) | pub fn prettify_option<T: Display>(option: Option<T>) -> String {
Condensed preview — 15 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (51K chars).
[
{
"path": ".gitignore",
"chars": 14,
"preview": "/target\n./tmp\n"
},
{
"path": ".vscode/launch.json",
"chars": 1009,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n //"
},
{
"path": "Cargo.toml",
"chars": 805,
"preview": "[package]\nname = \"sworkstyle\"\nversion = \"1.4.0\"\nauthors = [\"Lyr-7D1h <lyr-7d1h@pm.me>\"]\nedition = \"2021\"\nlicense = \"MIT\""
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2021 Ivo Velthoven\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 5859,
"preview": "# Swayest Workstyle\n\n[](https://aur.archlinux.org/packages/"
},
{
"path": "default_config.toml",
"chars": 2032,
"preview": "# Config for sworkstyle\n#\n# You can rename workspaces based on the exact application names or by generic pattern.\n# When"
},
{
"path": "rust-toolchain",
"chars": 7,
"preview": "stable\n"
},
{
"path": "rustfmt.toml",
"chars": 17,
"preview": "edition = \"2021\"\n"
},
{
"path": "script/release",
"chars": 8785,
"preview": "#!/usr/bin/env python3\n#\n# A script that detect packages and creates a new release for them.\n# It makes a lot of assumpt"
},
{
"path": "src/config/config_error.rs",
"chars": 593,
"preview": "use std::{error::Error, fmt::Display};\n\n#[derive(Debug)]\npub struct ConfigError {\n message: String,\n}\n\nimpl ConfigErr"
},
{
"path": "src/config/parse_content_to_config.rs",
"chars": 6825,
"preview": "use std::convert::TryFrom;\n\nuse toml::Value;\n\nuse super::{config_error::ConfigError, Config, Match, Pattern};\n\nfn parse_"
},
{
"path": "src/config.rs",
"chars": 7910,
"preview": "use std::{convert::TryFrom, fs::read_to_string, path::Path, str::from_utf8};\n\nuse log::{debug, error, info, warn};\nuse r"
},
{
"path": "src/lib.rs",
"chars": 8294,
"preview": "use futures_lite::prelude::*;\n\nuse async_io::Async;\nuse futures_lite::stream;\nuse inotify::{Inotify, WatchMask};\nuse std"
},
{
"path": "src/main.rs",
"chars": 4380,
"preview": "use std::process;\nuse sworkstyle::Sworkstyle;\n\nuse fslock::LockFile;\nuse log::{debug, error};\nuse simple_logger::SimpleL"
},
{
"path": "src/util.rs",
"chars": 224,
"preview": "use std::fmt::Display;\n\n/// Map an option to a printable string\npub fn prettify_option<T: Display>(option: Option<T>) ->"
}
]
About this extraction
This page contains the full source code of the Lyr-7D1h/swayest_workstyle GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 15 files (46.7 KB), approximately 11.8k tokens, and a symbol index with 41 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.