summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSam Nystrom <sam@samnystrom.dev>2025-11-27 17:35:48 -0500
committerSam Nystrom <sam@samnystrom.dev>2025-11-27 17:35:48 -0500
commit747b8a465a501211ebe41d5892eb9262f26743dc (patch)
tree170ef89197a6648a378cec1ce45201442c3e163e
Squashed '.config/waywall/waywork/' content from commit f0b8424
git-subtree-dir: .config/waywall/waywork git-subtree-split: f0b84240c63f4a13d6a8cb54a037424d5d64329a
-rw-r--r--LICENSE21
-rw-r--r--README.md283
-rw-r--r--core.lua61
-rw-r--r--keys.lua12
-rw-r--r--modes.lua103
-rw-r--r--processes.lua110
-rw-r--r--scene.lua115
7 files changed, 705 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c388b70
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Esensats
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5850eb0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,283 @@
+# Waywork
+
+A framework for building [waywall](https://github.com/tesselslate/waywall) configurations for Minecraft speedrunning setups on Linux/Wayland.
+
+## Overview
+
+Waywork provides a structured, modular approach to managing waywall configurations. It abstracts common patterns like resolution switching, scene management, and process orchestration into reusable components, making waywall configs more maintainable and easier to understand.
+
+## Example config
+
+For reference you can look at my config which uses waywork here: [Esensats/waywall-config](https://github.com/Esensats/waywall-config)
+
+## Table of Contents
+
+<!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=6 orderedList=false} -->
+
+<!-- code_chunk_output -->
+
+- [Overview](#overview)
+- [Example config](#example-config)
+- [Table of Contents](#table-of-contents)
+- [Components](#components)
+ - [Scene Manager (`scene.lua`)](#scene-manager-scenelua)
+ - [Mode Manager (`modes.lua`)](#mode-manager-modeslua)
+ - [Key Bindings (`keys.lua`)](#key-bindings-keyslua)
+ - [Core Utilities (`core.lua`)](#core-utilities-corelua)
+ - [Process Management (`processes.lua`)](#process-management-processeslua)
+- [Migration to Waywork](#migration-to-waywork)
+ - [Before](#before)
+ - [After (Waywork)](#after-waywork)
+- [Benefits](#benefits)
+
+<!-- /code_chunk_output -->
+
+## Components
+
+### Scene Manager (`scene.lua`)
+
+Manages visual elements (mirrors, images, text) uniformly with grouping and lifecycle management.
+
+**Features:**
+
+- **Unified Management**: Handle mirrors, images, and text objects through a single interface
+- **Grouping**: Organize scene objects into logical groups for batch operations
+- **Lazy Loading**: Objects are only created when enabled
+- **Dynamic Updates**: Modify object properties at runtime
+
+**Example:**
+
+```lua
+local Scene = require("waywork.scene")
+local scene = Scene.SceneManager.new(waywall)
+
+-- Register scene objects
+scene:register("e_counter", {
+ kind = "mirror",
+ options = {
+ src = { x = 1, y = 37, w = 49, h = 9 },
+ dst = { x = 1150, y = 300, w = 196, h = 36 }
+ },
+ groups = { "thin" },
+})
+
+scene:register("eye_overlay", {
+ kind = "image",
+ path = "/path/to/overlay.png",
+ options = { dst = { x = 30, y = 340, w = 700, h = 400 } },
+ groups = { "tall" },
+})
+
+-- Enable/disable by group
+scene:enable_group("thin", true) -- Enable all "thin" objects
+scene:enable_group("tall", false) -- Disable all "tall" objects
+
+-- Enable/disable individual objects
+scene:enable("e_counter", true)
+```
+
+### Mode Manager (`modes.lua`)
+
+Orchestrates resolution switching with enter/exit hooks and guard conditions.
+
+**Features:**
+
+- **Resolution Management**: Automatic resolution switching with cleanup
+- **Lifecycle Hooks**: `on_enter` and `on_exit` callbacks for mode transitions
+- **Toggle Guards**: Conditional guards to prevent accidental mode switches (e.g. pressing F3 + F4 to switch gamemode, but accidentally triggering mode transition instead)
+- **State Tracking**: Knows which mode is currently active
+
+**Example:**
+
+```lua
+local Modes = require("waywork.modes")
+local ModeManager = Modes.ModeManager.new(waywall)
+
+ModeManager:define("thin", {
+ width = 340,
+ height = 1080,
+ on_enter = function()
+ scene:enable_group("thin", true)
+ end,
+ on_exit = function()
+ scene:enable_group("thin", false)
+ end,
+})
+
+ModeManager:define("tall", {
+ width = 384,
+ height = 16384,
+ toggle_guard = function()
+ return not waywall.get_key("F3") -- Prevent toggle during F3 debug
+ end,
+ on_enter = function()
+ scene:enable_group("tall", true)
+ waywall.set_sensitivity(tall_sens)
+ end,
+ on_exit = function()
+ scene:enable_group("tall", false)
+ waywall.set_sensitivity(0)
+ end,
+})
+
+-- Toggle modes
+ModeManager:toggle("thin") -- Switch to thin mode
+ModeManager:toggle("thin") -- Switch back to default (0x0)
+```
+
+### Key Bindings (`keys.lua`)
+
+Simple utility for building action tables from key mappings.
+
+**Example:**
+
+```lua
+local Keys = require("waywork.keys")
+
+local actions = Keys.actions({
+ ["*-Alt_L"] = function()
+ return ModeManager:toggle("thin")
+ end,
+ ["*-F4"] = function()
+ return ModeManager:toggle("tall")
+ end,
+ ["Ctrl-E"] = function()
+ waywall.press_key("ESC")
+ end,
+})
+
+config.actions = actions
+```
+
+### Core Utilities (`core.lua`)
+
+Low-level utilities used throughout the framework.
+
+**Features:**
+
+- **Toggle**: Boolean state management with callbacks
+- **Resettable Timeout**: Timeout that cancels previous invocations
+- **Table Operations**: Copy and merge utilities
+
+### Process Management (`processes.lua`)
+
+Utilities for managing external processes with shell command handling.
+
+**Features:**
+
+- **Process Detection**: Check if processes are running using `pgrep`
+- **Java JAR Support**: Specialized utilities for launching Java applications
+- **Argument Handling**: Proper handling of command arguments as arrays
+
+**Example:**
+
+```lua
+local P = require("waywork.processes")
+
+-- Check if a process is running
+if P.is_running("firefox") then
+ print("Firefox is running")
+end
+
+-- Create Java JAR launchers with proper argument handling
+local ensure_paceman = P.ensure_java_jar(
+ waywall,
+ "/usr/lib/jvm/java-24-openjdk/bin/java",
+ "/home/user/apps/paceman-tracker.jar",
+ {"--nogui"} -- arguments as array
+)("paceman-tracker\\.jar*") -- process pattern to check
+
+local ensure_ninjabrain = P.ensure_java_jar(
+ waywall,
+ "/usr/lib/jvm/java-24-openjdk/bin/java",
+ "/home/user/apps/ninjabrain-bot.jar",
+ {"-Dawt.useSystemAAFontSettings=on"} -- JVM arguments
+)("ninjabrain-bot\\.jar") -- process pattern to check
+
+-- Use in key bindings
+["Ctrl-Shift-P"] = function()
+ ensure_ninjabrain() -- ensure Ninjabrain is running
+ ensure_paceman() -- ensure Paceman is running
+end,
+```
+
+## Migration to Waywork
+
+### Before
+
+```lua
+-- Scattered mirror management
+local make_mirror = function(options)
+ local this = nil
+ return function(enable)
+ if enable and not this then
+ this = waywall.mirror(options)
+ elseif this and not enable then
+ this:close()
+ this = nil
+ end
+ end
+end
+
+local mirrors = {
+ e_counter = make_mirror({ src = {...}, dst = {...} }),
+ thin_pie_all = make_mirror({ src = {...}, dst = {...} }),
+ -- ... dozens more
+}
+
+-- Manual resolution management
+local make_res = function(width, height, enable, disable)
+ return function()
+ local active_width, active_height = waywall.active_res()
+ if active_width == width and active_height == height then
+ waywall.set_resolution(0, 0)
+ disable()
+ else
+ waywall.set_resolution(width, height)
+ enable()
+ end
+ end
+end
+```
+
+### After (Waywork)
+
+```lua
+local waywall = require("waywall")
+
+local Scene = require("waywork.scene")
+local Modes = require("waywork.modes")
+local Keys = require("waywork.keys")
+
+local scene = Scene.SceneManager.new(waywall)
+local ModeManager = Modes.ModeManager.new(waywall)
+
+-- Clean object registration
+scene:register("e_counter", {
+ kind = "mirror",
+ options = { src = {...}, dst = {...} },
+ groups = { "thin" },
+})
+
+-- Declarative mode definitions
+ModeManager:define("thin", {
+ width = 340,
+ height = 1080,
+ on_enter = function() scene:enable_group("thin", true) end,
+ on_exit = function() scene:enable_group("thin", false) end,
+})
+
+-- Simple key mappings
+local actions = Keys.actions({
+ ["*-Alt_L"] = function() return ModeManager:toggle("thin") end,
+})
+```
+
+## Benefits
+
+1. **Reduced Boilerplate**: Framework handles object lifecycle, resolution management, and state tracking
+2. **Better Organization**: Logical grouping of related functionality
+3. **Maintainability**: Clear separation of concerns and declarative configuration
+4. **Reusability**: Common patterns abstracted into reusable components
+5. **Error Prevention**: Toggle guards and proper state management prevent common issues
+6. **Cleaner Code**: Focus on what you want to achieve, not how to implement it
diff --git a/core.lua b/core.lua
new file mode 100644
index 0000000..f8cd76b
--- /dev/null
+++ b/core.lua
@@ -0,0 +1,61 @@
+local core = {}
+
+--- Create a boolean toggle with on/off callbacks.
+function core.toggle(on, off)
+ local state = false
+ return {
+ set = function(v)
+ if v == state then
+ return state
+ end
+ state = not not v
+ if state then
+ on()
+ else
+ off()
+ end
+ return state
+ end,
+ get = function()
+ return state
+ end,
+ toggle = function(self)
+ return self.set(not state)
+ end,
+ }
+end
+
+--- Create a resettable timeout using blocking sleep (matches waywall execution model).
+--- Calls `f()` only if this invocation is the last one.
+function core.resettable_timeout(sleep, f)
+ local gen = 0
+ return function(delay_ms)
+ gen = gen + 1
+ local my = gen
+ sleep(delay_ms)
+ if my == gen then
+ f()
+ end
+ end
+end
+
+--- Shallow table copy
+function core.copy(t)
+ local r = {}
+ for k, v in pairs(t) do
+ r[k] = v
+ end
+ return r
+end
+
+--- Merge (dst gets missing fields from src)
+function core.merge(dst, src)
+ for k, v in pairs(src or {}) do
+ if dst[k] == nil then
+ dst[k] = v
+ end
+ end
+ return dst
+end
+
+return core
diff --git a/keys.lua b/keys.lua
new file mode 100644
index 0000000..1bb18e1
--- /dev/null
+++ b/keys.lua
@@ -0,0 +1,12 @@
+local K = {}
+
+--- Build actions table from simple mapping { [key] = fn, ... }
+function K.actions(map)
+ local t = {}
+ for k, fn in pairs(map) do
+ t[k] = fn
+ end
+ return t
+end
+
+return K
diff --git a/modes.lua b/modes.lua
new file mode 100644
index 0000000..3bf0047
--- /dev/null
+++ b/modes.lua
@@ -0,0 +1,103 @@
+local modes = {}
+
+--- ModeManager orchestrates resolution toggles and on_enter/on_exit hooks.
+local ModeManager = {}
+ModeManager.__index = ModeManager
+
+function ModeManager.new(waywall)
+ return setmetatable({ ww = waywall, active = nil, defs = {} }, ModeManager)
+end
+
+--- def: { width, height, on_enter=function(), on_exit=function(), toggle_guard=function()->bool }
+function ModeManager:define(name, def)
+ self.defs[name] = def
+end
+
+local function active_res(ww)
+ local w, h = ww.active_res()
+ return tonumber(w), tonumber(h)
+end
+
+--- Get the mode definition by name, erroring if not found.
+--- @param name string
+function ModeManager:_get_def(name)
+ assert(self.defs[name], "No such mode: " .. tostring(name))
+ return self.defs[name]
+end
+
+--- Transition to a mode by name, turning off any previously active mode.
+--- If name is nil, turn off any active mode.
+--- This does not check toggle_guard.
+--- @param name string?
+function ModeManager:_transition_to(name)
+ local function exit_active()
+ if self.active then
+ local prev = self:_get_def(self.active)
+ self.ww.set_resolution(0, 0)
+ if prev.on_exit then
+ prev.on_exit()
+ end
+ self.active = nil
+ end
+ end
+
+ local function enter_mode(_name)
+ if _name then
+ local new = self:_get_def(_name)
+ self.ww.set_resolution(new.width, new.height)
+ if new.on_enter then
+ new.on_enter()
+ end
+ self.active = _name
+ end
+ end
+
+ if name == nil then
+ exit_active()
+ return
+ end
+ if name == self.active then
+ -- already active, do nothing
+ return
+ end
+ exit_active()
+ enter_mode(name)
+end
+
+--- Toggle a mode by name. If it's active, turn it off. If it's inactive, turn it on.
+--- If the mode has a toggle_guard and it returns false, do nothing and return false.
+--- @param name string
+--- @return boolean|nil
+function ModeManager:toggle(name)
+ local ww, def = self.ww, self.defs[name]
+ if not def then
+ return
+ end
+ if def.toggle_guard and def.toggle_guard() == false then
+ return false
+ end
+ -- local w, h = active_res(ww)
+ -- if w == def.width and h == def.height then
+ -- ww.set_resolution(0, 0)
+ -- if def.on_exit then
+ -- def.on_exit()
+ -- end
+ -- self.active = nil
+ -- else
+ -- ww.set_resolution(def.width, def.height)
+ -- if def.on_enter then
+ -- def.on_enter()
+ -- end
+ -- -- exit previous if different
+ -- self.active = name
+ -- end
+
+ if name == self.active then
+ self:_transition_to(nil)
+ else
+ self:_transition_to(name)
+ end
+end
+
+modes.ModeManager = ModeManager
+return modes
diff --git a/processes.lua b/processes.lua
new file mode 100644
index 0000000..4ba61e8
--- /dev/null
+++ b/processes.lua
@@ -0,0 +1,110 @@
+local P = {}
+
+-- Shell-safe quoting: wraps in single quotes, escapes inner quotes
+local function shell_quote(str)
+ return "'" .. tostring(str):gsub("'", "'\"'\"'") .. "'"
+end
+
+-- Build a safe shell command from argv
+local function build_cmd(argv)
+ local quoted = {}
+ for i, arg in ipairs(argv) do
+ quoted[i] = shell_quote(arg)
+ end
+ return table.concat(quoted, " ")
+end
+
+--- Check if a process matching the given pattern is running.
+--- Uses `pgrep -f -- <pattern>`
+--- @param pattern string
+function P.is_running(pattern)
+ local cmd = build_cmd({ "pgrep", "-f", "--", pattern })
+ local h = io.popen(cmd)
+ if not h then
+ return false
+ end
+ local r = h:read("*l")
+ h:close()
+ return r ~= nil
+end
+
+--- @class waywall
+--- @field exec fun(cmd: string): nil
+
+--- wrapper around waywall.exec
+--- @param ww waywall
+--- @param argv table array-like table of args
+--- @return nil
+function P.ww_exec_argv(ww, argv)
+ -- No quoting needed; execvp-style expects raw argv
+ assert(type(argv) == "table", "argv must be an array-like table of strings")
+ for i, v in ipairs(argv) do
+ assert(type(v) == "string", "argv element must be a string")
+ end
+ local cmd = table.concat(argv, " ")
+ return ww.exec(cmd)
+end
+
+--- Return a function that starts an application when called.
+--- @param ww waywall waywall dependency
+--- @param app_path string path to the application executable
+--- @param args? string[] optional additional arguments to pass to the application
+--- @return fun(): nil
+function P.start_application(ww, app_path, args)
+ return function()
+ local argv = { app_path }
+ if args then
+ for _, a in ipairs(args) do
+ argv[#argv + 1] = a
+ end
+ end
+ P.ww_exec_argv(ww, argv)(ww, argv)
+ end
+end
+
+--- Ensure an application is running, start it if not.
+--- @param ww waywall waywall dependency
+--- @param app_path string path to the application executable
+--- @param args? string[] optional additional arguments to pass to the application
+--- @return fun(pattern: string): fun(): nil
+function P.ensure_application(ww, app_path, args)
+ return function(pattern)
+ return function()
+ if not P.is_running(pattern) then
+ local argv = { app_path }
+ if args then
+ for _, a in ipairs(args) do
+ argv[#argv + 1] = a
+ end
+ end
+ P.ww_exec_argv(ww, argv)
+ end
+ end
+ end
+end
+
+--- Ensure a Java JAR is running, start it if not.
+--- @param ww waywall
+--- @param java_path string path to java executable
+--- @param jar_path string path to the JAR file
+--- @param args? string[] optional additional arguments to pass to java
+--- @return fun(pattern: string): fun(): nil
+function P.ensure_java_jar(ww, java_path, jar_path, args)
+ return function(pattern)
+ return function()
+ if not P.is_running(pattern) then
+ local argv = { java_path }
+ if args then
+ for _, a in ipairs(args) do
+ argv[#argv + 1] = a
+ end
+ end
+ argv[#argv + 1] = "-jar"
+ argv[#argv + 1] = jar_path
+ P.ww_exec_argv(ww, argv)
+ end
+ end
+ end
+end
+
+return P
diff --git a/scene.lua b/scene.lua
new file mode 100644
index 0000000..bbb670a
--- /dev/null
+++ b/scene.lua
@@ -0,0 +1,115 @@
+local core = require("waywork.core")
+
+--- SceneManager manages images, mirrors, and text objects uniformly.
+local SceneManager = {}
+SceneManager.__index = SceneManager
+
+function SceneManager.new(waywall)
+ return setmetatable({ ww = waywall, defs = {}, instances = {}, groups = {} }, SceneManager)
+end
+
+--- A scene object definition.
+--- @class SceneDef
+--- @field kind "mirror" | "image" | "text"
+--- @field options table
+--- @field path? string (for images)
+--- @field text? string (for text)
+--- @field groups string[]
+--- @field enabled_by_default? boolean
+
+--- Register a scene object by name.
+--- @param name string
+--- @param def SceneDef
+function SceneManager:register(name, def)
+ self.defs[name] = core.copy(def)
+ if def.groups then
+ for _, g in ipairs(def.groups) do
+ self.groups[g] = self.groups[g] or {}
+ table.insert(self.groups[g], name)
+ end
+ end
+ if def.enabled_by_default then
+ self:enable(name, true)
+ end
+end
+
+function SceneManager:_create(def)
+ if def.kind == "mirror" then
+ return self.ww.mirror(def.options)
+ elseif def.kind == "image" then
+ return self.ww.image(def.path, def.options)
+ elseif def.kind == "text" then
+ return self.ww.text(def.text, def.options)
+ end
+end
+
+function SceneManager:_ensure(name, enable)
+ local inst = self.instances[name]
+ if enable and not inst then
+ inst = self:_create(self.defs[name])
+ self.instances[name] = inst
+ elseif not enable and inst then
+ inst:close()
+ self.instances[name] = nil
+ end
+end
+
+--- Enable or disable a registered scene object by name.
+--- @param name string
+--- @param on boolean
+function SceneManager:enable(name, on)
+ if not self.defs[name] then
+ return
+ end
+ self:_ensure(name, on)
+end
+
+--- Disable a registered scene object by name.
+--- @param name string
+function SceneManager:disable(name)
+ self:enable(name, false)
+end
+
+--- Enable or disable all scene objects in a group.
+--- @param group string
+--- @param on boolean
+function SceneManager:enable_group(group, on)
+ for _, name in ipairs(self.groups[group] or {}) do
+ self:enable(name, on)
+ end
+end
+
+--- Apply a predicate function to all registered scene objects.
+---
+--- Example:
+--- ```lua
+--- scene:apply(function(name, def)
+--- return def.kind == "mirror" -- enable all mirrors
+--- end)
+--- ```
+--- @param predicate fun(name: string, def: table): boolean
+function SceneManager:apply(predicate)
+ for name, def in pairs(self.defs) do
+ self:enable(name, predicate(name, def))
+ end
+end
+
+--- Update the destination rectangle of a registered scene object.
+--- @param name string
+--- @param new_dst table { x=number, y=number, w=number, h=number }
+SceneManager.update_dst = function(self, name, new_dst)
+ local def = self.defs[name]
+ if not def then
+ return
+ end
+ if def.options then
+ def.options.dst = core.copy(new_dst)
+ end
+ local inst = self.instances[name]
+ if inst then
+ inst:close()
+ self.instances[name] = self:_create(def)
+ end
+end
+
+return { SceneManager = SceneManager }