From 2e1013f7b023d80f530479e4761ded44a1f4f7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Fri, 18 Jul 2025 16:02:09 +0300 Subject: [PATCH 1/9] docs(readme): update readme Initial description of the project, and what you can do with it. --- README.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/README.md b/README.md index df492cf..27bb533 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,160 @@ # nirimgr + A manager for niri, written in Go. + +This project is my personal way of trying to learn a bit more of Go. + +I've been using [Niri WM](https://github.com/YaLTeR/niri) for a while, and +saw some helper script by YaLTeR in the discussions, and thought, why not try +and port the same in Go. + +I've also used i3wm before, and it had scratchpad functionality, which I used quite +a lot. I thought this would be a great learning experience for myself, and I try to +mimic the way i3wm handles the scratchpad. + +# Installation + +Run `go install github.com/soderluk/nirimgr@latest` to install nirimgr. + +# Configuration + +The configuration file for nirimgr should be put in ~/.config/nirimgr/config.json + +Example configuration: + +```json +{ + // The socket type to use. The Niri socket is a unix socket. + "socketType": "unix", + // Set the log level of the nirimgr command. Supported levels "DEBUG", "INFO", "WARN", "ERROR" + "logLevel": "DEBUG", + // Window rules and actions to do on the matched window. + "rules": [ + { + "match": [ + { + // Match the title Bitwarden + "title": "Bitwarden", + // Match the app-id zen + "appId": "zen" + } + ], + "actions": { + // Move the matching window to floating + "MoveWindowToFloating": {}, + // Set the floating window width to a fixed 400 + "SetWindowWidth": { + "change": { + "SetFixed": 400 + } + }, + // Set the floating window height to a fixed 600 + "SetWindowHeight": { + "change": { + "SetFixed": 600 + } + } + } + }, + { + "match": [ + { + // Match the app-id org.gnome.Calculator + "appId": "org.gnome.Calculator" + } + ], + "actions": { + // Move the calculator to floating + "MoveWindowToFloating": {}, + // Move the floating window to a fixed x, y coordinate of 800, 200 + "MoveFloatingWindow": { + "x": { + "SetFixed": 800 + }, + "y": { + "SetFixed": 200 + } + }, + // Set the floating window width to a fixed 50. + "SetWindowWidth": { + "change": { + "SetFixed": 50 + } + }, + // Set the floating window height to a fixed 50. + "SetWindowHeight": { + "change": { + "SetFixed": 50 + } + } + } + } + ] +} +``` + +The rules are the same as the `window-rule` in niri configuration. Match the window on a given title or app-id. +Then specify which action you want to do to the matched window. In the example above, the gnome calculator +is matched, then we move the calculator window to floating, move the floating window to a specified x and y coordinate, +set the window width and height to a fixed amount. + +Each action needs to be a separate action. The actions are applied sequentially on the window. + +The actions you can use can be found in the [niri ipc documentation](https://yalter.github.io/niri/niri_ipc/enum.Action.html) + +_NOTE_: Currently only `WindowsChanged`, `WindowOpenedOrChanged` and `WindowClosed` events are watched. + +# Usage + +To use nirimgr, it provides two CLI-commands: + +- events: The events command starts listening on the niri event-stream. `nirimgr events` +- scratch: The scratch command moves a window to the scratchpad workspace, or shows the window (moves the window + to the currently active workspace) from the scratchpad workspace. This command should be configured + as a keybind in niri configuration. `nirimgr scratch [move|show]` + +To use the scratchpad with Niri, you need to have a named workspace `scratchpad`. Set it up like so: + +```kdl +workspace "scratchpad" +spawn-at-startup "niri" "msg" "action" "focus-workspace-down" +``` + +The above will create a new named workspace, `scratchpad`, and focus immediately on the next +workspace. + +Then if you want to use the scratchpad, configure `nirimgr scratch move` and `nirimgr scratch show` +in a keybind, like so: + +```kdl +binds { + ... + Mod+S { + spawn "nirimgr" "scratch" "move" + } + Mod+Shift+S { + spawn "nirimgr" "scratch" "show" + } + ... +} +``` + +Press `Mod+S` to move the currently focused window to the scratchpad workspace, and `Mod+Shift+S` to +move it back. + +If you have multiple windows in the scratchpad, the command will move the last window to +the current workspace. + +If you want to use the events (i.e. listen to the niri event-stream and do actions based on the events) +you need to start the `nirimgr events` command on startup like so: + +`spawn-at-startup "nirimgr" "events"` + +This will listen on the event stream, and react to the matching windows accordingly. You need to +define the `rules` in the `config.json` and add the actions you want to do to the window when the +event happens. + +# Known issues + +There seems to be an [issue](https://github.com/YaLTeR/niri/issues/1805) with niri that it doesn't respect the `focus true/false` when moving a window. +And here's the [PR](https://github.com/YaLTeR/niri/pull/1820) to fix it (as of now, it hasn't been yet merged.) From 71dcfaa38137b4ffbabc76e8473b7f780cf119c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 23 Jul 2025 14:56:33 +0300 Subject: [PATCH 2/9] docs(readme): update readme Update the README. --- README.md | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 27bb533..1e35da4 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ A manager for niri, written in Go. -This project is my personal way of trying to learn a bit more of Go. +This is a small project mainly directed towards learning a bit more of Go. I've been using [Niri WM](https://github.com/YaLTeR/niri) for a while, and -saw some helper script by YaLTeR in the discussions, and thought, why not try -and port the same in Go. +saw a helper script by YaLTeR in the discussions, and thought, why not try +and port the same in Go. [Here's](https://github.com/YaLTeR/niri/discussions/1599) the script. -I've also used i3wm before, and it had scratchpad functionality, which I used quite +I've used i3wm before, and it had scratchpad functionality, which I used quite a lot. I thought this would be a great learning experience for myself, and I try to mimic the way i3wm handles the scratchpad. @@ -24,8 +24,8 @@ Example configuration: ```json { - // The socket type to use. The Niri socket is a unix socket. - "socketType": "unix", + // Define the scratchpad workspace name here. + "scratchpadWorkspace": "scratchpad", // Set the log level of the nirimgr command. Supported levels "DEBUG", "INFO", "WARN", "ERROR" "logLevel": "DEBUG", // Window rules and actions to do on the matched window. @@ -93,8 +93,8 @@ Example configuration: } ``` -The rules are the same as the `window-rule` in niri configuration. Match the window on a given title or app-id. -Then specify which action you want to do to the matched window. In the example above, the gnome calculator +The rules are the same as the `window-rule` in Niri configuration. Match the window on a given title or app-id. +Then specify which action you want to do with the matched window. In the example above, the gnome calculator is matched, then we move the calculator window to floating, move the floating window to a specified x and y coordinate, set the window width and height to a fixed amount. @@ -104,19 +104,26 @@ The actions you can use can be found in the [niri ipc documentation](https://yal _NOTE_: Currently only `WindowsChanged`, `WindowOpenedOrChanged` and `WindowClosed` events are watched. +Please feel free to open a PR if you have other thoughts that we could do with nirimgr. + # Usage To use nirimgr, it provides two CLI-commands: -- events: The events command starts listening on the niri event-stream. `nirimgr events` -- scratch: The scratch command moves a window to the scratchpad workspace, or shows the window (moves the window +- `events`: The events command starts listening on the niri event-stream. `nirimgr events` +- `scratch`: The scratch command moves a window to the scratchpad workspace, or shows the window (moves the window to the currently active workspace) from the scratchpad workspace. This command should be configured as a keybind in niri configuration. `nirimgr scratch [move|show]` +- `list`: The list command will list all the available actions or events, so you don't need to remember them all. + `nirimgr list [actions|events]` + +To use the scratchpad with Niri, you need to have a named workspace `scratchpad`, or if you want to configure it, +set the scratchpadWorkspace configuration option to something else `"scratchpadWorkspace": "scratch"`. -To use the scratchpad with Niri, you need to have a named workspace `scratchpad`. Set it up like so: +Set it up in niri config like so: ```kdl -workspace "scratchpad" +workspace "scratchpad" // or whatever you configured it to be. spawn-at-startup "niri" "msg" "action" "focus-workspace-down" ``` @@ -143,17 +150,28 @@ Press `Mod+S` to move the currently focused window to the scratchpad workspace, move it back. If you have multiple windows in the scratchpad, the command will move the last window to -the current workspace. +the current workspace (this is pretty much how i3wm did the scratchpad functionality). If you want to use the events (i.e. listen to the niri event-stream and do actions based on the events) you need to start the `nirimgr events` command on startup like so: -`spawn-at-startup "nirimgr" "events"` +```kdl +spawn-at-startup "nirimgr" "events" +``` This will listen on the event stream, and react to the matching windows accordingly. You need to -define the `rules` in the `config.json` and add the actions you want to do to the window when the +define the `rules` in `config.json` and add the actions you want to do to the window when the event happens. +# Acknowledgements + +Of course the biggest one goes to [Niri WM](https://github.com/YaLTeR/niri) and YaLTeR for an awesome manager! + +Since this is mostly a learning project for me, I had to look a bit more into a few of existing libraries, +the most notable being [niri-float-sticky](https://github.com/probeldev/niri-float-sticky) + +The goroutine handling of the event stream felt like a better approach than I had before, so thanks to the author for a great library! + # Known issues There seems to be an [issue](https://github.com/YaLTeR/niri/issues/1805) with niri that it doesn't respect the `focus true/false` when moving a window. From 648e90462411eab0ff7b014f48655ad2c493cac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 23 Jul 2025 15:16:42 +0300 Subject: [PATCH 3/9] feat: initial project files and structure Add all the project files, configurations and tests. --- .pre-commit-config.yaml | 126 +++++ actions/actions.go | 225 ++++++++ actions/actions_test.go | 24 + actions/enums.go | 47 ++ actions/models.go | 820 ++++++++++++++++++++++++++++++ cmd/events.go | 22 + cmd/list.go | 122 +++++ cmd/root.go | 57 +++ cmd/scratch.go | 189 +++++++ cmd/version.go | 36 ++ config/config.go | 82 +++ config/config.json | 54 ++ config/config_test.go | 99 ++++ events/events.go | 173 +++++++ events/events_test.go | 92 ++++ events/models.go | 127 +++++ go.mod | 21 + go.sum | 31 ++ internal/common/common.go | 55 ++ internal/connection/connection.go | 174 +++++++ main.go | 49 ++ models/enums.go | 114 +++++ models/models.go | 326 ++++++++++++ models/models_test.go | 71 +++ 24 files changed, 3136 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 actions/actions.go create mode 100644 actions/actions_test.go create mode 100644 actions/enums.go create mode 100644 actions/models.go create mode 100644 cmd/events.go create mode 100644 cmd/list.go create mode 100644 cmd/root.go create mode 100644 cmd/scratch.go create mode 100644 cmd/version.go create mode 100644 config/config.go create mode 100644 config/config.json create mode 100644 config/config_test.go create mode 100644 events/events.go create mode 100644 events/events_test.go create mode 100644 events/models.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/common/common.go create mode 100644 internal/connection/connection.go create mode 100644 main.go create mode 100644 models/enums.go create mode 100644 models/models.go create mode 100644 models/models_test.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8f46430 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,126 @@ +default_install_hook_types: + - pre-commit + - commit-msg +repos: + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.2.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] + # ========================================================================== + # Golang Pre-Commit Hooks | https://github.com/tekwizely/pre-commit-golang + # + # Visit the project home page to learn more about the available Hooks, + # including useful arguments you might want to pass into them. + # + # File-Based Hooks: + # Run against matching staged files individually. + # + # Module-Based Hooks: + # Run against module root folders containing matching staged files. + # + # Package-Based Hooks: + # Run against folders containing one or more staged files. + # + # Repo-Based Hooks: + # Run against the entire repo. + # The hooks only run once (if any matching files are staged), + # and are NOT provided the list of staged files, + # + # My-Cmd-* Hooks + # Allow you to invoke custom tools in various contexts. + # Can be useful if your favorite tool(s) are not built-in (yet) + # + # Hook Suffixes + # Hooks have suffixes in their name that indicate their targets: + # + # +-----------+--------------+ + # | Suffix | Target | + # |-----------+--------------+ + # | | Files | + # | -mod | Module | + # | -pkg | Package | + # | -repo | Repo Root | + # | -repo-mod | All Modules | + # | -repo-pkg | All Packages | + # +-----------+--------------+ + # + # ! Multiple Hook Invocations + # ! Due to OS command-line-length limits, Pre-Commit can invoke a hook + # ! multiple times if a large number of files are staged. + # ! For file and repo-based hooks, this isn't an issue, but for module + # ! and package-based hooks, there is a potential for the hook to run + # ! against the same module or package multiple times, duplicating any + # ! errors or warnings. + # + # Useful Hook Parameters: + # - id: hook-id + # args: [arg1, arg2, ..., '--'] # Pass options ('--' is optional) + # always_run: true # Run even if no matching files staged + # alias: hook-alias # Create an alias + # + # Passing Options To Hooks: + # If your options contain a reference to an existing file, then you will + # need to use a trailing '--' argument to separate the hook options from + # the modified-file list that Pre-Commit passes into the hook. + # NOTE: For repo-based hooks, '--' is not needed. + # + # Passing Environment Variables to Hooks: + # You can pass environment variables to hooks using args with the + # following format: + # + # --hook:env:NAME=VALUE + # + # Always Run: + # By default, hooks ONLY run when matching file types are staged. + # When configured to "always_run", a hook is executed as if EVERY matching + # file were staged. + # + # Aliases: + # Consider adding aliases to longer-named hooks for easier CLI usage. + # ========================================================================== + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 + hooks: + # + # Go Build + # + - id: go-build-repo-pkg + # + # Go Mod Tidy + # + - id: go-mod-tidy-repo + # + # Go Test + # + - id: go-test-repo-pkg + # + # Go Vet + # + - id: go-vet-repo-pkg + # + # GoSec + # + - id: go-sec-repo-pkg + # + # StaticCheck + # + - id: go-staticcheck-repo-pkg + # + # Formatters + # + - id: go-fmt + - id: go-fmt-repo + # + # Style Checkers + # + - id: go-lint + # + # GolangCI-Lint + # - Fast Multi-Linter + # - Can be configured to replace MOST other hooks + # - Supports repo config file for configuration + # - https://github.com/golangci/golangci-lint + # + - id: golangci-lint-repo-pkg diff --git a/actions/actions.go b/actions/actions.go new file mode 100644 index 0000000..c55593b --- /dev/null +++ b/actions/actions.go @@ -0,0 +1,225 @@ +// Package actions contains all the actions Niri currently supports. +// +// A thing to note: we need to add the models.AName embedded struct to all the actions: +// +// models.AName{Name: "ActionName"} +// Example: +// type Quit struct{ +// models.AName +// SkipConfirmation bool `json:"skip_confirmation"` +// } +// +// because we don't want to add the receiver functions for all 130+ actions. +// Supporting just one GetName() function for the interface, gives us some leeway when +// working with the actions. +// +// See: https://yalter.github.io/niri/niri_ipc/enum.Action.html# for more details. +package actions + +import ( + "encoding/json" + "log/slog" + "reflect" +) + +// SetActionID sets the ID field of the action dynamically. +// +// This is used when matching the windows, and we don't yet have the ID +// of the window when getting the action. +func SetActionID(a Action, id uint64) Action { + value := reflect.ValueOf(a) + if value.Kind() == reflect.Ptr { + value = value.Elem() + } + idField := value.FieldByName("ID") + if idField.IsValid() && idField.CanSet() && idField.Kind() == reflect.Uint64 { + idField.SetUint(id) + } + return a +} + +// FromRegistry returns the populated model from the ActionRegistry by given name. +func FromRegistry(name string, data []byte) Action { + model, ok := ActionRegistry[name] + if !ok { + slog.Error("Could not get action model for action", "name", name) + return nil + } + action := model() + if err := json.Unmarshal(data, action); err != nil { + slog.Error("Could not unmarshal action", "name", name, "error", err.Error()) + return nil + } + return action +} + +// ParseRawActions parses the actions into their respective structs. +func ParseRawActions(rawActions map[string]json.RawMessage) []Action { + var actionList []Action + + for name, raw := range rawActions { + action := FromRegistry(name, raw) + if action == nil { + continue + } + actionList = append(actionList, action) + } + return actionList +} + +// ActionRegistry contains all the actions Niri currently sends. +// +// The key needs to be the action name, and it should return the correct action model, and set +// its AName embedded struct. If you know of a better way to handle this, please let me know. +var ActionRegistry = map[string]func() Action{ + "Quit": func() Action { return &Quit{AName: AName{Name: "Quit"}} }, + "PowerOffMonitors": func() Action { return &PowerOffMonitors{AName: AName{Name: "PowerOffMonitors"}} }, + "PowerOnMonitors": func() Action { return &PowerOnMonitors{AName: AName{Name: "PowerOnMonitors"}} }, + "Spawn": func() Action { return &Spawn{AName: AName{Name: "Spawn"}} }, + "DoScreenTransition": func() Action { return &DoScreenTransition{AName: AName{Name: "DoScreenTransition"}} }, + "Screenshot": func() Action { return &Screenshot{AName: AName{Name: "Screenshot"}} }, + "ScreenshotScreen": func() Action { return &ScreenshotScreen{AName: AName{Name: "ScreenshotScreen"}} }, + "ScreenshotWindow": func() Action { return &ScreenshotWindow{AName: AName{Name: "ScreenshotWindow"}} }, + "ToggleKeyboardShortcutsInhibit": func() Action { + return &ToggleKeyboardShortcutsInhibit{AName: AName{Name: "ToggleKeyboardShortcutsInhibit"}} + }, + "CloseWindow": func() Action { return &CloseWindow{AName: AName{Name: "CloseWindow"}} }, + "FullscreenWindow": func() Action { return &FullscreenWindow{AName: AName{Name: "FullscreenWindow"}} }, + "ToggleWindowedFullscreen": func() Action { return &ToggleWindowedFullscreen{AName: AName{Name: "ToggleWindowedFullscreen"}} }, + "FocusWindow": func() Action { return &FocusWindow{AName: AName{Name: "FocusWindow"}} }, + "FocusWindowInColumn": func() Action { return &FocusWindowInColumn{AName: AName{Name: "FocusWindowInColumn"}} }, + "FocusWindowPrevious": func() Action { return &FocusWindowPrevious{AName: AName{Name: "FocusWindowPrevious"}} }, + "FocusColumnLeft": func() Action { return &FocusColumnLeft{AName: AName{Name: "FocusColumnLeft"}} }, + "FocusColumnRight": func() Action { return &FocusColumnRight{AName: AName{Name: "FocusColumnRight"}} }, + "FocusColumnFirst": func() Action { return &FocusColumnFirst{AName: AName{Name: "FocusColumnFirst"}} }, + "FocusColumnLast": func() Action { return &FocusColumnLast{AName: AName{Name: "FocusColumnLast"}} }, + "FocusColumnRightOrFirst": func() Action { return &FocusColumnRightOrFirst{AName: AName{Name: "FocusColumnRightOrFirst"}} }, + "FocusColumnLeftOrLast": func() Action { return &FocusColumnLeftOrLast{AName: AName{Name: "FocusColumnLeftOrLast"}} }, + "FocusColumn": func() Action { return &FocusColumn{AName: AName{Name: "FocusColumn"}} }, + "FocusWindowOrMonitorUp": func() Action { return &FocusWindowOrMonitorUp{AName: AName{Name: "FocusWindowOrMonitorUp"}} }, + "FocusWindowOrMonitorDown": func() Action { return &FocusWindowOrMonitorDown{AName: AName{Name: "FocusWindowOrMonitorDown"}} }, + "FocusColumnOrMonitorLeft": func() Action { return &FocusColumnOrMonitorLeft{AName: AName{Name: "FocusColumnOrMonitorLeft"}} }, + "FocusColumnOrMonitorRight": func() Action { return &FocusColumnOrMonitorRight{AName: AName{Name: "FocusColumnOrMonitorRight"}} }, + "FocusWindowDown": func() Action { return &FocusWindowDown{AName: AName{Name: "FocusWindowDown"}} }, + "FocusWindowUp": func() Action { return &FocusWindowUp{AName: AName{Name: "FocusWindowUp"}} }, + "FocusWindowDownOrColumnLeft": func() Action { return &FocusWindowDownOrColumnLeft{AName: AName{Name: "FocusWindowDownOrColumnLeft"}} }, + "FocusWindowDownOrColumnRight": func() Action { + return &FocusWindowDownOrColumnRight{AName: AName{Name: "FocusWindowDownOrColumnRight"}} + }, + "FocusWindowUpOrColumnLeft": func() Action { return &FocusWindowUpOrColumnLeft{AName: AName{Name: "FocusWindowUpOrColumnLeft"}} }, + "FocusWindowUpOrColumnRight": func() Action { return &FocusWindowUpOrColumnRight{AName: AName{Name: "FocusWindowUpOrColumnRight"}} }, + "FocusWindowOrWorkspaceDown": func() Action { return &FocusWindowOrWorkspaceDown{AName: AName{Name: "FocusWindowOrWorkspaceDown"}} }, + "FocusWindowOrWorkspaceUp": func() Action { return &FocusWindowOrWorkspaceUp{AName: AName{Name: "FocusWindowOrWorkspaceUp"}} }, + "FocusWindowTop": func() Action { return &FocusWindowTop{AName: AName{Name: "FocusWindowTop"}} }, + "FocusWindowBottom": func() Action { return &FocusWindowBottom{AName: AName{Name: "FocusWindowBottom"}} }, + "FocusWindowDownOrTop": func() Action { return &FocusWindowDownOrTop{AName: AName{Name: "FocusWindowDownOrTop"}} }, + "FocusWindowUpOrBottom": func() Action { return &FocusWindowUpOrBottom{AName: AName{Name: "FocusWindowUpOrBottom"}} }, + "MoveColumnLeft": func() Action { return &MoveColumnLeft{AName: AName{Name: "MoveColumnLeft"}} }, + "MoveColumnRight": func() Action { return &MoveColumnRight{AName: AName{Name: "MoveColumnRight"}} }, + "MoveColumnToFirst": func() Action { return &MoveColumnToFirst{AName: AName{Name: "MoveColumnToFirst"}} }, + "MoveColumnToLast": func() Action { return &MoveColumnToLast{AName: AName{Name: "MoveColumnToLast"}} }, + "MoveColumnLeftOrToMonitorLeft": func() Action { + return &MoveColumnLeftOrToMonitorLeft{AName: AName{Name: "MoveColumnLeftOrToMonitorLeft"}} + }, + "MoveColumnRightOrToMonitorRight": func() Action { + return &MoveColumnRightOrToMonitorRight{AName: AName{Name: "MoveColumnRightOrToMonitorRight"}} + }, + "MoveColumnToIndex": func() Action { return &MoveColumnToIndex{AName: AName{Name: "MoveColumnToIndex"}} }, + "MoveWindowDown": func() Action { return &MoveWindowDown{AName: AName{Name: "MoveWindowDown"}} }, + "MoveWindowUp": func() Action { return &MoveWindowUp{AName: AName{Name: "MoveWindowUp"}} }, + "MoveWindowDownOrToWorkspaceDown": func() Action { + return &MoveWindowDownOrToWorkspaceDown{AName: AName{Name: "MoveWindowDownOrToWorkspaceDown"}} + }, + "MoveWindowUpOrToWorkspaceUp": func() Action { return &MoveWindowUpOrToWorkspaceUp{AName: AName{Name: "MoveWindowUpOrToWorkspaceUp"}} }, + "ConsumeOrExpelWindowLeft": func() Action { return &ConsumeOrExpelWindowLeft{AName: AName{Name: "ConsumeOrExpelWindowLeft"}} }, + "ConsumeOrExpelWindowRight": func() Action { return &ConsumeOrExpelWindowRight{AName: AName{Name: "ConsumeOrExpelWindowRight"}} }, + "ConsumeWindowIntoColumn": func() Action { return &ConsumeWindowIntoColumn{AName: AName{Name: "ConsumeWindowIntoColumn"}} }, + "ExpelWindowFromColumn": func() Action { return &ExpelWindowFromColumn{AName: AName{Name: "ExpelWindowFromColumn"}} }, + "SwapWindowRight": func() Action { return &SwapWindowRight{AName: AName{Name: "SwapWindowRight"}} }, + "SwapWindowLeft": func() Action { return &SwapWindowLeft{AName: AName{Name: "SwapWindowLeft"}} }, + "ToggleColumnTabbedDisplay": func() Action { return &ToggleColumnTabbedDisplay{AName: AName{Name: "ToggleColumnTabbedDisplay"}} }, + "SetColumnDisplay": func() Action { return &SetColumnDisplay{AName: AName{Name: "SetColumnDisplay"}} }, + "CenterColumn": func() Action { return &CenterColumn{AName: AName{Name: "CenterColumn"}} }, + "CenterWindow": func() Action { return &CenterWindow{AName: AName{Name: "CenterWindow"}} }, + "CenterVisibleColumns": func() Action { return &CenterVisibleColumns{AName: AName{Name: "CenterVisibleColumns"}} }, + "FocusWorkspaceDown": func() Action { return &FocusWorkspaceDown{AName: AName{Name: "FocusWorkspaceDown"}} }, + "FocusWorkspaceUp": func() Action { return &FocusWorkspaceUp{AName: AName{Name: "FocusWorkspaceUp"}} }, + "FocusWorkspace": func() Action { return &FocusWorkspace{AName: AName{Name: "FocusWorkspace"}} }, + "FocusWorkspacePrevious": func() Action { return &FocusWorkspacePrevious{AName: AName{Name: "FocusWorkspacePrevious"}} }, + "MoveWindowToWorkspaceDown": func() Action { return &MoveWindowToWorkspaceDown{AName: AName{Name: "MoveWindowToWorkspaceDown"}} }, + "MoveWindowToWorkspaceUp": func() Action { return &MoveWindowToWorkspaceUp{AName: AName{Name: "MoveWindowToWorkspaceUp"}} }, + "MoveWindowToWorkspace": func() Action { return &MoveWindowToWorkspace{AName: AName{Name: "MoveWindowToWorkspace"}} }, + "MoveColumnToWorkspaceDown": func() Action { return &MoveColumnToWorkspaceDown{AName: AName{Name: "MoveColumnToWorkspaceDown"}} }, + "MoveColumnToWorkspaceUp": func() Action { return &MoveColumnToWorkspaceUp{AName: AName{Name: "MoveColumnToWorkspaceUp"}} }, + "MoveColumnToWorkspace": func() Action { return &MoveColumnToWorkspace{AName: AName{Name: "MoveColumnToWorkspace"}} }, + "MoveWorkspaceDown": func() Action { return &MoveWorkspaceDown{AName: AName{Name: "MoveWorkspaceDown"}} }, + "MoveWorkspaceUp": func() Action { return &MoveWorkspaceUp{AName: AName{Name: "MoveWorkspaceUp"}} }, + "MoveWorkspaceToIndex": func() Action { return &MoveWorkspaceToIndex{AName: AName{Name: "MoveWorkspaceToIndex"}} }, + "SetWorkspaceName": func() Action { return &SetWorkspaceName{AName: AName{Name: "SetWorkspaceName"}} }, + "UnsetWorkspaceName": func() Action { return &UnsetWorkspaceName{AName: AName{Name: "UnsetWorkspaceName"}} }, + "FocusMonitorLeft": func() Action { return &FocusMonitorLeft{AName: AName{Name: "FocusMonitorLeft"}} }, + "FocusMonitorRight": func() Action { return &FocusMonitorRight{AName: AName{Name: "FocusMonitorRight"}} }, + "FocusMonitorDown": func() Action { return &FocusMonitorDown{AName: AName{Name: "FocusMonitorDown"}} }, + "FocusMonitorUp": func() Action { return &FocusMonitorUp{AName: AName{Name: "FocusMonitorUp"}} }, + "FocusMonitorPrevious": func() Action { return &FocusMonitorPrevious{AName: AName{Name: "FocusMonitorPrevious"}} }, + "FocusMonitorNext": func() Action { return &FocusMonitorNext{AName: AName{Name: "FocusMonitorNext"}} }, + "FocusMonitor": func() Action { return &FocusMonitor{AName: AName{Name: "FocusMonitor"}} }, + "MoveWindowToMonitorLeft": func() Action { return &MoveWindowToMonitorLeft{AName: AName{Name: "MoveWindowToMonitorLeft"}} }, + "MoveWindowToMonitorRight": func() Action { return &MoveWindowToMonitorRight{AName: AName{Name: "MoveWindowToMonitorRight"}} }, + "MoveWindowToMonitorDown": func() Action { return &MoveWindowToMonitorDown{AName: AName{Name: "MoveWindowToMonitorDown"}} }, + "MoveWindowToMonitorUp": func() Action { return &MoveWindowToMonitorUp{AName: AName{Name: "MoveWindowToMonitorUp"}} }, + "MoveWindowToMonitorPrevious": func() Action { return &MoveWindowToMonitorPrevious{AName: AName{Name: "MoveWindowToMonitorPrevious"}} }, + "MoveWindowToMonitorNext": func() Action { return &MoveWindowToMonitorNext{AName: AName{Name: "MoveWindowToMonitorNext"}} }, + "MoveWindowToMonitor": func() Action { return &MoveWindowToMonitor{AName: AName{Name: "MoveWindowToMonitor"}} }, + "MoveColumnToMonitorLeft": func() Action { return &MoveColumnToMonitorLeft{AName: AName{Name: "MoveColumnToMonitorLeft"}} }, + "MoveColumnToMonitorRight": func() Action { return &MoveColumnToMonitorRight{AName: AName{Name: "MoveColumnToMonitorRight"}} }, + "MoveColumnToMonitorDown": func() Action { return &MoveColumnToMonitorDown{AName: AName{Name: "MoveColumnToMonitorDown"}} }, + "MoveColumnToMonitorUp": func() Action { return &MoveColumnToMonitorUp{AName: AName{Name: "MoveColumnToMonitorUp"}} }, + "MoveColumnToMonitorPrevious": func() Action { return &MoveColumnToMonitorPrevious{AName: AName{Name: "MoveColumnToMonitorPrevious"}} }, + "MoveColumnToMonitorNext": func() Action { return &MoveColumnToMonitorNext{AName: AName{Name: "MoveColumnToMonitorNext"}} }, + "MoveColumnToMonitor": func() Action { return &MoveColumnToMonitor{AName: AName{Name: "MoveColumnToMonitor"}} }, + "SetWindowWidth": func() Action { return &SetWindowWidth{AName: AName{Name: "SetWindowWidth"}} }, + "SetWindowHeight": func() Action { return &SetWindowHeight{AName: AName{Name: "SetWindowHeight"}} }, + "ResetWindowHeight": func() Action { return &ResetWindowHeight{AName: AName{Name: "ResetWindowHeight"}} }, + "SwitchPresetColumnWidth": func() Action { return &SwitchPresetColumnWidth{AName: AName{Name: "SwitchPresetColumnWidth"}} }, + "SwitchPresetWindowWidth": func() Action { return &SwitchPresetWindowWidth{AName: AName{Name: "SwitchPresetWindowWidth"}} }, + "SwitchPresetWindowHeight": func() Action { return &SwitchPresetWindowHeight{AName: AName{Name: "SwitchPresetWindowHeight"}} }, + "MaximizeColumn": func() Action { return &MaximizeColumn{AName: AName{Name: "MaximizeColumn"}} }, + "SetColumnWidth": func() Action { return &SetColumnWidth{AName: AName{Name: "SetColumnWidth"}} }, + "ExpandColumnToAvailableWidth": func() Action { + return &ExpandColumnToAvailableWidth{AName: AName{Name: "ExpandColumnToAvailableWidth"}} + }, + "SwitchLayout": func() Action { return &SwitchLayout{AName: AName{Name: "SwitchLayout"}} }, + "ShowHotkeyOverlay": func() Action { return &ShowHotkeyOverlay{AName: AName{Name: "ShowHotkeyOverlay"}} }, + "MoveWorkspaceToMonitorLeft": func() Action { return &MoveWorkspaceToMonitorLeft{AName: AName{Name: "MoveWorkspaceToMonitorLeft"}} }, + "MoveWorkspaceToMonitorRight": func() Action { return &MoveWorkspaceToMonitorRight{AName: AName{Name: "MoveWorkspaceToMonitorRight"}} }, + "MoveWorkspaceToMonitorDown": func() Action { return &MoveWorkspaceToMonitorDown{AName: AName{Name: "MoveWorkspaceToMonitorDown"}} }, + "MoveWorkspaceToMonitorUp": func() Action { return &MoveWorkspaceToMonitorUp{AName: AName{Name: "MoveWorkspaceToMonitorUp"}} }, + "MoveWorkspaceToMonitorPrevious": func() Action { + return &MoveWorkspaceToMonitorPrevious{AName: AName{Name: "MoveWorkspaceToMonitorPrevious"}} + }, + "MoveWorkspaceToMonitorNext": func() Action { return &MoveWorkspaceToMonitorNext{AName: AName{Name: "MoveWorkspaceToMonitorNext"}} }, + "MoveWorkspaceToMonitor": func() Action { return &MoveWorkspaceToMonitor{AName: AName{Name: "MoveWorkspaceToMonitor"}} }, + "ToggleDebugTint": func() Action { return &ToggleDebugTint{AName: AName{Name: "ToggleDebugTint"}} }, + "DebugToggleOpaqueRegions": func() Action { return &DebugToggleOpaqueRegions{AName: AName{Name: "DebugToggleOpaqueRegions"}} }, + "DebugToggleDamage": func() Action { return &DebugToggleDamage{AName: AName{Name: "DebugToggleDamage"}} }, + "ToggleWindowFloating": func() Action { return &ToggleWindowFloating{AName: AName{Name: "ToggleWindowFloating"}} }, + "MoveWindowToFloating": func() Action { return &MoveWindowToFloating{AName: AName{Name: "MoveWindowToFloating"}} }, + "MoveWindowToTiling": func() Action { return &MoveWindowToTiling{AName: AName{Name: "MoveWindowToTiling"}} }, + "FocusFloating": func() Action { return &FocusFloating{AName: AName{Name: "FocusFloating"}} }, + "FocusTiling": func() Action { return &FocusTiling{AName: AName{Name: "FocusTiling"}} }, + "SwitchFocusBetweenFloatingAndTiling": func() Action { + return &SwitchFocusBetweenFloatingAndTiling{AName: AName{Name: "SwitchFocusBetweenFloatingAndTiling"}} + }, + "MoveFloatingWindow": func() Action { return &MoveFloatingWindow{AName: AName{Name: "MoveFloatingWindow"}} }, + "ToggleWindowRuleOpacity": func() Action { return &ToggleWindowRuleOpacity{AName: AName{Name: "ToggleWindowRuleOpacity"}} }, + "SetDynamicCastWindow": func() Action { return &SetDynamicCastWindow{AName: AName{Name: "SetDynamicCastWindow"}} }, + "SetDynamicCastMonitor": func() Action { return &SetDynamicCastMonitor{AName: AName{Name: "SetDynamicCastMonitor"}} }, + "ClearDynamicCastTarget": func() Action { return &ClearDynamicCastTarget{AName: AName{Name: "ClearDynamicCastTarget"}} }, + "ToggleOverview": func() Action { return &ToggleOverview{AName: AName{Name: "ToggleOverview"}} }, + "OpenOverview": func() Action { return &OpenOverview{AName: AName{Name: "OpenOverview"}} }, + "CloseOverview": func() Action { return &CloseOverview{AName: AName{Name: "CloseOverview"}} }, + "ToggleWindowUrgent": func() Action { return &ToggleWindowUrgent{AName: AName{Name: "ToggleWindowUrgent"}} }, + "SetWindowUrgent": func() Action { return &SetWindowUrgent{AName: AName{Name: "SetWindowUrgent"}} }, + "UnsetWindowUrgent": func() Action { return &UnsetWindowUrgent{AName: AName{Name: "UnsetWindowUrgent"}} }, +} diff --git a/actions/actions_test.go b/actions/actions_test.go new file mode 100644 index 0000000..1ff9003 --- /dev/null +++ b/actions/actions_test.go @@ -0,0 +1,24 @@ +package actions + +import ( + "testing" +) + +func TestActionRegistryCreatesCorrectTypes(t *testing.T) { + for name, model := range ActionRegistry { + action := model() + if action == nil { + t.Errorf("ActionRegistry[%q] returned nil", name) + } + if action.GetName() != name { + t.Errorf("ActionRegistry[%q] returned action with name %q", name, action.GetName()) + } + } +} + +func TestANameGetName(t *testing.T) { + a := AName{Name: "TestAction"} + if a.GetName() != "TestAction" { + t.Errorf("AName.GetName() = %q, want %q", a.GetName(), "TestAction") + } +} diff --git a/actions/enums.go b/actions/enums.go new file mode 100644 index 0000000..de7bc7a --- /dev/null +++ b/actions/enums.go @@ -0,0 +1,47 @@ +package actions + +// ColumnDisplay sets the column display to either Normal (tiled) or Tabbed (tabs). +type ColumnDisplay struct { + Normal int `json:"Normal,omitempty"` + Tabbed int `json:"Tabbed,omitempty"` +} + +// LayoutSwitchTarget defines the layout to switch to. +type LayoutSwitchTarget struct { + // Next the next configured layout. + Next int `json:"Next,omitempty"` + // Prev the previous configured layout. + Prev int `json:"Prev,omitempty"` + // Index the specific layout by index. + Index uint8 `json:"Index,omitempty"` +} + +// PositionChange defines how we want to position a window. +type PositionChange struct { + // SetFixed sets the position in logical pixels. + SetFixed float64 `json:"SetFixed,omitempty"` + // AdjustFixed adds or subtracts the current position in logical pixels. + AdjustFixed float64 `json:"AdjustFixed,omitempty"` +} + +// SizeChange defines how we want to change the size of a window. +type SizeChange struct { + // SetFixed sets the size in logical pixels. + SetFixed int32 `json:"SetFixed,omitempty"` + // SetProportion sets the size as a proportion of the working area. + SetProportion float64 `json:"SetProportion,omitempty"` + // AdjustFixed adds or subtracts the current size in logical pixels. + AdjustFixed int32 `json:"AdjustFixed,omitempty"` + // AdjustProportion adds or subtracts the current size as a proportion of the working area. + AdjustProportion float64 `json:"AdjustProportion,omitempty"` +} + +// WorkspaceReferenceArg takes either the ID, Index or Name of the workspace. +type WorkspaceReferenceArg struct { + // ID the ID of the workspace. + ID uint64 `json:"Id,omitempty"` + // Index the index of the workspace. + Index uint8 `json:"Index,omitempty"` + // Name the name of the workspace. + Name string `json:"Name,omitempty"` +} diff --git a/actions/models.go b/actions/models.go new file mode 100644 index 0000000..900107e --- /dev/null +++ b/actions/models.go @@ -0,0 +1,820 @@ +package actions + +// If more actions are added in Niri, we must define them here, and add them to the ActionRegistry. + +// Action is the "base" interface for all the actions. +// +// NOTE: We have to use GetName, since the field is called Name. +type Action interface { + GetName() string +} + +// AName defines the name of the action. +type AName struct { + Name string +} + +// GetName returns the action name. +func (a AName) GetName() string { + return a.Name +} + +// Quit exits niri. +type Quit struct { + AName + // SkipConfirmation skips the "Press Enter to confirm" prompt. + SkipConfirmation bool `json:"skip_confirmation"` +} + +// PowerOffMonitors powers off all monitors via DPMS. +type PowerOffMonitors struct { + AName +} + +// PowerOnMonitors powers on all monitors via DPMS. +type PowerOnMonitors struct { + AName +} + +// Spawn spawns a command. +type Spawn struct { + AName + // Command the command to spawn. + Command []string `json:"command"` +} + +// DoScreenTransition does a screen transition. +type DoScreenTransition struct { + AName + // DelayMs the delay in ms for the screen to freeze before starting the transition. + DelayMs uint16 `json:"delay_ms"` +} + +// Screenshot opens the screenshot UI. +type Screenshot struct { + AName + // ShowPointer whether to show the pointer by default in the screenshot UI. + ShowPointer bool `json:"show_pointer"` +} + +// ScreenshotScreen screenshots the focused screen. +type ScreenshotScreen struct { + AName + // WriteToDisk writes the screenshot to disk in addition to putting it in the clipboard. + WriteToDisk bool `json:"write_to_disk"` + // ShowPointer whether to include the mouse pointer in the screenshot or not. + ShowPointer bool `json:"show_pointer"` +} + +// ScreenshotWindow screenshots a window. +type ScreenshotWindow struct { + AName + // ID the ID of the window to screenshot. + ID uint64 `json:"id"` + // WriteToDisk writes the screenshot to disk in addition to putting it in the clipboard. + WriteToDisk bool `json:"write_to_disk"` +} + +// ToggleKeyboardShortcutsInhibit enables or disables the keyboard shortcuts inhibitor (if any) for the focused surface. +type ToggleKeyboardShortcutsInhibit struct { + AName +} + +// CloseWindow closes a window. +type CloseWindow struct { + AName + // ID the ID of the window to close. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// FullscreenWindow toggles fullscreen on a window. +type FullscreenWindow struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// ToggleWindowedFullscreen toggles windowed (fake) fullscreen on a window. +type ToggleWindowedFullscreen struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// FocusWindow focuses a window by ID. +type FocusWindow struct { + AName + // ID the window ID to focus. + ID uint64 `json:"id"` +} + +// FocusWindowInColumn focuses a window in the focused column by index. +type FocusWindowInColumn struct { + AName + // Index the index of the window in the column. The index starts from 1 for the topmost window. + Index uint8 `json:"index"` +} + +// FocusWindowPrevious focuses the previously focused window. +type FocusWindowPrevious struct { + AName +} + +// FocusColumnLeft focuses the column to the left. +type FocusColumnLeft struct { + AName +} + +// FocusColumnRight focuses the column to the right. +type FocusColumnRight struct { + AName +} + +// FocusColumnFirst focuses the first column. +type FocusColumnFirst struct { + AName +} + +// FocusColumnLast focuses the last column. +type FocusColumnLast struct { + AName +} + +// FocusColumnRightOrFirst focuses the next column on the right, looping if at end. +type FocusColumnRightOrFirst struct { + AName +} + +// FocusColumnLeftOrLast focuses the next column on the left, looping if at start. +type FocusColumnLeftOrLast struct { + AName +} + +// FocusColumn focuses a column by index. +type FocusColumn struct { + AName + // Index the index of the column to focus. The index starts from 1 for the first column. + Index uint `json:"index"` +} + +// FocusWindowOrMonitorUp focuses the window or the monitor above. +type FocusWindowOrMonitorUp struct { + AName +} + +// FocusWindowOrMonitorDown focuses the window or the monitor below. +type FocusWindowOrMonitorDown struct { + AName +} + +// FocusColumnOrMonitorLeft focuses the column or monitor to the left. +type FocusColumnOrMonitorLeft struct { + AName +} + +// FocusColumnOrMonitorRight focuses the column or monitor to the right. +type FocusColumnOrMonitorRight struct { + AName +} + +// FocusWindowDown focuses the window below. +type FocusWindowDown struct { + AName +} + +// FocusWindowUp focuses the window above. +type FocusWindowUp struct { + AName +} + +// FocusWindowDownOrColumnLeft focuses the window below or the column to the left. +type FocusWindowDownOrColumnLeft struct { + AName +} + +// FocusWindowDownOrColumnRight focuses the window above or the column to the right. +type FocusWindowDownOrColumnRight struct { + AName +} + +// FocusWindowUpOrColumnLeft focuses the window above or the column to the left. +type FocusWindowUpOrColumnLeft struct { + AName +} + +// FocusWindowUpOrColumnRight focuses the window above or the column to the right. +type FocusWindowUpOrColumnRight struct { + AName +} + +// FocusWindowOrWorkspaceDown focuses the window or the workspace below. +type FocusWindowOrWorkspaceDown struct { + AName +} + +// FocusWindowOrWorkspaceUp focuses the window or the workspace above. +type FocusWindowOrWorkspaceUp struct { + AName +} + +// FocusWindowTop focuses the topmost window. +type FocusWindowTop struct { + AName +} + +// FocusWindowBottom focuses the bottommost window. +type FocusWindowBottom struct { + AName +} + +// FocusWindowDownOrTop focuses the window below or the topmost window. +type FocusWindowDownOrTop struct { + AName +} + +// FocusWindowUpOrBottom focuses the window above or the bottommost window. +type FocusWindowUpOrBottom struct { + AName +} + +// MoveColumnLeft moves the focused column to the left. +type MoveColumnLeft struct { + AName +} + +// MoveColumnRight moves the focused column to the right. +type MoveColumnRight struct { + AName +} + +// MoveColumnToFirst moves the focused column to the start of the workspace. +type MoveColumnToFirst struct { + AName +} + +// MoveColumnToLast moves the focused column to the end of the workspace. +type MoveColumnToLast struct { + AName +} + +// MoveColumnLeftOrToMonitorLeft moves the focused column to the left, or to the monitor to the left. +type MoveColumnLeftOrToMonitorLeft struct { + AName +} + +// MoveColumnRightOrToMonitorRight moves the focused column to the right, or to the monitor to the right. +type MoveColumnRightOrToMonitorRight struct { + AName +} + +// MoveColumnToIndex moves the focused column to a specific index on its workspace. +type MoveColumnToIndex struct { + AName + // Index is the new index for the column. The index starts from 1 for the first column. + Index uint `json:"index"` +} + +// MoveWindowDown moves the focused window down in a column. +type MoveWindowDown struct { + AName +} + +// MoveWindowUp moves the focused window up in a column. +type MoveWindowUp struct { + AName +} + +// MoveWindowDownOrToWorkspaceDown moves the focused window down in a column or the workspace below. +type MoveWindowDownOrToWorkspaceDown struct { + AName +} + +// MoveWindowUpOrToWorkspaceUp moves the focused window up in a column or to the workspace above. +type MoveWindowUpOrToWorkspaceUp struct { + AName +} + +// ConsumeOrExpelWindowLeft consumes or expels a window left. +type ConsumeOrExpelWindowLeft struct { + AName + // ID the ID of the window to consume or expel. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// ConsumeOrExpelWindowRight consumes or expels a window right. +type ConsumeOrExpelWindowRight struct { + AName + // ID the ID of the window to consume or expel. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// ConsumeWindowIntoColumn consumes the window to the right into the focused column. +type ConsumeWindowIntoColumn struct { + AName +} + +// ExpelWindowFromColumn expels the focused window from the column. +type ExpelWindowFromColumn struct { + AName +} + +// SwapWindowRight swaps the focused window with the one to the right. +type SwapWindowRight struct { + AName +} + +// SwapWindowLeft swaps the focused window with the one to the left. +type SwapWindowLeft struct { + AName +} + +// ToggleColumnTabbedDisplay toggles the focused column between normal and tabbed display. +type ToggleColumnTabbedDisplay struct { + AName +} + +// SetColumnDisplay sets the display mode of the focused column. +type SetColumnDisplay struct { + AName + // Display display mode to set. + Display ColumnDisplay `json:"display"` +} + +// CenterColumn centers the focused column on the screen. +type CenterColumn struct { + AName +} + +// CenterWindow centers a window on the screen. +type CenterWindow struct { + AName + // ID the ID of the window to center. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// CenterVisibleColumns centers all fully visible columns on the screen. +type CenterVisibleColumns struct { + AName +} + +// FocusWorkspaceDown focuses the workspace below. +type FocusWorkspaceDown struct { + AName +} + +// FocusWorkspaceUp focuses the workspace above. +type FocusWorkspaceUp struct { + AName +} + +// FocusWorkspace focuses a workspace by reference (id, index or name). +type FocusWorkspace struct { + AName + // Reference the reference (id, index or name) of the workspace to focus. + Reference WorkspaceReferenceArg `json:"reference"` +} + +// FocusWorkspacePrevious focuses the previous workspace. +type FocusWorkspacePrevious struct { + AName +} + +// MoveWindowToWorkspaceDown moves the focused window to the workspace below. +type MoveWindowToWorkspaceDown struct { + AName +} + +// MoveWindowToWorkspaceUp moves the focused window to the workspace above. +type MoveWindowToWorkspaceUp struct { + AName +} + +// MoveWindowToWorkspace moves a window to a workspace. +type MoveWindowToWorkspace struct { + AName + // WindowID the ID of the window to move. If omitted, uses the focused window. + WindowID uint64 `json:"window_id"` + // Reference the reference (id, index or name) of the workspace to move the window to. + Reference WorkspaceReferenceArg `json:"reference"` + // Focus follows the moved window. + // + // If true (default) and the window to move is focused, the focus will follow the window to the new workspace. + // If false, the focus will remain on the original workspace. + Focus bool `json:"focus"` +} + +// MoveColumnToWorkspaceDown moves the focused column to the workspace below. +type MoveColumnToWorkspaceDown struct { + AName + // Focus follows the target workspace. + // + // If true (default), the focus will follow the column to the new workspace. + // If false, the focus will remain on the original workspace. + Focus bool `json:"focus"` +} + +// MoveColumnToWorkspaceUp moves the focused column to the workspace above. +type MoveColumnToWorkspaceUp struct { + AName + // Focus follows the target workspace. + // + // If true (default), the focus will follow the column to the new workspace. + // If false, the focus will remain on the original workspace. + Focus bool `json:"focus"` +} + +// MoveColumnToWorkspace moves the focused column to a workspace by reference (id, index or name). +type MoveColumnToWorkspace struct { + AName + // Reference the reference (id, index or name) of the workspace to move the column to. + Reference WorkspaceReferenceArg `json:"reference"` + // Focus follows the target workspace. + // + // If true (default), the focus will follow the column to the new workspace. + // If false, the focus will remain on the original workspace. + Focus bool `json:"focus"` +} + +// MoveWorkspaceDown moves the focused workspace below. +type MoveWorkspaceDown struct { + AName +} + +// MoveWorkspaceUp moves the focused workspace above. +type MoveWorkspaceUp struct { + AName +} + +// MoveWorkspaceToIndex moves the focused workspace to a specific index on its monitor. +type MoveWorkspaceToIndex struct { + AName + // Index the new index for the workspace. + Index uint `json:"index"` + // Reference the reference (id, index or name) of the workspace to move. If omitted, uses the focused workspace. + Reference WorkspaceReferenceArg `json:"reference"` +} + +// SetWorkspaceName sets the name of a workspace. +type SetWorkspaceName struct { + AName + // Name the new name of the workspace. + Name string `json:"name"` + // Workspace the reference (id, index or name) of the workspace to name. If omitted, uses the focused workspace. + Workspace WorkspaceReferenceArg `json:"workspace"` +} + +// UnsetWorkspaceName unsets the name of a workspace. +type UnsetWorkspaceName struct { + AName + // Reference the reference (id, index or name) of the workspace to unname. If omitted, uses the focused workspace. + Reference WorkspaceReferenceArg `json:"reference"` +} + +// FocusMonitorLeft focuses the monitor to the left. +type FocusMonitorLeft struct { + AName +} + +// FocusMonitorRight focuses the monitor to the right. +type FocusMonitorRight struct { + AName +} + +// FocusMonitorDown focuses the monitor below. +type FocusMonitorDown struct { + AName +} + +// FocusMonitorUp focuses the monitor above. +type FocusMonitorUp struct { + AName +} + +// FocusMonitorPrevious focuses the previous monitor. +type FocusMonitorPrevious struct { + AName +} + +// FocusMonitorNext focuses the next monitor. +type FocusMonitorNext struct { + AName +} + +// FocusMonitor focuses a monitor by name. +type FocusMonitor struct { + AName + // Output the name of the output to focus. + Output string `json:"output"` +} + +// MoveWindowToMonitorLeft moves the focused window to the monitor to the left. +type MoveWindowToMonitorLeft struct { + AName +} + +// MoveWindowToMonitorRight moves the focused window to the monitor to the right. +type MoveWindowToMonitorRight struct { + AName +} + +// MoveWindowToMonitorDown moves the focused window to the monitor below. +type MoveWindowToMonitorDown struct { + AName +} + +// MoveWindowToMonitorUp moves the focused window to the monitor above. +type MoveWindowToMonitorUp struct { + AName +} + +// MoveWindowToMonitorPrevious moves the focused window to the previous monitor. +type MoveWindowToMonitorPrevious struct { + AName +} + +// MoveWindowToMonitorNext moves the focused window to the next monitor. +type MoveWindowToMonitorNext struct { + AName +} + +// MoveWindowToMonitor moves a window to a specific monitor. +type MoveWindowToMonitor struct { + AName + // ID the ID of the window to move. If omitted, uses the focused window. + ID uint64 `json:"id"` + // Output the target output name. + Output string `json:"output"` +} + +// MoveColumnToMonitorLeft moves the focused column to the monitor to the left. +type MoveColumnToMonitorLeft struct { + AName +} + +// MoveColumnToMonitorRight moves the focused column to the monitor to the right. +type MoveColumnToMonitorRight struct { + AName +} + +// MoveColumnToMonitorDown moves the focused column to the monitor below. +type MoveColumnToMonitorDown struct { + AName +} + +// MoveColumnToMonitorUp moves the focused column to the monitor above. +type MoveColumnToMonitorUp struct { + AName +} + +// MoveColumnToMonitorPrevious moves the focused column to the previous monitor. +type MoveColumnToMonitorPrevious struct { + AName +} + +// MoveColumnToMonitorNext moves the focused column to the next monitor. +type MoveColumnToMonitorNext struct { + AName +} + +// MoveColumnToMonitor moves the focused column to a specific monitor. +type MoveColumnToMonitor struct { + AName + // Output the target output name. + Output string `json:"output"` +} + +// SetWindowWidth changes the width of a window. +type SetWindowWidth struct { + AName + // ID the ID of the window to change the width for. If omitted, uses the focused window. + ID uint64 `json:"id"` + // Change tells how to change the width. + Change SizeChange `json:"change"` +} + +// SetWindowHeight changes the height of a window. +type SetWindowHeight struct { + AName + // ID the ID of the window to change the height for. If omitted, uses the focused window. + ID uint64 `json:"id"` + // Change tells how to change the height. + Change SizeChange `json:"change"` +} + +// ResetWindowHeight resets the height of a window back to automatic. +type ResetWindowHeight struct { + AName + // ID the ID of the window to reset the height for. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// SwitchPresetColumnWidth switches between preset column widths. +type SwitchPresetColumnWidth struct { + AName +} + +// SwitchPresetWindowWidth switches between preset window widths. +type SwitchPresetWindowWidth struct { + AName + // ID the ID of the window to switch the width for. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// SwitchPresetWindowHeight switches between preset window heights. +type SwitchPresetWindowHeight struct { + AName + // ID the ID of the window to switch the height for. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// MaximizeColumn toggles the maximized state of the focused column. +type MaximizeColumn struct { + AName +} + +// SetColumnWidth changes the width of the focused column. +type SetColumnWidth struct { + AName + // Change tells how to change the width. + Change SizeChange `json:"change"` +} + +// ExpandColumnToAvailableWidth expands the focused column to space not taken up by other fully visible columns. +type ExpandColumnToAvailableWidth struct { + AName +} + +// SwitchLayout switches between keyboard layouts. +type SwitchLayout struct { + AName + // Layout the layout to switch to. + Layout LayoutSwitchTarget `json:"layout"` +} + +// ShowHotkeyOverlay shows the hotkey overlay. +type ShowHotkeyOverlay struct { + AName +} + +// MoveWorkspaceToMonitorLeft moves the focused workspace to the monitor to the left. +type MoveWorkspaceToMonitorLeft struct { + AName +} + +// MoveWorkspaceToMonitorRight moves the focused workspace to the monitor to the right. +type MoveWorkspaceToMonitorRight struct { + AName +} + +// MoveWorkspaceToMonitorDown moves the focused workspace to the monitor below. +type MoveWorkspaceToMonitorDown struct { + AName +} + +// MoveWorkspaceToMonitorUp moves the focused workspace to the monitor above. +type MoveWorkspaceToMonitorUp struct { + AName +} + +// MoveWorkspaceToMonitorPrevious moves the focused workspace to the previous monitor. +type MoveWorkspaceToMonitorPrevious struct { + AName +} + +// MoveWorkspaceToMonitorNext moves the focused workspace to the next monitor. +type MoveWorkspaceToMonitorNext struct { + AName +} + +// MoveWorkspaceToMonitor moves a workspace to a specific monitor. +type MoveWorkspaceToMonitor struct { + AName + // Output the target output name. + Output string `json:"output"` + // Reference the reference (id, index or name) of the workspace to move. If omitted, uses the focused workspace. + Reference WorkspaceReferenceArg `json:"reference"` +} + +// ToggleDebugTint toggles a debug tint on windows. +type ToggleDebugTint struct { + AName +} + +// DebugToggleOpaqueRegions toggles visualization of render element opaque regions. +type DebugToggleOpaqueRegions struct { + AName +} + +// DebugToggleDamage toggles visualization of output damage. +type DebugToggleDamage struct { + AName +} + +// ToggleWindowFloating toggles the focused window between floating and tiling layout. +type ToggleWindowFloating struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// MoveWindowToFloating moves a window to the floating layout. +type MoveWindowToFloating struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// MoveWindowToTiling moves a window to the tiling layout. +type MoveWindowToTiling struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// FocusFloating switches focus to the floating layout. +type FocusFloating struct { + AName +} + +// FocusTiling switches focus to the tiling layout. +type FocusTiling struct { + AName +} + +// SwitchFocusBetweenFloatingAndTiling toggles the focus between floating and tiling layout. +type SwitchFocusBetweenFloatingAndTiling struct { + AName +} + +// MoveFloatingWindow moves a floating window or screen. +type MoveFloatingWindow struct { + AName + // ID the ID of the window to move. If omitted, uses the focused window. + ID uint64 `json:"id"` + // X tells how to change the x position. + X PositionChange `json:"x"` + // Y tells how to change the y position. + Y PositionChange `json:"y"` +} + +// ToggleWindowRuleOpacity toggles the opacity of a window. +type ToggleWindowRuleOpacity struct { + AName + // ID the ID of the window to toggle. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// SetDynamicCastWindow sets the dynamic cast target to a window. +type SetDynamicCastWindow struct { + AName + // ID the ID of the window to target. If omitted, uses the focused window. + ID uint64 `json:"id"` +} + +// SetDynamicCastMonitor sets the dynamic cast target to a monitor. +type SetDynamicCastMonitor struct { + AName + // Output the name of the output to target. If omitted, uses the focused output. + Output string `json:"output"` +} + +// ClearDynamicCastTarget clears the dynamic cast target, making it show nothing. +type ClearDynamicCastTarget struct { + AName +} + +// ToggleOverview toggles the overview. +type ToggleOverview struct { + AName +} + +// OpenOverview open the overview. +type OpenOverview struct { + AName +} + +// CloseOverview closes the overview. +type CloseOverview struct { + AName +} + +// ToggleWindowUrgent toggles the urgent status of a window. +type ToggleWindowUrgent struct { + AName + // ID the ID of the window to toggle. + ID uint64 `json:"id"` +} + +// SetWindowUrgent sets the urgent status of a window. +type SetWindowUrgent struct { + AName + // ID the ID of the window to set urgent. + ID uint64 `json:"id"` +} + +// UnsetWindowUrgent unsets the urgent status of a window. +type UnsetWindowUrgent struct { + AName + // ID the ID of the window to unset urgent. + ID uint64 `json:"id"` +} diff --git a/cmd/events.go b/cmd/events.go new file mode 100644 index 0000000..3fc5893 --- /dev/null +++ b/cmd/events.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/soderluk/nirimgr/events" + + "github.com/spf13/cobra" +) + +// eventsCmd listens to the Niri event stream. +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "Listen to niri event stream and act to events.", + Long: `This command listens to the niri event stream, and when an event is seen, + acts on it as defined in the configuration. See config.json rules section.`, + Run: func(cmd *cobra.Command, args []string) { + events.Run() + }, +} + +func init() { + rootCmd.AddCommand(eventsCmd) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..f294271 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os" + "reflect" + "sort" + + "github.com/olekukonko/tablewriter" + "github.com/soderluk/nirimgr/actions" + "github.com/soderluk/nirimgr/events" + "github.com/spf13/cobra" +) + +// listCmd lists the available actions and events defined in nirimgr. +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all available actions or events that nirimgr has defined.", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + command := args[0] + + switch command { + case "actions": + listActions() + case "events": + listEvents() + default: + slog.Error("Unknown command", "cmd", command) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} + +// startTable creates the tablewriter and sets the header. +func startTable() *tablewriter.Table { + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"Name", "Fields"}) + + return table +} + +// listActions lists all the defined actions. +func listActions() { + fmt.Println("nirimgr supports the following actions:") + actionsSorted := msort(actions.ActionRegistry) + + table := startTable() + + for _, name := range actionsSorted { + action := actions.ActionRegistry[name] + model := action() + fields := extractFields(model) + err := table.Append([]string{name, fmt.Sprintf("%+v", fields)}) + if err != nil { + fmt.Printf("could not append %v to table, error: %v", name, err) + continue + } + } + err := table.Render() + if err != nil { + fmt.Printf("could not render table %v", err) + } +} + +// listEvents lists all the defined events. +func listEvents() { + fmt.Println("nirimgr supports the following events:") + eventsSorted := msort(events.EventRegistry) + + table := startTable() + for _, name := range eventsSorted { + event := events.EventRegistry[name] + model := event() + fields := extractFields(model) + err := table.Append([]string{name, fmt.Sprintf("%+v", fields)}) + if err != nil { + fmt.Printf("could not append %v to table, error: %v", name, err) + continue + } + } + err := table.Render() + if err != nil { + fmt.Printf("could not render table %v", err) + } +} + +// msort sorts the given map by keys and returns the sorted list as a slice. +func msort[T any](m map[string]T) []string { + l := make([]string, 0, len(m)) + for k := range m { + l = append(l, k) + } + sort.Strings(l) + return l +} + +// extractFields extracts the fields in a struct. +// +// Note: The AName and EName embedded structs are discarded for convenience. +func extractFields(s any) map[string]any { + m := make(map[string]any) + v := reflect.ValueOf(s) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + for i := range v.NumField() { + name := t.Field(i).Name + if name == "AName" || name == "EName" { + continue + } + value := v.Field(i).Interface() + m[name] = value + } + return m +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..bd34b5f --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,57 @@ +// Package cmd contains all the commands nirimgr supports. +// +// The root command just specifies nirimgr cli-name. Use the sub-commands to use nirimgr. +// +// # Events +// +// The events command starts listening on the Niri event stream. +// +// Usage: nirimgr events +// +// # List +// +// The list command lists all defined events and actions. +// +// Usage: nirimgr list [actions|events] +// +// # Scratch +// +// The scratch is the command to move a window to the scratchpad workspace, +// or show a window from the scratchpad workspace. +// +// Usage: nirimgr scratch [move|show] +// +// # Version +// +// The version command prints out the version and build info of nirimgr. +// +// Usage: nirimgr version +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "nirimgr", + Short: "Commands for managing Niri", + Long: `The nirimgr command can be used to listen to the niri event stream, and + do something when an event comes in. E.g. when a window title hasn't yet been set + when the window opens the first time, niri's own window-rule might not pick up on it. + This command can handle the actions to be done for such cases. E.g. set a window to + floating, when the app id and title of the window matches a rule. + There is also a "scratchpad" command that can be run on a keybind.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/cmd/scratch.go b/cmd/scratch.go new file mode 100644 index 0000000..a7dc886 --- /dev/null +++ b/cmd/scratch.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + + "github.com/soderluk/nirimgr/actions" + "github.com/soderluk/nirimgr/config" + "github.com/soderluk/nirimgr/internal/connection" + "github.com/soderluk/nirimgr/models" + + "github.com/spf13/cobra" +) + +// scratchCmd is the command for handling the scratchpad. +// +// Depending on the arguments, we either show the scratchpad window, +// or move the currently focused window to scratchpad. +var scratchCmd = &cobra.Command{ + Use: "scratch", + Short: "Some kind of support for a scratchpad in niri.", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + command := args[0] + switch command { + case "move": + moveToScratchpad() + case "show": + showScratchpad() + default: + slog.Error("Unknown command", "cmd", command) + os.Exit(1) + } + }, +} + +func init() { + rootCmd.AddCommand(scratchCmd) +} + +// moveToScratchpad moves the currently focused window to the scratchpad workspace. +// +// This requires niri to have a named workspace called "scratchpad". See README.md for more information. +func moveToScratchpad() { + // Move to scratchpad: + // 1. get scratch workspace + // 2. get focused window + // 3. move window to floating + // 4. move floating window to scratchpad workspace, focus=false + scratchpad, _ := getWorkspace(config.Config.ScratchpadWorkspace) + focusedWindow, _ := getFocusedWindow() + + actionList := []actions.Action{ + actions.MoveWindowToWorkspace{ + AName: actions.AName{Name: "MoveWindowToWorkspace"}, + WindowID: focusedWindow.ID, + Reference: actions.WorkspaceReferenceArg{ + ID: scratchpad.ID, + }, + Focus: false, + }, + actions.MoveWindowToFloating{ + AName: actions.AName{Name: "MoveWindowToFloating"}, + ID: focusedWindow.ID, + }, + } + + for _, action := range actionList { + connection.PerformAction(action) + } +} + +// showScratchpad moves the last window from the scratchpad workspace to the currently active workspace. +// +// This requires niri to have a named workspace called "scratchpad". See README.md for more information. +func showScratchpad() { + // Show scratchpad: + // 1. get current workspace + // 2. list all windows in scratchpad workspace + // 3. take latest window and move it to the current workspace + + scratchpad, _ := getWorkspace(config.Config.ScratchpadWorkspace) + focusedWorkspace, _ := getWorkspace("focused") + + response, _ := connection.PerformRequest(models.Windows) + + resp := <-response + + var windows []*models.Window + + if err := json.Unmarshal(resp.Ok["Windows"], &windows); err != nil { + slog.Error("Could not unmarshal windows", "error", err.Error()) + os.Exit(1) + } + // Filter the scratchpad windows. + workspaceWindows := filterWindows(windows, func(w *models.Window) bool { + return w.WorkspaceID == scratchpad.ID + }) + + // Return the last window in the list, if we have any. + if len(workspaceWindows) > 0 { + window := workspaceWindows[len(workspaceWindows)-1] + action := actions.MoveWindowToWorkspace{ + AName: actions.AName{Name: "MoveWindowToWorkspace"}, + WindowID: window.ID, + Reference: actions.WorkspaceReferenceArg{ + ID: focusedWorkspace.ID, + }, + Focus: true, + } + connection.PerformAction(action) + } +} + +// getWorkspace returns a workspace. +// +// Given the wtype, we return either the named workspace, focused or active workspace. +func getWorkspace(wtype string) (*models.Workspace, error) { + response, err := connection.PerformRequest(models.Workspaces) + if err != nil { + return nil, err + } + + resp := <-response + + var workspaces []*models.Workspace + if err := json.Unmarshal(resp.Ok["Workspaces"], &workspaces); err != nil { + return nil, err + } + + // If the wtype is not given, default to "scratchpad" + if wtype == "" { + wtype = "scratchpad" + } + // If the scratchpad workspace is not configured, default to "scratchpad" + scratchpadWorkspace := config.Config.ScratchpadWorkspace + if scratchpadWorkspace == "" { + scratchpadWorkspace = "scratchpad" + } + for _, workspace := range workspaces { + switch wtype { + case scratchpadWorkspace: + if workspace.Name == scratchpadWorkspace { + return workspace, nil + } + case "focused": + if workspace.IsFocused { + return workspace, nil + } + case "active": + if workspace.IsActive { + return workspace, nil + } + default: + return nil, fmt.Errorf("invalid type '%v'", wtype) + } + } + return nil, fmt.Errorf("no workspace found matching '%v'", wtype) +} + +// getFocusedWindow returns the currently focused window. +func getFocusedWindow() (*models.Window, error) { + response, err := connection.PerformRequest(models.FocusedWindow) + if err != nil { + return nil, err + } + + resp := <-response + + var window *models.Window + if err := json.Unmarshal(resp.Ok["FocusedWindow"], &window); err != nil { + return nil, err + } + return window, nil +} + +// filterWindows returns a slice of window models depending on the filtering function. +func filterWindows(data []*models.Window, f func(*models.Window) bool) []*models.Window { + w := make([]*models.Window, 0) + for _, e := range data { + if f(e) { + w = append(w, e) + } + } + + return w +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..270c2c8 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "runtime" + + "github.com/soderluk/nirimgr/config" + "github.com/spf13/cobra" +) + +// versionCmd prints out build information about nirimgr +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version", + Long: "Prints the version number and build information about nirimgr", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("nirimgr " + buildInfo()) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +// buildInfo returns the build information about nirimgr +func buildInfo() string { + info := config.Version + info += fmt.Sprintf(" (%s %s %s %s)", + runtime.Version(), + runtime.GOARCH, + runtime.GOOS, + config.Date, + ) + + return info +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..105c6e7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,82 @@ +// Package config handles the configuration of nirimgr. +// +// See an example configuration in the README.md. +package config + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/soderluk/nirimgr/models" +) + +var ( + // Version contains the current version of nirimgr + Version string = "git" + // Date is the date when nirimgr was built + Date string = time.Now().Format("2006-01-02") + // Config contains all configurations + Config *models.Config +) + +// getConfigFile returns the config.json file. +// +// We first try locally, if we're e.g. running nirimgr with go run main.go, +// if that's not found, try ~/.config/nirimgr/config.json. +func getConfigFile(filename string) (*os.File, error) { + if !strings.Contains(filename, "config.json") { + slog.Error("Invalid configuration name", "got", filename, "want", "*config.json") + return nil, fmt.Errorf("invalid configuration filename") + } + f, err := os.Open("config/" + filename) + if err != nil { + slog.Warn("Could not open local config file, trying ~/.config/nirimgr/" + filename) + + homeDir, homeErr := os.UserHomeDir() + if homeErr != nil { + return nil, homeErr + } + configPath := filepath.Join(homeDir, ".config", "nirimgr", filename) + f, err = os.Open(configPath) + if err != nil { + return nil, err + } + } + return f, nil +} + +// newConfig configures the application. +// +// Returns the decoded data from the specified config file in the config struct. +func newConfig(filename string) (*models.Config, error) { + f, err := getConfigFile(filename) + if err != nil { + return nil, err + } + + defer f.Close() + var c *models.Config + err = json.NewDecoder(f).Decode(&c) + + slog.Debug("Configured", "config", c) + return c, err +} + +// Configure reads the configuration file (json) to get the configuration. +// +// Sets the global Config, so it can be accessed from anywhere. +// See example configuration in the README.md. +func Configure(filename string) error { + cfg, err := newConfig(filename) + if err != nil { + slog.Error("Could not read configuration from file.", "error", err.Error()) + return err + } + Config = cfg + return nil +} diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..ccc3cfd --- /dev/null +++ b/config/config.json @@ -0,0 +1,54 @@ +{ + "logLevel": "DEBUG", + "rules": [ + { + "match": [ + { + "title": "Bitwarden", + "appId": "zen" + } + ], + "actions": { + "MoveWindowToFloating": {}, + "SetWindowWidth": { + "change": { + "SetFixed": 400 + } + }, + "SetWindowHeight": { + "change": { + "SetFixed": 600 + } + } + } + }, + { + "match": [ + { + "appId": "org.gnome.Calculator" + } + ], + "actions": { + "MoveWindowToFloating": {}, + "MoveFloatingWindow": { + "x": { + "SetFixed": 800 + }, + "y": { + "SetFixed": 200 + } + }, + "SetWindowWidth": { + "change": { + "SetFixed": 50 + } + }, + "SetWindowHeight": { + "change": { + "SetFixed": 50 + } + } + } + } + ] +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..a67fa10 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,99 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func createTempConfigFile(t *testing.T, content string) (string, func()) { + dir := t.TempDir() + file := filepath.Join(dir, "test_config.json") + err := os.WriteFile(file, []byte(content), 0o644) + if err != nil { + t.Fatalf("Failed to write temp config file: %v", err) + } + return file, func() { + err := os.Remove(file) + if err != nil { + t.Logf("Could not remove file %v", file) + } + } +} + +func TestNewConfig_LocalFile(t *testing.T) { + configContent := `{"logLevel":"debug"}` + file, cleanup := createTempConfigFile(t, configContent) + defer cleanup() + + // Move file to expected local path + if err := os.MkdirAll("config", 0o755); err != nil { + t.Logf("could not create directory 'config'") + } + if err := os.Rename(file, "config/test_config.json"); err != nil { + t.Logf("could not rename file %v", file) + } + defer os.Remove("config/test_config.json") + + cfg, err := newConfig("test_config.json") + if err != nil { + t.Fatalf("NewConfig failed: %v", err) + } + if cfg.LogLevel != "debug" { + t.Errorf("Unexpected config: %+v", cfg) + } +} + +func TestNewConfig_HomeFallback(t *testing.T) { + configContent := `{"logLevel":"info"}` + file, cleanup := createTempConfigFile(t, configContent) + defer cleanup() + + homeDir, _ := os.UserHomeDir() + configDir := filepath.Join(homeDir, ".config", "nirimgr") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Logf("could not create directory '%v'", configDir) + } + configPath := filepath.Join(configDir, "test_config.json") + if err := os.Rename(file, configPath); err != nil { + t.Logf("could not rename file '%v' to '%v'", file, configPath) + } + defer os.Remove(configPath) + + cfg, err := newConfig("test_config.json") + if err != nil { + t.Fatalf("NewConfig failed: %v", err) + } + if cfg.LogLevel != "info" { + t.Errorf("Unexpected config: %+v", cfg) + } +} + +func TestConfigure_SetsGlobalConfig(t *testing.T) { + configContent := `{"logLevel":"warn"}` + file, cleanup := createTempConfigFile(t, configContent) + defer cleanup() + if err := os.MkdirAll("config", 0o755); err != nil { + t.Logf("could not create directory 'config'") + } + if err := os.Rename(file, "config/test_config.json"); err != nil { + t.Logf("could not rename file '%v'", file) + } + defer os.Remove("config/test_config.json") + + err := Configure("test_config.json") + if err != nil { + t.Fatalf("Configure failed: %v", err) + } + if Config == nil || Config.LogLevel != "warn" { + t.Errorf("Config not set correctly: %+v", Config) + } +} + +func TestGetConfigFile_Error(t *testing.T) { + os.Remove("config/test_config.json") + cfg, err := getConfigFile("test_config.json") + if err == nil || cfg != nil { + t.Error("Expected error when config files are missing") + } +} diff --git a/events/events.go b/events/events.go new file mode 100644 index 0000000..7ae0e5d --- /dev/null +++ b/events/events.go @@ -0,0 +1,173 @@ +// Package events handles events from Niri event-stream. +// +// Listens to the event stream on the NIRI_SOCKET and reacts to +// events in a specified manner. +package events + +import ( + "encoding/json" + "fmt" + "log/slog" + + "github.com/soderluk/nirimgr/actions" + "github.com/soderluk/nirimgr/config" + "github.com/soderluk/nirimgr/internal/common" + "github.com/soderluk/nirimgr/internal/connection" + "github.com/soderluk/nirimgr/models" +) + +// Run starts listening on the event stream, and handle the events. +// +// Currently we only handle a few events, `WindowsChanged`, `WindowOpenedOrChanged` and `WindowClosed`. +// If you need to handle more events, add them in the switch statement. +// Initially the thought was to support the "Dynamic open-float script, for Bitwarden and other windows that set title/app-id late": +// https://github.com/YaLTeR/niri/discussions/1599 +// But it doesn't stop us from handling other types of events as well. +func Run() { + events, err := EventStream() + if err != nil { + slog.Error("Could not get events", "error", err.Error()) + panic(err) + } + existingWindows := make(map[uint64]*models.Window) + + for event := range events { + switch ev := event.(type) { + case *WindowsChanged: + slog.Debug("Handling event", "name", common.Repr(ev)) + for _, win := range ev.Windows { + matchWindowAndPerformActions(win, existingWindows) + existingWindows[win.ID] = win + } + case *WindowOpenedOrChanged: + slog.Debug("Handling event", "name", common.Repr(ev)) + matchWindowAndPerformActions(ev.Window, existingWindows) + existingWindows[ev.Window.ID] = ev.Window + case *WindowClosed: + slog.Debug("Handling event", "name", common.Repr(ev)) + delete(existingWindows, ev.ID) + default: + } + } +} + +// EventStream listens on the events in Niri event-stream. +// +// The function will use a goroutine to return the event models. +// Inspiration from: https://github.com/probeldev/niri-float-sticky +func EventStream() (<-chan any, error) { + stream := make(chan any) + socket := connection.Socket() + + go func() { + defer connection.PutSocket(socket) + defer socket.Close() + defer close(stream) + + for line := range socket.Recv() { + if len(line) < 2 { + continue + } + + var event map[string]json.RawMessage + + if err := json.Unmarshal(line, &event); err != nil { + slog.Error("Error decoding JSON", "error", err.Error()) + continue + } + _, model, err := ParseEvent(event) + if err != nil { + slog.Error("Could not parse event!", "event", event, "error", err.Error()) + } + stream <- model + } + }() + + if err := socket.Send(fmt.Sprintf("\"%s\"", models.EventStream)); err != nil { + return nil, fmt.Errorf("error requesting event stream: %w", err) + } + + return stream, nil +} + +// ParseEvent parses the given event into it's struct. +// +// Returns the name, model, error. The name is the name of the event, model is the populated struct. +func ParseEvent(event map[string]json.RawMessage) (string, any, error) { + for name, raw := range event { + model := FromRegistry(name, raw) + if model == nil { + continue + } + slog.Debug("Parsed event into", "model", common.Repr(model)) + return name, model, nil + } + return "", nil, fmt.Errorf("no event found") +} + +// matchWindowAndPerformActions updates the window struct if it matches the rule as configured in the config file. +// +// If the matching window has any defined actions in the config, run them sequentially on the matched window. +// The functionality is taken from the "Dynamic open-float script, for Bitwarden and other windows that set title/app-id late": +// https://github.com/YaLTeR/niri/discussions/1599 +func matchWindowAndPerformActions(window *models.Window, existingWindows map[uint64]*models.Window) { + window.Matched = false + if existing, ok := existingWindows[window.ID]; ok { + window.Matched = existing.Matched + } + + matchedBefore := window.Matched + window.Matched = false + var rawActions map[string]json.RawMessage + for _, r := range config.Config.GetRules() { + if r.Matches(*window) { + window.Matched = true + if len(r.Actions) > 0 { + rawActions = r.Actions + } + break + } + } + actionList := actions.ParseRawActions(rawActions) + if window.Matched && !matchedBefore { + for _, a := range actionList { + // Set the action Id dynamically here. + a = actions.SetActionID(a, window.ID) + connection.PerformAction(a) + } + } +} + +// FromRegistry returns the populated model from the EventRegistry by given name. +func FromRegistry(name string, data []byte) Event { + model, ok := EventRegistry[name] + if !ok { + slog.Error("Could not get event model for event", "name", name) + return nil + } + event := model() + if err := json.Unmarshal(data, event); err != nil { + slog.Error("Could not unmarshal event", "name", name, "error", err.Error()) + return nil + } + return event +} + +// EventRegistry contains all the events Niri currently sends. +// +// The key needs to be the event name, and it should return the correct event model, and set +// its EName embedded struct. If you know of a better way to handle this, please let me know. +var EventRegistry = map[string]func() Event{ + "WorkspacesChanged": func() Event { return &WorkspacesChanged{EName: EName{Name: "WorkspacesChanged"}} }, + "WorkspaceUrgencyChanged": func() Event { return &WorkspaceUrgencyChanged{EName: EName{Name: "WorkspaceUrgencyChanged"}} }, + "WorkspaceActivated": func() Event { return &WorkspaceActivated{EName: EName{Name: "WorkspaceActivated"}} }, + "WorkspaceActiveWindowChanged": func() Event { return &WorkspaceActiveWindowChanged{EName: EName{Name: "WorkspaceActiveWindowChanged"}} }, + "WindowsChanged": func() Event { return &WindowsChanged{EName: EName{Name: "WindowsChanged"}} }, + "WindowOpenedOrChanged": func() Event { return &WindowOpenedOrChanged{EName: EName{Name: "WindowOpenedOrChanged"}} }, + "WindowClosed": func() Event { return &WindowClosed{EName: EName{Name: "WindowClosed"}} }, + "WindowFocusChanged": func() Event { return &WindowFocusChanged{EName: EName{Name: "WindowFocusChanged"}} }, + "WindowUrgencyChanged": func() Event { return &WindowUrgencyChanged{EName: EName{Name: "WindowUrgencyChanged"}} }, + "KeyboardLayoutsChanged": func() Event { return &KeyboardLayoutsChanged{EName: EName{Name: "KeyboardLayoutsChanged"}} }, + "KeyboardLayoutSwitched": func() Event { return &KeyboardLayoutSwitched{EName: EName{Name: "KeyboardLayoutSwitched"}} }, + "OverviewOpenedOrClosed": func() Event { return &OverviewOpenedOrClosed{EName: EName{Name: "OverviewOpenedOrClosed"}} }, +} diff --git a/events/events_test.go b/events/events_test.go new file mode 100644 index 0000000..08baa42 --- /dev/null +++ b/events/events_test.go @@ -0,0 +1,92 @@ +package events + +import ( + "encoding/json" + "testing" + + "github.com/soderluk/nirimgr/config" + "github.com/soderluk/nirimgr/models" +) + +type DummyEvent struct { + EName + Field string `json:"field"` +} + +func TestParseEvent(t *testing.T) { + EventRegistry["dummy_event"] = func() Event { return &DummyEvent{EName: EName{Name: "dummy_event"}} } + defer delete(EventRegistry, "dummy_event") + + event := map[string]json.RawMessage{ + "dummy_event": json.RawMessage(`{"field": "value"}`), + } + key, model, err := ParseEvent(event) + if err != nil { + t.Fatalf("ParseEvent failed: %v", err) + } + if key != "dummy_event" { + t.Errorf("Expected key 'dummy_event', got '%s'", key) + } + m, ok := model.(*DummyEvent) + if !ok { + t.Fatalf("Expected *DummyEvent, got %T", model) + } + if m.Field != "value" { + t.Errorf("Expected field 'value', got '%s'", m.Field) + } +} + +func TestParseEvent_NoEventFound(t *testing.T) { + event := map[string]json.RawMessage{ + "unknown_event": json.RawMessage(`{"field": "value"}`), + } + key, model, err := ParseEvent(event) + if err == nil || err.Error() != "no event found" { + t.Errorf("Expected error 'no event found', got %v", err) + } + if key != "" || model != nil { + t.Errorf("Expected key '', model nil, got key '%s', model %v", key, model) + } +} + +func TestParseEvent_UnmarshalError(t *testing.T) { + EventRegistry["dummy_event"] = func() Event { return &DummyEvent{EName: EName{Name: "dummy_event"}} } + defer delete(EventRegistry, "dummy_event") + + event := map[string]json.RawMessage{ + "dummy_event": []byte(`{"field": }`), + } + key, model, err := ParseEvent(event) + if err == nil { + t.Errorf("Expected unmarshal error, got nil") + } + if key != "" { + t.Errorf("Expected key 'dummy_event', got '%s'", key) + } + if model != nil { + t.Errorf("Expected model nil, got %v", model) + } +} + +func TestUpdateMatched_MatchAndAction(t *testing.T) { + window := &models.Window{ID: 1, Title: "Test window", AppID: "test-app"} + allWindows := map[uint64]*models.Window{} + + // Simulate config + cfg := &models.Config{ + Rules: []models.Rule{ + { + Match: []models.Match{ + { + AppID: "test-app", + }, + }, + }, + }, + } + config.Config = cfg + matchWindowAndPerformActions(window, allWindows) + if !window.Matched { + t.Errorf("Expected window to be matched") + } +} diff --git a/events/models.go b/events/models.go new file mode 100644 index 0000000..28747cf --- /dev/null +++ b/events/models.go @@ -0,0 +1,127 @@ +package events + +import "github.com/soderluk/nirimgr/models" + +// If more events are added in Niri, we must define them here, and add them to the EventRegistry. + +// Event defines the "base" interface for all the events. +// +// NOTE: We have to use GetName, since the field is called Name. +type Event interface { + GetName() string +} + +// EName defines the name of the event. +type EName struct { + Name string +} + +// GetName returns the event name. +func (e EName) GetName() string { + return e.Name +} + +// WorkspacesChanged when the workspace configuration has changed. +type WorkspacesChanged struct { + EName + // Workspaces contains the new workspace configuration. + // + // This configuration completely replaces the previous configuration. If any workspaces + // are missing from here, then they were deleted. + Workspaces []models.Workspace `json:"workspaces"` +} + +// WorkspaceUrgencyChanged when the workspace urgency changed. +type WorkspaceUrgencyChanged struct { + EName + // ID the ID of the workspace. + ID uint64 `json:"id"` + // Urgent tells if this workspace has an urgent window. + Urgent bool `json:"urgent"` +} + +// WorkspaceActivated when a workspace was activated on an output. +type WorkspaceActivated struct { + EName + // ID the ID of the newly active workspace. + ID uint64 `json:"id"` + // Focused tells if this workspace also became focused. + // + // If true, this is now the single focused workspace. All other workspaces are no longer + // focused, but they may remain active on their respective outputs. + Focused bool `json:"focused"` +} + +// WorkspaceActiveWindowChanged when an active window changed on a workspace. +type WorkspaceActiveWindowChanged struct { + EName + // WorkspaceID the ID of the workspace on which the active window changed. + WorkspaceID uint64 `json:"workspace_id"` + // ActiveWindowID the ID of the new active window, if any. + ActiveWindowID uint64 `json:"active_window_id"` +} + +// WindowsChanged when the window configuration has changed. +type WindowsChanged struct { + EName + // Windows contains the new window configuration. + // + // This configuration completely replaces the previous configuration. If any windows + // are missing from here, then they were closed. + Windows []*models.Window `json:"windows"` +} + +// WindowOpenedOrChanged when a new toplevel window was opened, or an existing toplevel window changed. +type WindowOpenedOrChanged struct { + EName + // Window contains the new or updated window. + // + // If the window is focused, all other windows are no longer focused. + Window *models.Window `json:"window"` +} + +// WindowClosed when a toplevel window was closed. +type WindowClosed struct { + EName + // ID the ID of the removed window. + ID uint64 `json:"id"` +} + +// WindowFocusChanged when a window focus changed. +// +// All other windows are no longer focused. +type WindowFocusChanged struct { + EName + // ID the ID of the newly focused window, or omitted if no window is now focused. + ID uint64 `json:"id"` +} + +// WindowUrgencyChanged when a window urgency changed. +type WindowUrgencyChanged struct { + EName + // ID the ID of the window. + ID uint64 `json:"id"` + // Urgent the new urgency state of the window. + Urgent bool `json:"urgent"` +} + +// KeyboardLayoutsChanged when the configured keyboard layouts have changed. +type KeyboardLayoutsChanged struct { + EName + // KeyboardLayouts contains the new keyboard layout configuration. + KeyboardLayouts models.KeyboardLayouts `json:"keyboard_layouts"` +} + +// KeyboardLayoutSwitched when the keyboard layout switched. +type KeyboardLayoutSwitched struct { + EName + // Idx contains the index of the newly active layout. + Idx uint8 `json:"idx"` +} + +// OverviewOpenedOrClosed when the overview was opened or closed. +type OverviewOpenedOrClosed struct { + EName + // IsOpen contains the new state of the overview. + IsOpen bool `json:"is_open"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2463ea --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/soderluk/nirimgr + +go 1.23.5 + +require ( + github.com/olekukonko/tablewriter v1.0.8 + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/fatih/color v1.15.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect + github.com/olekukonko/ll v0.0.8 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d506d80 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= +github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= +github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.0.8 h1:f6wJzHg4QUtJdvrVPKco4QTrAylgaU0+b9br/lJxEiQ= +github.com/olekukonko/tablewriter v1.0.8/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/common/common.go b/internal/common/common.go new file mode 100644 index 0000000..e0b4c64 --- /dev/null +++ b/internal/common/common.go @@ -0,0 +1,55 @@ +// Package common contains commonly used functions. +package common + +import ( + "log/slog" + "os" + "reflect" + + "github.com/soderluk/nirimgr/config" +) + +// SetupLogger sets up the logging for the application. +// +// The log level can be defined as the string "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" +// in the configuration file. E.g. "LogLevel": "INFO". +// Defaults to "DEBUG". +func SetupLogger() { + logLevel := parseLogLevel(config.Config.LogLevel) + handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}) + logger := slog.New(handler) + + slog.SetDefault(logger) +} + +// Repr returns the name of the given model. +// +// This can be used to print out the model name. +func Repr(model any) string { + if model == nil { + return "" + } + + t := reflect.TypeOf(model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + return t.Name() +} + +// parseLogLevel parses the given log level string to slog log level. +func parseLogLevel(level string) slog.Level { + switch level { + case "DEBUG": + return slog.LevelDebug + case "INFO": + return slog.LevelInfo + case "WARN": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelDebug + } +} diff --git a/internal/connection/connection.go b/internal/connection/connection.go new file mode 100644 index 0000000..ea4fc8d --- /dev/null +++ b/internal/connection/connection.go @@ -0,0 +1,174 @@ +// Package connection contains functionality to communicate with the Niri socket. +// +// We connect to the Niri socket NIRI_SOCKET, and perform actions and requests to it. +// Actions are same as niri msg action , where is the PascalCase of the +// dashed niri action, e.g. move-window-to-floating -> MoveWindowToFloating etc. +// Requests are the simple requests, e.g. niri msg , where is one of the +// niri requests to the socket. niri msg outputs -> Outputs. +// This is heavily inspired by https://github.com/probeldev/niri-float-sticky +package connection + +import ( + "bufio" + "encoding/json" + "fmt" + "log/slog" + "net" + "os" + "sync" + + "github.com/soderluk/nirimgr/actions" + "github.com/soderluk/nirimgr/models" +) + +// NiriSocket contains the connection to the socket. +type NiriSocket struct { + conn net.Conn +} + +// pool contains the pool of socket connections. +var pool = sync.Pool{ + New: func() any { + conn, err := net.Dial("unix", os.Getenv("NIRI_SOCKET")) + if err != nil { + slog.Error("Failed to connect to NIRI_SOCKET", "error", err.Error()) + panic(err) + } + return &NiriSocket{conn: conn} + }, +} + +// Socket returns the NiriSocket from the pool. +func Socket() *NiriSocket { + sock, ok := pool.Get().(*NiriSocket) + if !ok { + slog.Error("Could not get socket") + panic("could not get socket") + } + return sock +} + +// PutSocket adds the socket to the pool. +func PutSocket(socket *NiriSocket) { + pool.Put(socket) +} + +// Send writes the request to the socket. +func (s *NiriSocket) Send(req string) error { + _, err := fmt.Fprintf(s.conn, "%s\n", req) + return err +} + +// Recv reads the data from the socket. +func (s *NiriSocket) Recv() <-chan []byte { + lines := make(chan []byte) + + go func() { + defer func() { _ = s.conn.Close() }() + defer close(lines) + + scanner := bufio.NewScanner(s.conn) + for scanner.Scan() { + lines <- scanner.Bytes() + } + + if err := scanner.Err(); err != nil { + slog.Error("Could not scan response from socket", "error", err.Error()) + } + }() + + return lines +} + +// Close closes the socket connection. +func (s *NiriSocket) Close() { + _ = s.conn.Close() +} + +// PerformAction performs the given action. +// +// The action is one of the actions that niri can handle. +// The supported actions are defined here: https://docs.rs/niri-ipc/latest/niri_ipc/enum.Action.html +func PerformAction(action actions.Action) bool { + socket := Socket() + name := action.GetName() + slog.Debug("PerformAction", "name", name, "action", action) + + // Convert the action to a map. + actionData, err := structToMap(action) + if err != nil { + slog.Error("Could not convert action to map", "error", err.Error()) + return false + } + // We need the request as a string to be sent to the socket. + request, err := structToString(map[string]any{ + "Action": map[string]any{ + name: actionData, + }, + }) + if err != nil { + slog.Error("Could not convert action request to string", "error", err.Error()) + return false + } + if err := socket.Send(string(request)); err != nil { + slog.Error("Error sending request", "error", err.Error()) + return false + } + return true +} + +// PerformRequest sends a simple request to the niri socket. +// +// The request is one of the requests that niri can handle. +// The supported requests are defined here: https://docs.rs/niri-ipc/latest/niri_ipc/enum.Request.html +func PerformRequest(req models.NiriRequest) (<-chan models.Response, error) { + stream := make(chan models.Response) + socket := Socket() + + go func() { + defer PutSocket(socket) + defer socket.Close() + defer close(stream) + + for line := range socket.Recv() { + if len(line) < 2 { + continue + } + + var response models.Response + + if err := json.Unmarshal(line, &response); err != nil { + slog.Error("Error decoding JSON", "error", err.Error()) + continue + } + stream <- response + } + }() + + if err := socket.Send(fmt.Sprintf("\"%s\"", req)); err != nil { + return nil, fmt.Errorf("error requesting event stream: %w", err) + } + + return stream, nil +} + +// structToMap converts a go struct to a map. +func structToMap(a any) (map[string]any, error) { + var m map[string]any + b, err := json.Marshal(a) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, &m) + return m, err +} + +// structToString converts a go struct to a string. +func structToString(a any) (string, error) { + b, err := json.Marshal(a) + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a486bc0 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +// Package main handles the commands that nirimgr supports. +// +// Currently supports the following commands: +// +// # events +// +// The events command +// +// nirimgr events +// +// listens on the niri event-stream, and reacts +// to specified events. You need to configure the rules in the config.json file. +// It supports the same matching on windows as the niri config window-rule. Specify +// matches and excludes containing the title or appId of the window you want to match. +// Then provide which actions you want to do on the matched window, e.g. MoveWindowToFloating +// will move the matched window to floating. +// +// # scratch +// +// The scratch command +// +// nirimgr scratch [move|show] +// +// takes one argument, either `move` or `show`. Move will move the +// currently focused window to the scratchpad workspace. Show will take the last window +// on the scratchpad workspace, and move it to the currently focused workspace. +// +// # list +// +// The list command +// +// nirimgr list +// +// lists all the available actions and events defined in nirimgr. +package main + +import ( + "github.com/soderluk/nirimgr/cmd" + "github.com/soderluk/nirimgr/config" + "github.com/soderluk/nirimgr/internal/common" +) + +func main() { + if err := config.Configure("config.json"); err != nil { + panic(err) + } + common.SetupLogger() + cmd.Execute() +} diff --git a/models/enums.go b/models/enums.go new file mode 100644 index 0000000..e7d6338 --- /dev/null +++ b/models/enums.go @@ -0,0 +1,114 @@ +// Contains enums for niri models. +// +// These are not really structs, nor are they actions or events either, +// but can be used in models. +package models + +// ModeToSet is the output mode to set. +type ModeToSet struct { + // Automatic tells that niri will pick the mode automatically. + Automatic string `json:"Automatic,omitempty"` + // Specific tells that niri should pick a specific mode. + Specific ConfiguredMode `json:"Specific,omitempty"` +} + +// OutputAction is the output actions that niri can perform. +type OutputAction struct { + // Off tells niri to turn off the output. + Off string `json:"Off,omitempty"` + // On tells niri to turn on the output. + On string `json:"On,omitempty"` + // Mode tells niri which output mode to set. + Mode struct { + // Mode is the mode to set, or "auto" for automatic selection. + // + // Run niri msg outputs to see the available modes. + Mode ModeToSet `json:"mode"` + } `json:"Mode"` + // Scale tells niri which output scale to set. + Scale struct { + // Scale is the scale factor to set, or "auto" for automatic selection. + Scale ScaleToSet `json:"scale"` + } `json:"Scale"` + // Transform tells niri which output transform to set. + Transform struct { + // Transform is the transform to set, counter-clockwise. + Transform Transform `json:"transform"` + } `json:"Transform"` + // Position tells niri which output position to set. + Position struct { + // Position is the position to set, or "auto" for automatic selection. + Position PositionToSet `json:"position"` + } `json:"Position"` + // Vrr tells niri which variable refresh rate to set. + Vrr struct { + // Vrr is the variable refresh rate mode to set. + Vrr VrrToSet `json:"vrr"` + } `json:"Vrr"` +} + +// OutputConfigChanged is the output configuration change result. +type OutputConfigChanged struct { + // Applied tells if the target output was connected and the change was applied. + Applied string `json:"Applied"` + // OutputWasMissing tells if the target output was not found, the change will be applied when it's connected. + OutputWasMissing string `json:"OutputWasMissing"` +} + +// PositionToSet is the output position to set. +type PositionToSet struct { + // Automatic tells niri to position the output automatically. + Automatic string `json:"Automatic,omitempty"` + // Specific tells niri to use a specific position. + Specific ConfiguredPosition `json:"Specific,omitempty"` +} + +// ScaleToSet is the output scale to set. +type ScaleToSet struct { + // Automatic tells niri to pick the scale automatically. + Automatic string `json:"Automatic,omitempty"` + // Specific tells niri to set a specific scale. + Specific float64 `json:"Specific,omitempty"` +} + +// Layer is the layer-shell layer. +type Layer struct { + // Background is the background layer. + Background string `json:"Background,omitempty"` + // Bottom is the bottom layer. + Bottom string `json:"Bottom,omitempty"` + // Top is the top layer. + Top string `json:"Top,omitempty"` + // Overlay is the overlay layer. + Overlay string `json:"Overlay,omitempty"` +} + +// LayerSurfaceKeyboardInteractivity is the keyboard interactivity modes for a layer-shell surface. +type LayerSurfaceKeyboardInteractivity struct { + // None tells that the surface cannot receive keyboard focus. + None string `json:"None,omitempty"` + // Exclusive tells that the surface receives keyboard focus whenever possible. + Exclusive string `json:"Exclusive,omitempty"` + // OnDemand tells that the surface receives keyboard focus on demand, e.g. when clicked. + OnDemand string `json:"OnDemand,omitempty"` +} + +// Transform is the output transformation, which goes counter-clockwise. +type Transform struct { + // Normal is untransformed. + Normal string `json:"Normal,omitempty"` + // Rotate90 is rotated by 90 deg. + Rotate90 string `json:"_90,omitempty"` + // Rotate180 is rotated by 180 deg. + Rotate180 string `json:"_180,omitempty"` + // Rotate270 is rotated by 270 deg. + Rotate270 string `json:"_270,omitempty"` + // Flipped is flipped horizontally. + Flipped string `json:"Flipped,omitempty"` + // Flipped90 is rotated by 90 deg and flipped horizontally. + Flipped90 string `json:"Flipped90,omitempty"` + // Flipped180 is flipped vertically. + Flipped180 string `json:"Flipped180,omitempty"` + // Flipped270 is rotated by 270 deg and flipped horizontally. + Flipped270 string `json:"Flipped270,omitempty"` +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..d4501d9 --- /dev/null +++ b/models/models.go @@ -0,0 +1,326 @@ +// Package models contains all the necessary models for different niri objects. +package models + +import ( + "encoding/json" + "log/slog" + "regexp" +) + +// NiriRequest is the representation of a simple niri request. +// +// The request is sent to the socket as a string, e.g. "Windows" returns all the current windows. +type NiriRequest string + +const ( + // Outputs lists connected outputs + Outputs NiriRequest = "Outputs" + // Workspaces lists workspaces + Workspaces NiriRequest = "Workspaces" + // Windows lists open windows + Windows NiriRequest = "Windows" + // Layers lists open layer-shell surfaces + Layers NiriRequest = "Layers" + // ListKeyboardLayouts lists the configured keyboard layouts + ListKeyboardLayouts NiriRequest = "KeyboardLayouts" + // FocusedOutput prints information about the focused output + FocusedOutput NiriRequest = "FocusedOutput" + // FocusedWindow prints information about the focused window + FocusedWindow NiriRequest = "FocusedWindow" + // PickWindow to pick a window with the mouse and print information about it. Not applicable to nirimgr. + PickWindow NiriRequest = "PickWindow" + // PickColor to pick a color from the screen with the mouse. Not applicable to nirimgr. + PickColor NiriRequest = "PickColor" + // RunAction performs an action + RunAction NiriRequest = "Action" + // ChangeOutput changes output configuration temporarily + ChangeOutput NiriRequest = "Output" + // EventStream starts continuously receiving events from the compositor + EventStream NiriRequest = "EventStream" + // Version prints the version of the running niri instance + Version NiriRequest = "Version" + // RequestError requests an error from the running niri instance + RequestError NiriRequest = "RequestError" + // OverviewState prints the overview state + OverviewState NiriRequest = "OverviewState" +) + +// Config contains the configuration for nirimgr. +type Config struct { + // LogLevel is the log level to use. One of "DEBUG", "INFO", "WARN", "ERROR" should be used. Defaults to "INFO". + LogLevel string `json:"logLevel"` + // Rules contains the rules to match windows, and the actions to perform on them. + Rules []Rule `json:"rules,omitempty"` + // ScratchpadWorkspace is the name of the scratchpad workspace. Defaults to "scratchpad". + // + // NOTE: The named workspace must be defined in niri config. + ScratchpadWorkspace string `json:"scratchpadWorkspace,omitempty"` +} + +// GetRules returns the configured rules. +// +// NOTE: We cannot use the name Rules() because we already define the Rules in the struct. +func (c *Config) GetRules() []Rule { + var rules []Rule + rules = append(rules, c.Rules...) + return rules +} + +// Match is used to match a window. +type Match struct { + // Title matches the title of the window. + Title string `json:"title,omitempty"` + // AppID matches the app-id of the window. + AppID string `json:"appId,omitempty"` +} + +// Matches checks if the window matches the specified rule match. +func (m Match) Matches(window Window) bool { + if m.Title == "" && m.AppID == "" { + slog.Debug("Title and AppID is empty for window", "window", window.ID) + return false + } + matched := true + + if m.Title != "" { + titleMatch, err := regexp.MatchString(m.Title, window.Title) + if err != nil { + slog.Error("Could not match title", "error", err.Error()) + return false + } + matched = matched && titleMatch + } + if m.AppID != "" { + appMatch, err := regexp.MatchString(m.AppID, window.AppID) + if err != nil { + slog.Error("Could not match AppID", "error", err.Error()) + return false + } + matched = matched && appMatch + } + + return matched +} + +// Rule contains the matches, excludes and actions for a window. +type Rule struct { + // Match list of matches to target a window. + Match []Match `json:"match,omitempty"` + // Exclude list of matches to target a window, to be excluded from the match. + Exclude []Match `json:"exclude,omitempty"` + // Actions defines the action to do on the matching window. + // + // This is a json.RawMessage on purpose, since we need to + // dynamically create the action struct. + Actions map[string]json.RawMessage `json:"actions,omitempty"` +} + +// Matches checks if the window matches the given rule. +func (r Rule) Matches(window Window) bool { + if len(r.Match) > 0 { + matched := false + for _, m := range r.Match { + if m.Matches(window) { + matched = true + break + } + } + if !matched { + return false + } + } + for _, m := range r.Exclude { + if m.Matches(window) { + return false + } + } + return true +} + +// Response contains the response from the Niri Socket. +type Response struct { + Ok map[string]json.RawMessage `json:"Ok"` +} + +// ConfiguredMode is the output mode as set in the config file. +type ConfiguredMode struct { + // Width is the width in physical pixels. + Width uint16 `json:"width"` + // Height is the height in physical pixels. + Height uint16 `json:"height"` + // Refresh is the refresh rate. + Refresh float64 `json:"refresh,omitempty"` +} + +// ConfiguredPosition is the output position as set in the config file. +type ConfiguredPosition struct { + // X is the logical x position. + X int32 `json:"x"` + // Y is the logical y position. + Y int32 `json:"y"` +} + +// KeyboardLayouts is the configured keyboard layouts. +type KeyboardLayouts struct { + // Names is the XKB names of the configured layouts. + Names []string `json:"names"` + // CurrentIdx is the index of the currently active layout in Names. + CurrentIdx uint8 `json:"current_idx"` +} + +// LayerSurface is the layer-shell surface. +type LayerSurface struct { + // Namespace is the namespace provided by the layer-shell client. + Namespace string `json:"namespace"` + // Output is the name of the output the surface is on. + Output string `json:"output"` + // Layer is the layer that the surface is on. + Layer Layer `json:"layer"` + // KeyboardInteractivity is the surface's keyboard interactivity mode. + KeyboardInteractivity LayerSurfaceKeyboardInteractivity `json:"keyboard_interactivity"` +} + +// LogicalOutput is the logical output in the compositor's coordinate space. +type LogicalOutput struct { + // X is the logical x position. + X int `json:"x"` + // Y is the logical y position. + Y int `json:"y"` + // Width is the width in logical pixels. + Width int `json:"width"` + // Height is the height in logical pixels. + Height int `json:"height"` + // Scale is the scale factor. + Scale float64 `json:"scale"` + // Transform sets the transformation of the output. + Transform Transform `json:"transform"` +} + +// Mode is the output mode. +type Mode struct { + // Width is the width in physical pixels. + Width int `json:"width"` + // Height is the height in physical pixels. + Height int `json:"height"` + // RefreshRate is the refresh rate in millihertz. + RefreshRate int `json:"refresh_rate"` + // IsPreferred tells whether this mode is preferred by the monitor. + IsPreferred bool `json:"is_preferred"` +} + +// Output is the connected output. +type Output struct { + // Name is the name of the output. + Name string `json:"name"` + // Make is the textual description of the manufacturer. + Make string `json:"make"` + // Model is the textual description of the model. + Model string `json:"model"` + // Serial is the serial of the output, if known. + Serial string `json:"serial"` + // PhysicalSize is the physical width and height of the output in mm, if known. + PhysicalSize []int `json:"physical_size"` + // Modes is the available modes for the output. + Modes []Mode `json:"modes"` + // CurrentMode is the current mode. None if the output is disabled. + CurrentMode int `json:"current_mode"` + // VrrSupported tells whether the output supports variable refresh rate. + VrrSupported bool `json:"vrr_supported"` + // VrrEnabled tells whether the variable refresh rate is enabled on the output. + VrrEnabled bool `json:"vrr_enabled"` + // Logical is the logical output information. None if the output is not mapped to any logical output (e.g. if it's disabled). + Logical LogicalOutput `json:"logical"` +} + +// Overview is the overview information. +type Overview struct { + // IsOpen tells whether the overview is currently open or not. + IsOpen bool `json:"is_open"` +} + +// PickedColor is the color picked from the screen. +type PickedColor struct { + // RGB is the color values as red, green, blue, each ranging from 0.0 to 1.0. + RGB float64 `json:"rgb"` +} + +// VrrToSet is the output variable refresh rate to set. +type VrrToSet struct { + // Vrr tells whether to enable variable refresh rate or not. + Vrr bool `json:"vrr"` + // OnDemand tells to only enable when the output shows a window matching the variable-refresh-rate window rule. + OnDemand bool `json:"on_demand"` +} + +// Window contains the details of a window. +type Window struct { + // ID is the unique ID of this window. + // + // This ID remains constant while this window is open. + // + // Do not assume that window IDs will always increase without wrapping, or start at 1. + // That is an implementation detail subject to change. For example, IDs may change to be + // randomly generated for each new window. + ID uint64 `json:"id"` + // Title is the window title, if set. + Title string `json:"title"` + // AppID is the application ID, if set. + AppID string `json:"app_id"` + // Pid is the process ID that created the Wayland connection for this window, if known. + // + // Currently, windows created by xdg-desktop-portal-gnome will have a None PID, but this + // may change in the future. + Pid int `json:"pid"` + // WorkspaceID is the ID of the workspace this window is on, if any. + WorkspaceID uint64 `json:"workspace_id"` + // IsFocused tell whether this window is currently focused. + // + // There can either be one focused window, or zero (e.g. when a layer-shell surface has focus). + IsFocused bool `json:"is_focused"` + // IsFloating tells whether this window is currently floating. + // + // If the window isn't floating, then it's in the tiling layout. + IsFloating bool `json:"is_floating"` + // IsUrgent tells whether this window requests your attention. + IsUrgent bool `json:"is_urgent"` + // Matched tells if the window matches a rule defined by nirimgr rules. + // + // This is not a part of the Niri Window model. + Matched bool +} + +// Workspace is the workspace. +type Workspace struct { + // ID is the unique ID of this workspace. + // + // This id remains constant regardless of the workspace moving around and across monitors. + // Do not assume that workspace IDs will always increase without wrapping, or start at 1. + // That is an implementation detail subject to change. + // For example, IDs may change to be randomly generated for each new workspace. + ID uint64 `json:"id"` + // Idx is the index of the workspace on this monitor. + // + // This is the same index you can use for requests like niri msg action focus-workspace. + // This index will change as you move and re-order workspace. It is merely the workspace's + // current position on its monitor. Workspaces on different monitors can have the same index. + // If you need a unique workspace id that doesn’t change, see Id. + Idx uint8 `json:"idx"` + // Name is the optional name of the workspace. + Name string `json:"name"` + // Output is the name of the output that the workspace is on. + // + // Can be None if no outputs are currently connected. + Output string `json:"output"` + // IsUrgent tells whether the workspace currently has an urgent window in its output. + IsUrgent bool `json:"is_urgent"` + // IsActive tells whether the workspace is currently active on its output. + // + // Every output has one active workspace, the one that is currently visible on that output. + IsActive bool `json:"is_active"` + // IsFocused tells whether the workspace is currently focused. + // + // There's only one focused workspace across all outputs. + IsFocused bool `json:"is_focused"` + // ActiveWindowID is the ID of the active window on this workspace, if any. + ActiveWindowID uint64 `json:"active_window_id"` +} diff --git a/models/models_test.go b/models/models_test.go new file mode 100644 index 0000000..17090e1 --- /dev/null +++ b/models/models_test.go @@ -0,0 +1,71 @@ +package models + +import ( + "testing" +) + +func TestRuleMatches(t *testing.T) { + rules := []Rule{ + { + Match: []Match{ + {Title: "Bitwarden", AppID: "zen"}, + }, + Exclude: nil, + }, + { + Match: []Match{ + {AppID: "^foot$"}, + {AppID: "^mpv$"}, + }, + }, + { + Match: []Match{ + {Title: "^foo$"}, + }, + Exclude: []Match{ + {AppID: "^bar$"}, + }, + }, + } + windows := []Window{ + { + ID: 1, + Title: "Bitwarden", + AppID: "zen", + }, + { + ID: 2, + AppID: "foot", + }, + { + ID: 3, + Title: "mpv", + }, + { + ID: 4, + Title: "foo", + AppID: "bar", + }, + } + + tests := []struct { + ruleIdx int + windowIdx int + wantMatch bool + }{ + {0, 0, true}, + {0, 1, false}, + {1, 1, true}, + {1, 2, false}, + {2, 3, false}, + } + + for _, tt := range tests { + rule := rules[tt.ruleIdx] + window := windows[tt.windowIdx] + got := rule.Matches(window) + if got != tt.wantMatch { + t.Errorf("Rule[%d].Matches(Window[%d]) = %v, want %v", tt.ruleIdx, tt.windowIdx, got, tt.wantMatch) + } + } +} From c8a0c8797649519fcd49931cd84a05d8a5c0926e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 23 Jul 2025 15:55:17 +0300 Subject: [PATCH 4/9] feat: add github actions Add the GitHub Actions workflow for releasing. Add goreleaser configuration. Update version information. --- .github/workflows/release.yaml | 41 ++++++++++++++++++++++++++++++++++ .goreleaser.yaml | 17 ++++++++++++++ cmd/version.go | 12 ++++++---- config/config.go | 2 ++ 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8a57687 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,41 @@ +name: CI +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - uses: golangci/golangci-lint-action@v8 + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - run: go test -v ./... + release: + runs-on: ubuntu-latest + needs: test + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - uses: go-semantic-release/action@v2 + with: + hooks: goreleaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4cd952c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,17 @@ +builds: + - env: + - CGO_ENABLED=0 + targets: + - linux_amd64 + - darwin_amd64 + - darwin_arm64 + main: ./ + flags: + - -trimpath + - -buildvcs=false + ldflags: + - -extldflags '-static' + - -s -w + - -X config.Version={{.Version}} + - -X config.CommitSHA={{.FullCommit}} + - -X config.Date={{.Date}} diff --git a/cmd/version.go b/cmd/version.go index 270c2c8..97625d6 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "runtime" + "runtime/debug" "github.com/soderluk/nirimgr/config" "github.com/spf13/cobra" @@ -24,13 +25,16 @@ func init() { // buildInfo returns the build information about nirimgr func buildInfo() string { - info := config.Version - info += fmt.Sprintf(" (%s %s %s %s)", + info := fmt.Sprintf("\nVersion:\t%s\nCommit:\t%s\nGo Version:\t%s\nBuild Date:\t%s\nBuild info: \n", + config.Version, + config.CommitSHA, runtime.Version(), - runtime.GOARCH, - runtime.GOOS, config.Date, ) + bi, _ := debug.ReadBuildInfo() + for _, setting := range bi.Settings { + info += fmt.Sprintf("%s:\t%s\n", setting.Key, setting.Value) + } return info } diff --git a/config/config.go b/config/config.go index 105c6e7..c9db514 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,8 @@ var ( Version string = "git" // Date is the date when nirimgr was built Date string = time.Now().Format("2006-01-02") + // CommitSHA contains the build commit SHA hash. + CommitSHA string = "000" // Config contains all configurations Config *models.Config ) From d7e582e72b5d90afdc935b4845df91e4faba2b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Thu, 24 Jul 2025 08:50:19 +0300 Subject: [PATCH 5/9] fix(lint): fix linting errors Check the defer error value on e.g. f.Close() --- config/config.go | 7 ++++++- config/config_test.go | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/config/config.go b/config/config.go index c9db514..d92292d 100644 --- a/config/config.go +++ b/config/config.go @@ -61,7 +61,12 @@ func newConfig(filename string) (*models.Config, error) { return nil, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + slog.Error("Could not close config file", "error", err.Error()) + } + }() + var c *models.Config err = json.NewDecoder(f).Decode(&c) diff --git a/config/config_test.go b/config/config_test.go index a67fa10..eadb785 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -28,12 +28,16 @@ func TestNewConfig_LocalFile(t *testing.T) { // Move file to expected local path if err := os.MkdirAll("config", 0o755); err != nil { - t.Logf("could not create directory 'config'") + t.Log("could not create directory 'config'") } if err := os.Rename(file, "config/test_config.json"); err != nil { t.Logf("could not rename file %v", file) } - defer os.Remove("config/test_config.json") + defer func() { + if err := os.Remove("config/test_config.json"); err != nil { + t.Log("could not remove config file") + } + }() cfg, err := newConfig("test_config.json") if err != nil { @@ -58,7 +62,11 @@ func TestNewConfig_HomeFallback(t *testing.T) { if err := os.Rename(file, configPath); err != nil { t.Logf("could not rename file '%v' to '%v'", file, configPath) } - defer os.Remove(configPath) + defer func() { + if err := os.Remove(configPath); err != nil { + t.Log("could not remove config path") + } + }() cfg, err := newConfig("test_config.json") if err != nil { @@ -74,12 +82,16 @@ func TestConfigure_SetsGlobalConfig(t *testing.T) { file, cleanup := createTempConfigFile(t, configContent) defer cleanup() if err := os.MkdirAll("config", 0o755); err != nil { - t.Logf("could not create directory 'config'") + t.Log("could not create directory 'config'") } if err := os.Rename(file, "config/test_config.json"); err != nil { t.Logf("could not rename file '%v'", file) } - defer os.Remove("config/test_config.json") + defer func() { + if err := os.Remove("config/test_config.json"); err != nil { + t.Log("could not remove test config file") + } + }() err := Configure("test_config.json") if err != nil { @@ -91,7 +103,10 @@ func TestConfigure_SetsGlobalConfig(t *testing.T) { } func TestGetConfigFile_Error(t *testing.T) { - os.Remove("config/test_config.json") + err := os.Remove("config/test_config.json") + if err != nil { + t.Log("could not remove test config.") + } cfg, err := getConfigFile("test_config.json") if err == nil || cfg != nil { t.Error("Expected error when config files are missing") From 8b882d96a10ce920ba70a2bb06ea70463aae4d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Thu, 24 Jul 2025 08:54:57 +0300 Subject: [PATCH 6/9] fix(actions): fix the github actions Move test and lint to their own files. Update the semantic-release version to v1. --- .github/workflows/lint.yaml | 17 +++++++++++++++++ .github/workflows/release.yaml | 21 ++------------------- .github/workflows/test.yaml | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..8f222a0 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,17 @@ +name: "Run lint" +on: + push: + branches: + - "**" + pull_request: + branches: + - "**" +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - uses: golangci/golangci-lint-action@v8 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8a57687..21f4bd4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,4 +1,4 @@ -name: CI +name: "Release nirimgr" on: push: branches: @@ -7,23 +7,6 @@ on: branches: - "main" jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: 1.23 - - uses: golangci/golangci-lint-action@v8 - test: - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: 1.23 - - run: go test -v ./... release: runs-on: ubuntu-latest needs: test @@ -34,7 +17,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: 1.23 - - uses: go-semantic-release/action@v2 + - uses: go-semantic-release/action@v1 with: hooks: goreleaser env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..cf92b82 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,18 @@ +name: "Run tests" +on: + push: + branches: + - "**" + pull_request: + branches: + - "**" +jobs: + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - run: go test -v ./... From ca6a7a6dc69fe1f26fbac6bf173acf8acb61bb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Thu, 24 Jul 2025 09:01:45 +0300 Subject: [PATCH 7/9] fix(actions): fix the actions --- .github/workflows/release.yaml | 6 +++++- .github/workflows/test.yaml | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 21f4bd4..681751a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,4 +1,4 @@ -name: "Release nirimgr" +name: Release pipeline on: push: branches: @@ -7,6 +7,10 @@ on: branches: - "main" jobs: + lint: + uses: soderluk/nirimgr/.github/workflows/lint.yaml + test: + uses: soderluk/nirimgr/.github/workflows/test.yaml release: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cf92b82..553c8c7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,6 +7,8 @@ on: branches: - "**" jobs: + lint: + uses: soderluk/.github/workflows/lint.yaml test: runs-on: ubuntu-latest needs: lint From 08965e3b6c55357164721d0ad946dc2ffe0da88c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Thu, 24 Jul 2025 09:08:28 +0300 Subject: [PATCH 8/9] fix(actions): remove the separate files and move them to release --- .github/workflows/lint.yaml | 17 ----------------- .github/workflows/release.yaml | 21 +++++++++++++++++---- .github/workflows/test.yaml | 20 -------------------- 3 files changed, 17 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/lint.yaml delete mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml deleted file mode 100644 index 8f222a0..0000000 --- a/.github/workflows/lint.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Run lint" -on: - push: - branches: - - "**" - pull_request: - branches: - - "**" -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: 1.23 - - uses: golangci/golangci-lint-action@v8 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 681751a..45017e6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,15 +2,28 @@ name: Release pipeline on: push: branches: - - "main" + - "**" pull_request: branches: - - "main" + - "**" jobs: lint: - uses: soderluk/nirimgr/.github/workflows/lint.yaml + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - uses: golangci/golangci-lint-action@v8 test: - uses: soderluk/nirimgr/.github/workflows/test.yaml + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.23 + - run: go test -v ./... release: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 553c8c7..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Run tests" -on: - push: - branches: - - "**" - pull_request: - branches: - - "**" -jobs: - lint: - uses: soderluk/.github/workflows/lint.yaml - test: - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: 1.23 - - run: go test -v ./... From c606e8cf1ff35df74690a74a8345687edfcc7d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Thu, 24 Jul 2025 09:23:07 +0300 Subject: [PATCH 9/9] fix: try to fix release --- .github/workflows/release.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 45017e6..8d453eb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -35,7 +35,9 @@ jobs: with: go-version: 1.23 - uses: go-semantic-release/action@v1 + if: github.ref == 'refs/heads/main' with: + github-token: ${{ secrets.GITHUB_TOKEN }} + changelog-file: CHANGELOG.md hooks: goreleaser - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + allow-initial-development-versions: true