diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8d453eb --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,43 @@ +name: Release pipeline +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 + 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@v1 + if: github.ref == 'refs/heads/main' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + changelog-file: CHANGELOG.md + hooks: goreleaser + allow-initial-development-versions: true 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/.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/README.md b/README.md index df492cf..1e35da4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ # nirimgr + A manager for niri, written in 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 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 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 +{ + // 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. + "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 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. + +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. + +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 + 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"`. + +Set it up in niri config like so: + +```kdl +workspace "scratchpad" // or whatever you configured it to be. +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 (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: + +```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 `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. +And here's the [PR](https://github.com/YaLTeR/niri/pull/1820) to fix it (as of now, it hasn't been yet merged.) 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..97625d6 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "runtime" + "runtime/debug" + + "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 := 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(), + 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 new file mode 100644 index 0000000..d92292d --- /dev/null +++ b/config/config.go @@ -0,0 +1,89 @@ +// 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") + // CommitSHA contains the build commit SHA hash. + CommitSHA string = "000" + // 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 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) + + 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..eadb785 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,114 @@ +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.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 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 { + 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 func() { + if err := os.Remove(configPath); err != nil { + t.Log("could not remove config path") + } + }() + + 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.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 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 { + 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) { + 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") + } +} 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) + } + } +}