From c76d2ae6f7d637cca2f1b4ddfe87bf7a61c68787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 3 Sep 2025 20:05:37 +0300 Subject: [PATCH 1/5] feat: add new model for window layout Add new model for WindowLayout and add it to the Window model. Add WindowSlice, WorkspaceSlice and OutputSlice structs and the First() receiver functions to get the first element in the slice. --- models/models.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/models/models.go b/models/models.go index d96caea..4f4d1b7 100644 --- a/models/models.go +++ b/models/models.go @@ -5,6 +5,7 @@ package models import ( "encoding/json" + "errors" "fmt" "log/slog" "regexp" @@ -351,7 +352,7 @@ type LogicalOutput struct { // Scale is the scale factor. Scale float64 `json:"scale"` // Transform sets the transformation of the output. - Transform Transform `json:"transform"` + Transform string `json:"transform"` } // Mode is the output mode. @@ -441,12 +442,41 @@ type Window struct { IsFloating bool `json:"is_floating"` // IsUrgent tells whether this window requests your attention. IsUrgent bool `json:"is_urgent"` + // Layout shows position- and size-related properties of the window. + Layout WindowLayout `json:"layout"` // Matched tells if the window matches a rule defined by nirimgr rules. // // This is not a part of the Niri Window model. Matched bool } +// WindowLayout shows the position- and size-related properties of a Window. +type WindowLayout struct { + // PosInScrollingLayout is the location of a tiled window within a workspace: + // (column index, tile index in column). + // + // The indices are 1-based, i.e. the leftmost column is at index 1 and the topmost tile in a column is at index 1. + // This is consistent with Action::FocusColumn and Action::FocusWindowInColumn. + PosInScrollingLayout []uint `json:"pos_in_scrolling_layout,omitempty"` + // TileSize is the size of the tile this window is in, including decorations like borders. + TileSize []float64 `json:"tile_size"` + // WindowSize is the size of the window's visual geometry itself. + // + // Does not include niri decorations like borders. + // Currently, Wayland top-level windows can only be integer-sized in logical pixels, + // even though it doesn't necessarily align to physical pixels. + WindowSize []int32 `json:"window_size"` + // TilePosInWorkspaceView is the tile position within the current view of the workspace. + // + // This is the same "workspace view" as in gradients' relative-to in the niri config. + TilePosInWorkspaceView []float64 `json:"tile_pos_in_workspace_view,omitempty"` + // WindowOffsetInTile is the location of the window's visual geometry within its tile. + // + // This includes things like border sizes. For full-screened fixed-size windows this includes + // the distance from the corner of the black backdrop to the corner of the (centered) window contents. + WindowOffsetInTile []float64 `json:"window_offset_in_tile"` +} + // Workspace is the workspace. type Workspace struct { // ID is the unique ID of this workspace. @@ -507,3 +537,42 @@ type PossibleKeys struct { Index uint8 Reference ReferenceKeys } + +// WindowSlice is a wrapper for windows. +type WindowSlice struct { + Windows []*Window +} + +// First returns the first window in the window slice. +func (ws WindowSlice) First() (*Window, error) { + if len(ws.Windows) == 0 { + return nil, errors.New("no windows matched given filter") + } + return ws.Windows[0], nil +} + +// WorkspaceSlice is a wrapper for workspaces. +type WorkspaceSlice struct { + Workspaces []*Workspace +} + +// First returns the first workspace in the workspace slice. +func (ws WorkspaceSlice) First() (*Workspace, error) { + if len(ws.Workspaces) == 0 { + return nil, errors.New("no workspaces matched given filter") + } + return ws.Workspaces[0], nil +} + +// OutputSlice is a wrapper for outputs. +type OutputSlice struct { + Outputs []*Output +} + +// First returns the first output in the output slice. +func (o OutputSlice) First() (*Output, error) { + if len(o.Outputs) == 0 { + return nil, errors.New("no outputs matched given filter") + } + return o.Outputs[0], nil +} From d902e3714de092b775f8f4806dab7585dc884bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 3 Sep 2025 20:05:37 +0300 Subject: [PATCH 2/5] feat: add filter functions for windows, workspaces and outputs Add the Filter* functions to filter windows, workspaces and outputs, as well as their corresponding *Chain functions, so you can get the first element in the slice. --- internal/common/common.go | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/internal/common/common.go b/internal/common/common.go index 1510291..32c317c 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -7,6 +7,7 @@ import ( "reflect" "github.com/soderluk/nirimgr/config" + "github.com/soderluk/nirimgr/models" ) // SetupLogger sets up the logging for the application. @@ -88,3 +89,55 @@ func SetStringField(field reflect.Value, fieldName string, val string) { } } } + +// FilterWindows returns a slice of window models depending on the filtering function. +func FilterWindows(windows []*models.Window, f func(*models.Window) bool) []*models.Window { + w := make([]*models.Window, 0) + for _, e := range windows { + if f(e) { + w = append(w, e) + } + } + + return w +} + +// FilterWindowsChain is a chainable function, you can use .First() to get the first window in the slice. +func FilterWindowsChain(windows []*models.Window, f func(*models.Window) bool) models.WindowSlice { + return models.WindowSlice{Windows: FilterWindows(windows, f)} +} + +// FilterWorkspaces returns a slice of workspace models depending on the filtering function. +func FilterWorkspaces(workspaces []*models.Workspace, f func(*models.Workspace) bool) []*models.Workspace { + w := make([]*models.Workspace, 0) + for _, e := range workspaces { + if f(e) { + w = append(w, e) + } + } + + return w +} + +// FilterWorkspacesChain is a chainable function, you can use .First() to get the first workspace in the slice. +func FilterWorkspacesChain(workspaces []*models.Workspace, f func(*models.Workspace) bool) models.WorkspaceSlice { + return models.WorkspaceSlice{Workspaces: FilterWorkspaces(workspaces, f)} +} + +// FilterOutputs returns a slice of output models depending on the filtering function. +func FilterOutputs(outputs []*models.Output, f func(*models.Output) bool) []*models.Output { + o := make([]*models.Output, 0) + + for _, e := range outputs { + if f(e) { + o = append(o, e) + } + } + + return o +} + +// FilterOutputsChain is a chainable function, you can use .First() to get the first output in the slice. +func FilterOutputsChain(outputs []*models.Output, f func(*models.Output) bool) models.OutputSlice { + return models.OutputSlice{Outputs: FilterOutputs(outputs, f)} +} From 66892ef8a898a7e5b93bc3e0c8825866a8293824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 3 Sep 2025 20:05:37 +0300 Subject: [PATCH 3/5] feat: add support for listing outputs Add ListOutputs function to list all the outputs via Niri IPC. --- internal/connection/connection.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/connection/connection.go b/internal/connection/connection.go index 233fc35..2a76434 100644 --- a/internal/connection/connection.go +++ b/internal/connection/connection.go @@ -186,6 +186,25 @@ func ListWorkspaces() ([]*models.Workspace, error) { return workspaces, nil } +// ListOutputs returns the current list of outputs from Niri IPC. +func ListOutputs() ([]*models.Output, error) { + response, err := PerformRequest(models.Outputs) + if err != nil { + return nil, err + } + resp := <-response + + var outputMap map[string]*models.Output + if err := json.Unmarshal(resp.Ok["Outputs"], &outputMap); err != nil { + return nil, err + } + var outputs []*models.Output + for _, output := range outputMap { + outputs = append(outputs, output) + } + return outputs, nil +} + // structToMap converts a go struct to a map. func structToMap(a any) (map[string]any, error) { var m map[string]any From 8bd3dbcbf4818d09b12ecfc944ee3aa410279589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Wed, 3 Sep 2025 20:05:37 +0300 Subject: [PATCH 4/5] feat: add nirimgr floating move command Add a new command `floating` and it's sub-command `move` to move a floating window to the edges of the screen. Either top/bottom/left/right edge can be defined. Bind keys in niri config to run nirimgr floating move top/bottom/left/right [border]. --- cmd/floating.go | 15 +++++++ cmd/move.go | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 cmd/floating.go create mode 100644 cmd/move.go diff --git a/cmd/floating.go b/cmd/floating.go new file mode 100644 index 0000000..c362831 --- /dev/null +++ b/cmd/floating.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// floatingCmd is the main command for actions to be done on floating windows. +var floatingCmd = &cobra.Command{ + Use: "floating", + Short: "Main command for floating windows. See --help for the sub-commands.", +} + +func init() { + rootCmd.AddCommand(floatingCmd) +} diff --git a/cmd/move.go b/cmd/move.go new file mode 100644 index 0000000..0e57219 --- /dev/null +++ b/cmd/move.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "encoding/json" + "errors" + "strconv" + + "github.com/soderluk/nirimgr/actions" + "github.com/soderluk/nirimgr/internal/common" + "github.com/soderluk/nirimgr/internal/connection" + "github.com/soderluk/nirimgr/models" + "github.com/spf13/cobra" +) + +// moveCmd moves a floating window to the left/right/top/bottom edges. +// +// This is from https://github.com/YaLTeR/niri/discussions/1656#discussioncomment-14268880 +var moveCmd = &cobra.Command{ + Use: "move", + Short: "Moves a floating window to the left/right/top/bottom edges of the screen.", + SilenceUsage: true, // If there's an error during running the command, don't show usage. + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + direction := args[0] + // Note: The border must be at minimum 1, because if it's 0, the "newX" will be empty. + border := float64(1) + if len(args) > 1 { + border, _ = strconv.ParseFloat(args[1], 64) + } + + windows, err := connection.ListWindows() + if err != nil { + return errors.New("could not get windows") + } + window, err := common.FilterWindowsChain(windows, func(w *models.Window) bool { + return w.IsFocused && w.IsFloating + }).First() + if err != nil { + return errors.New("no active floating window") + } + + workspaces, err := connection.ListWorkspaces() + if err != nil { + return errors.New("could not get workspaces") + } + + workspace, err := common.FilterWorkspacesChain(workspaces, func(w *models.Workspace) bool { + return w.IsFocused + }).First() + if err != nil { + return errors.New("could not get focused workspace") + } + outputName := workspace.Output + outputs, err := connection.ListOutputs() + if err != nil { + return errors.New("could not get outputs") + } + output, err := common.FilterOutputsChain(outputs, func(o *models.Output) bool { + return o.Name == outputName + }).First() + if err != nil { + return errors.New("could not get output") + } + + width := float64(window.Layout.WindowSize[0]) + height := float64(window.Layout.WindowSize[1]) + x := float64(window.Layout.TilePosInWorkspaceView[0]) + y := float64(window.Layout.TilePosInWorkspaceView[1]) + + screenWidth := float64(output.Logical.Width) + screenHeight := float64(output.Logical.Height) + + var newX, newY float64 + switch direction { + case "left": + newX = border + newY = y - 34 + case "right": + newX = (screenWidth - width - border) + newY = y - 34 + case "up": + newX = x + newY = border + case "down": + newX = x + newY = (screenHeight - height - border) - 34 + default: + return errors.New("invalid direction provided") + } + + data := map[string]any{ + "x": map[string]float64{ + "SetFixed": newX, + }, + "y": map[string]float64{ + "SetFixed": newY, + }, + } + jsonData, err := json.Marshal(data) + if err != nil { + return errors.New("error parsing action json data") + } + action := actions.FromRegistry("MoveFloatingWindow", jsonData) + action = actions.HandleDynamicIDs(action, models.PossibleKeys{ID: window.ID}) + connection.PerformAction(action) + return nil + }, +} + +func init() { + floatingCmd.AddCommand(moveCmd) +} From 5bc1fd65b71b76272ea2e5d730e24d97184ba039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20S=C3=B6derlund?= Date: Sat, 8 Nov 2025 13:06:34 +0200 Subject: [PATCH 5/5] chore(docs): update docs Update README and add niri config examples file. --- README.md | 40 +++++++++++++++++++++++++++++++++------- examples/niri-config.kdl | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 examples/niri-config.kdl diff --git a/README.md b/README.md index aad20d7..b5e7bc6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Q: Why not write this in Rust? A: Because I need to improve my Golang knowledge (for work), and this was a great opportunity for me to dive a bit deeper into the language, as well as learn a bit more on the Niri IPC and how it works. -# Installation +## Installation ## Using go install @@ -45,8 +45,8 @@ nirimgr_x.x.x_linux_amd64.tar.gz: OK After verification succeeds, extract the tarball somewhere, e.g. `~/Downloads/nirimgr`. ```bash -$ mkdir -p ~/Downloads/nirimgr -$ tar xf nirimgr_x.x.x_linux_amd64.tar.gz --directory=~/Downloads/nirimgr/ +mkdir -p ~/Downloads/nirimgr +tar xf nirimgr_x.x.x_linux_amd64.tar.gz --directory=~/Downloads/nirimgr/ ``` The tarball includes the following files: @@ -82,7 +82,7 @@ $ nirimgr --version nirimgr version vx.x.x (commit: aaaaaaa, built at: 2025-07-25T06:05:16Z) ``` -# Configuration +## Configuration The configuration file for nirimgr should be put in ~/.config/nirimgr/config.json @@ -283,6 +283,7 @@ Example configuration (see: [config.json](./examples/config.json)): evaluates to true. This also applies for the matching actions in the rules. **NOTE**: The condition _must_ start with `model.`, i.e. `"when": "model.ID == 1"`. where the `model` refers to the `Window` model when matching `"type": "window"`, `Workspace` model when matching `"type": "workspace"`, or the event model when listening on custom events. +- Added in v0.7.0: Command to move a floating window to the edges of the screen `nirimgr floating move top 10` The rules are the same as the `window-rule` in Niri configuration. Currently we only 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 @@ -309,9 +310,31 @@ Since v0.6.0 you can add a condition to the actions. I.e. perform the action onl We use the [expr-lang](https://expr-lang.org/docs/getting-started) to evaluate the expression. You can see the supported conditions [here](https://expr-lang.org/docs/language-definition). Note that the `model` is required when writing the condition, i.e. `"when": "model.Name == 'work'"` which refers to the matching window/workspace/event. +Since v0.7.0 you can bind the `floating move` command in niri config: + +```kdl + Mod+Ctrl+H { + spawn-sh "nirimgr floating move left || niri msg action move-column-left-or-to-monitor-left" + } + Mod+Ctrl+J { + spawn-sh "nirimgr floating move down 4 || niri msg action move-window-down-or-to-workspace-down" + } + Mod+Ctrl+K { + spawn-sh "nirimgr floating move up || niri msg action move-window-up-or-to-workspace-up" + } + Mod+Ctrl+L { + spawn-sh "nirimgr floating move right 4 || niri msg action move-column-right-or-to-monitor-right" + } +``` + +This will move an open active floating window to the top/bottom/left/right edges of the screen, adding +an X amount of pixels as an empty "border". The default border is 1 if none is given. NOTE: a border of +0 will not work, 1 is the minimum. Thanks to @arnaudmathias for sharing this piece of script +[here](https://github.com/YaLTeR/niri/discussions/1656#discussioncomment-14268880). + Please feel free to open a PR if you have other thoughts what we could do with nirimgr. -# Usage +## Usage To use nirimgr, it provides the following CLI-commands: @@ -322,6 +345,7 @@ To use nirimgr, it provides the following CLI-commands: Added in v0.3.0: the spawn-or-focus command takes as parameter the app-id of the window you want to open/focus. See the configuration `spawnOrFocus` to see how you should configure the apps. - `nirimgr list [actions|events]`: The list command will list all the available actions or events, so you don't need to remember them all. +- `nirimgr floating move [up|down|left|right] [[border]]`: Moves an active floating window to the screen edges. 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"`. @@ -378,7 +402,7 @@ This will listen on the event stream, and react to the matching windows accordin define the `rules` in `config.json` and add the actions you want to do to the window when the event happens. -# Justfile +## Justfile The following actions can be performed with the `just` command: @@ -392,7 +416,7 @@ The following actions can be performed with the `just` command: - `version`: Prints the version. - `vet`: Runs `go vet` on the project. -# Acknowledgements +## Acknowledgements Of course the biggest one goes to [Niri WM](https://github.com/YaLTeR/niri) and YaLTeR for an awesome manager! @@ -402,3 +426,5 @@ the most notable being [niri-float-sticky](https://github.com/probeldev/niri-flo 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! The `nirimgr scratch spawn-or-focus [app-id]` is heavily inspired by the discussion [here](https://github.com/YaLTeR/niri/discussions/329#discussioncomment-13378697) + +The `floating move` command is from this discussion [here](https://github.com/YaLTeR/niri/discussions/1656#discussioncomment-14268880). diff --git a/examples/niri-config.kdl b/examples/niri-config.kdl new file mode 100644 index 0000000..bcb06cf --- /dev/null +++ b/examples/niri-config.kdl @@ -0,0 +1,39 @@ +// Here are some example configurations for keybinds and others in the niri config. +workspace scratchpad +spawn-at-startup niri msg action focus-workspace-down +binds { + Mod+Shift+T { + spawn nirimgr scratch spawn-or-focus special-term + } + Mod+S { + spawn nirimgr scratch move + } + Mod+Shift+S { + spawn nirimgr scratch show + } + Mod+Return { + spawn nirimgr scratch spawn-or-focus special-files + } + Mod+Shift+B { + spawn nirimgr scratch spawn-or-focus special-btop + } + Mod+Shift+M { + spawn nirimgr scratch spawn-or-focus deezer + } + Mod+Shift+C { + spawn nirimgr scratch spawn-or-focus Slack + } + Mod+Ctrl+H { + spawn-sh "nirimgr floating move left || niri msg action move-column-left-or-to-monitor-left" + } + Mod+Ctrl+J { + spawn-sh "nirimgr floating move down 4 || niri msg action move-window-down-or-to-workspace-down" + } + Mod+Ctrl+K { + spawn-sh "nirimgr floating move up || niri msg action move-window-up-or-to-workspace-up" + } + Mod+Ctrl+L { + spawn-sh "nirimgr floating move right 4 || niri msg action move-column-right-or-to-monitor-right" + } +} +