Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -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"`.
Expand Down Expand Up @@ -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:

Expand All @@ -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!

Expand All @@ -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).
15 changes: 15 additions & 0 deletions cmd/floating.go
Original file line number Diff line number Diff line change
@@ -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)
}
112 changes: 112 additions & 0 deletions cmd/move.go
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 39 additions & 0 deletions examples/niri-config.kdl
Original file line number Diff line number Diff line change
@@ -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"
}
}

53 changes: 53 additions & 0 deletions internal/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"reflect"

"github.com/soderluk/nirimgr/config"
"github.com/soderluk/nirimgr/models"
)

// SetupLogger sets up the logging for the application.
Expand Down Expand Up @@ -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)}
}
19 changes: 19 additions & 0 deletions internal/connection/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading