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/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) +} 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" + } +} + 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)} +} 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 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 +}