StrataWM / strata

A cutting-edge, robust and sleek Wayland compositor with batteries included.
https://stratawm.github.io
GNU General Public License v3.0
179 stars 7 forks source link

Add support for extending WM functionality using a scripting language. #3

Closed anantnrg closed 1 year ago

anantnrg commented 1 year ago

Currently all the configuration is done using TOML. Would love to implement support for extending functionality like custom layouts and window management stuff eg., grouping together windows of different programs like PekWM. Language of choice should be something like Lua, Nickel, Haskell or the like.

calops commented 1 year ago

I'll put my vote down for lua.

Nickel is great, but it's only useful for producing a static configuration. There's no runtime coming with it, so it can't help implement dynamic behavior. Something like lua could enable very handy stuff like this:

bindings = {
  {
    {"CTRL", "SHIFT", "Q"},
    function()
      for window in strata.current_workspace.get_windows() do
        window.close()
      done
    end,
    )
  }
},
animations = {
  window_in = function()
    -- My custom bezier curve
  end
}

Not only does it let the user implement any behavior very easily without resorting to external scripts and remote cli utilities (as is used for every other WM I've used), it also allows you to distribute some builtin behaviors as a library directly in lua (as a library) by using the same API, and these functions can be implemented both in rust and lua.

I'm sure Haskell could manage the same thing but it's not nearly as popular and much less easy to learn. It also has to be compiled, if I'm not mistaken, and can't really challenge lua as far as performance goes.

I'll go even further and say that lua could be the lone language of configuration for the WM. If you don't use any fancy construct, a simple configuration file full of options could look like this:

local strata = require("strata") -- You could even make this line optional
return {
  vsync = true,
  shadows = true,
  borders = {
    color = "red",
    thickness = 2,
  },
  keybindings = {
    { { "SUPER", "LEFT" }, strata.builtin.move_window_left }
  },
}

It honestly doesn't look any more complex than any random yaml configuration to me, and keeps users able to copy/paste any snippet for complex stuff they might find online without having to change configuration formats.

For inspiration, you can look at how wezterm handles this. As an example, here's how my own configuration file looks like: https://github.com/calops/nix/blob/main/programs/gui/wezterm/config.lua

anantnrg commented 1 year ago

Looks great and the syntax is also pretty good. I'll look into implementing it soon after I fix some critical bugs. Also, I want your opinion about the whole modularity thing. Is it something that you're interested in?

Good suggestions tho. Cheers :)

calops commented 1 year ago

Modularity sounds interesting to me, but what do you have in mind exactly? Something like plugins? If so, yes definitely. It would make it much easier to work with plugins compared to something like hyprland, although I'm not sure it'd be possible to achieve low-level graphical plugins that way.

It does require a somewhat stable API though, but I guess that's not really a concern in the early stages of the project.

anantnrg commented 1 year ago

Sure, plugins sound great. What I meant by 'modularity' is the current design principle, i.e.,

╭───────────────╮       ╭───────────╮       ╭──────────╮
│ Hotkey Daemon │ ────> │ StrataCTL │ ────> │ StrataWM │
╰───────────────╯       ╰───────────╯       ╰──────────╯

Or would doing everything inside the WM itself be better?

calops commented 1 year ago

I'm not really sure. I know this kind of modularity is the traditional way of doing things, but it's probably a bit limited for "analog" input. For example, you want the WM itself to be handling touchpad events, otherwise you can't expect smooth scrolling and gestures.

A hotkey deamon is so lightweight anyway, it doesn't strike me as superfluous if you embed it in the WM itself, even if it's not used by all users. As for the CTL utility, that can just be the role of the lua runtime itself, with the freedom afterwards to implement a CLI interface on top of it for integration with simple tools. You will probably want additional ways to integrate for full functionality though, such as pipes (for example, to enable reactive buttons in bars).

anantnrg commented 1 year ago

You're right. I've decided to scrap the whole hotkey daemon thing and just implement everything in the WM. Mostly because I'm having errors communicating with WM and calling functions, and I'm too lazy to make it work.

Also I started working on implementing Lua and getting rid of TOML. Decided to use mlua which seems to be the one used by Wezterm and is pretty good. Still tryna understand and wrap my head around the whole syntax thing. Pretty new stuff to me even though I've configured NeoVim a lot. Will update once I make a base implementation. Cheers :)

anantnrg commented 1 year ago

Hey @calops, I created a more advanced config. Could you let me know what you think

local strata = require("strata")

return {
    autostart {
        "kitty --tile Terminal",
        "kagi"
    },
    general {
        workspaces = 1,
        gaps_in = 8,
        gaps_out = 12,
        kb_repeat = {500, 250}
    },
    decorations {
        border {
            width = 2,
            active = "#FFF",
            inactive = "#131418",
            radius = 5,
        },
        window {
            opacity = 0.9
        },
        blur {
            enabled = true,
            size = 2,
            passes = 3,
            optimize = true,
        },
        shadow {
            enabled = true,
            size = 2,
            blur = 3,
            color = "#FFF"
        }
    },
    tiling {
        layout = "dwindle"
    },
    animations {
        enabled = true,
    },
    rules {
        workspaces {
            {
                workspace = 1,
                class_name = "kitty"
            },
            {
                workspace = 2,
                class_name = "Brave-browser"
            }
        },
        floating {
            {
                class_name = "pavucontrol"
            }
        }
    },
    bindings {
        {
            {"CTRL", "SHIFT", "Q"},
            function()
              for window in strata.current_workspace.get_windows() 
              do
                window.close()
              end
            end,
        },
        {
            {"WIN", "RETURN"},
            strata.spawn("kitty --title Terminal");
        }
    }
}

I'm currently trying to parse this. mlua's documentation is a bit sparse so kinda tricky. I found rlua which seems to have a bit better documentation. Just experimenting with stuff rn. If you have any resources that's worth checking out, please do let me know. Cheers :)

calops commented 1 year ago

Awesome! This looks great. I'll be sure to play with it this week and report back.

Some preliminary thoughts:

  1. You're probably looking for a syntax that looks like this instead:

    return {
       autostart = {
           "kitty --tile Terminal",
           "kagi"
       },
       general = {
           workspaces = 1,
           gaps_in = 8,
           gaps_out = 12,
           kb_repeat = {500, 250}
       },
    }

    Otherwise, autostart { ... } and general { ... } are function calls, which would need to be brought in scope just for this (and also, it looks a bit weird among the rest of the lua conf).

  2. I know you took it from my example before, but there's probably better to be done for the bindings. Something more verbose (which for configurations means more intuitive) like:

    bindings = {
        {
            keys = {"CTRL", "SHIFT", "Q"},
            action = function()
              for window in strata.current_workspace.get_windows() 
              do
                window.close()
              end
            end,
        },
    }

    I think it's the best way to go about this, especially since there will probably be more (optional) arguments later. This makes sure that nothing is ambiguous.

  3. To be discussed as I'm not sure how annoying this can be, but the consensus everywhere I've looked for spawning programs seems to be to take arguments as a list, rather than a full-string command line. Something like {"kitty", "--title", "Terminal"} instead of "kitty --title Terminal}. This makes it easier to provide multi-words arguments (which, especially for this example, are common).

    However, the magic of lua means we don't have to choose! You can accept both a string or a list of strings, and adjust the behavior with simple introspection. But since the backend libs that will spawn processes take lists of arguments anyway, I advise to first implement my suggestion and the string one on top of it.

calops commented 1 year ago
  1. About this:

    rules {
        workspaces {
            {
                workspace = 1,
                class_name = "kitty"
            },
            {
                workspace = 2,
                class_name = "Brave-browser"
            }
        },
        floating {
            {
                class_name = "pavucontrol"
            }
        }
    },

    I get the idea and that's pretty much how it's done in other WMs, but maybe there are improvements that could be made here thanks to lua? First, I would reverse the logic, which lets us create hooks for pretty much anything. Something like this (I tried to come up with simple use-cases that could actually make sense):

    rules = {
       -- Always bind firefox to the workspace 1, before the window is opened
       {
           triggers = { event = "win_open_pre", class_name = "firefox" },
           action = function(window) window.send_to_workspace(1) end,
       },
    
       -- A single action that can be ran from multiple triggers.
       {
           triggers = {
               -- Set mpv to floating, always
               { event = "win_open_pre", class_name = "mpv" },
               -- Set terminals to floating only if on the workspace 1 (where we put firefox previously)
               { event = "win_open_pre", workspace = 1, class_name = { "kitty", "wezterm" },
           }
           action = function(window) window.set_floating() end,
       },
    
       -- Here comes the nice stuff: helper functions! This one does the same thing as the first rule
       strata.rules.bind_to_workspace(1, "firefox"),
    
       -- Nothing preventing us from some more sugar:
       strata.rules.bind_to_workspace {
           { 1, "firefox" },
           { 10, "slack" },
       },
    
       -- This does the same thing as the first part of the second rule
       strata.rules.set_floating("mpv"),
    }
  2. I think we should consider a more imperative approach for all of this, rather than the declarative way of returning a dict that serves as the whole configuration.

    -- this:
    return {
      rules = {
         ...
      }
    }
    
    -- Could instead be done this way:
    local strata = require("strata")
    strata.create_rules {
      ...
    }

    It doesn't change much for a simple config file, but it's much more powerful when you consider plugins. It leverages the fact that you actually have a runtime, which means configuration can be altered in real time during the WM's life. It's basically the exact same approach as neovim: provide API functions and let people configure whatever they want with this. This means a plugin (or even the user directly) could create and delete rules depending on arbitrary things, making it able to deeply extend the WM's functionality very easily.

    I think there's actually a lot of inspiration to take from neovim here, and some thinking to do to see what should stick and what should be improved. The concept of autocmds is pretty much the same thing as rules in strata, but it also comes with the concept of augroups, which I think is a useful addition if you want to delete or toggle a bunch of rules at once.

    Anyway, this is all a bunch of complexity that can be added later on without necessarily breaking (just deprecating) the first draft. I'm mostly brainstorming with myself right now. It's also likely that I'm getting overeager and this is all overkill for a WM, who knows. I still really like point 4. though.

anantnrg commented 1 year ago

These are really great suggestions! Lua is pretty new stuff to me and parsing it in Rust is even newer. I'm just researching on how its parsed. The docs for mlua and rlua are a bit sparse, so I'm looking at projects that use them and then reverse engineer it. Wezterm seems to do a fair bit of processing and some advanced stuff. I also have my exams coming up next week so I have study a lot. I'll look into tho. Cheers :)

anantnrg commented 1 year ago

Hey @calops, I've been thinking about a new way of doing stuff. I came across KDL, and its a pretty human readable language. I though of having a design where all the static config is done in KDL and then the scriptable parts are done in Lua, kinda like Wezterm does it, instead KDL for TOML. This way, it'll also be a bit easier and not overwhelming for new users when they just get started. I thought of having a directory structure like this:

strata
|- config.kdl
|- scripts
    |- custom_layout.lua
    |- window_function.lua

This way, its the best of both worlds imo. Also I've been pulling my hair out for the past day or so trying to figure out how to parse the desired Lua config and this way, I think it'll be easier for us devs and the users. Do let me know what you think!

Cheers :)

calops commented 1 year ago

KDL is great and I understand the motivations, but I'm still iffy about having two different ways to achieve some things. It hurts discoverability, and adds unnecessary friction when somebody suddenly wants to do something that requires to move to lua.

I think there are other ways to make it not overwhelming for newcomers:

Anyway, as the project is young, I suggest going with the more powerful option (lua) first, and then later implement stuff to make things easier for users.

Also I've been pulling my hair out for the past day or so trying to figure out how to parse the desired Lua config

I'm a bit confused by this, as I don't think there's any parsing to be done at all. This should be done by mlua entirely, you only need to provide a context (the strata module) and then load a lua file to let the runtime interpret it.

anantnrg commented 1 year ago

Oh okay, all this stuff is a bit new to me since I haven't played around with Lua that much and only started taking it seriously after I worked with this project. You're right, implementing too many languages has its draw backs and the suggestions you provided are really great. I'll work on it today and hopefully make a simple practical implementation.

Cheers :)

anantnrg commented 1 year ago

Hey @calops, I'm also a bit confused by this (lol, recursion):

I don't think there's any parsing to be done at all. This should be done by mlua entirely, you only need to provide a context (the strata module) and then load a lua file to let the runtime interpret it.

(I haven't done anything like this before so please bear with me)

So where I come from, when you parse a TOML file, you just store it to a variable and access the data. Now since Lua obviously doesn't work in the exact same way, I'm a bit confused as to how we access variables and at the same time run functions. For example, what I would do to handle keybindings previously, was to get all the keybinds, their keys and commands and then when any key is pressed, check if the pressed key(s) match a certain keybind and if they do, then just spawn a new child process with the command. Now with Lua and its functions and stuff, I'm assuming that we'd have to get the keybinds, watch for the key presses and if any keypresses match the bindings, then call back to the Lua runtime for the function to be executed.

Also, since this config is used pretty much everywhere, I previously stored it in a once_cell instance so that it can be accessed in all the files. Now I'm not exactly sure how to implement that.

It would be great if you could shine some light on this. Cheers :)

calops commented 1 year ago

What you're doing with lua is actually embedding a runtime within your program. What this means is that this runtime will be executing stuff independently, and you get to provide it with functions that can be called from within (the strata module that you can get with require()), as well as the ability to call functions within it yourself (you shouldn't have any need for this, but who know).

Disclaimer: I haven't used mlua much, so I'm far from an expert, but this is what I understand.

For configuration, there are two ways to go about it:

  1. You go the "return a big dict" route like wezterm (and your initial experiment) What this entails: you load the user configuration as a module, you call it, and you get the dict in return. You can then get that dict back into a rust struct by deserializing the result. I'm sure mlua offers plenty of utilities to do this. If there are lua functions in this dict, you should be able to keep calling them and it'll be hooked into the runtime by mlua.
  2. You go the "call a bunch of strata functions" route (the one I suggested) What this entails: you provide a bunch of functions in your strata module that will directly call rust code that will update a global state for the WM. Like before, you get the arguments passed to the function as rust structs deserialized by mlua (and I'm sure serde plays a role).

Both approaches should have roughly the same level of complexity, I think.

Also, since this config is used pretty much everywhere, I previously stored it in a once_cell instance so that it can be accessed in all the files. Now I'm not exactly sure how to implement that.

First of all, I'm not sure once_cell is the way forward, since you will probably want to be able to perform hot reloads of the configuration whenever the user changes it (this is very handy when tweaking things without having to force reload everything). But you can use some other synchronization method and it'll keep working just fine, so let's not focus on this (although I strongly recommend putting an abstraction over this so that you're free to change the synchronization method without changing all the call sites too much).

Virtually, you can keep doing it that way with both methods:

  1. You simply call the lua to get the config whenever you want, so you have control on when you set the configuration file. This is compatible with your current once_cell method.
  2. Every function in your strata module can access this global state exactly the same as the rest of your code. However, they would need to be able to modify it as well, so this method disquilifies once_cell right away.
calops commented 1 year ago

Hey, FWIW I'm gonna have some time to work on random stuff soon, and I'd love to help implement this (or other things in the WM). Where are you at on this?

anantnrg commented 1 year ago

I created a separate repo so that I can work on it a bit more easily. Its here. Currently, I've been able to execute the script and get the config tables. I changed the config quite a bit, more like what your suggested (or so I think). From what I've seen from one of the mlua examples for serializing data, the goto way seems to be just parsing them as JSON and then destructuring that data into a struct. This works for the config table where all the values are constant but for the bindings table, you can't parse a LuaFunction into a JSON struct. That's where I'm at currently. Also my term exams start on 16th so I have to study and I probably won't get much time to work on this. I can start working on it after the 25th. If you could make any changes and create a pull request, I'll try to check it out and merge it.

Cheers :)

calops commented 1 year ago

Thanks for the update!

I do have a few comments on the new format, so I'll work on it on a branch and show you what I was thinking of.

As for getting back functions, you're right, you can't just parse the table as JSON. However, there are serde utilities in mlua, I think? And I haven't tried it, but the crate mlua_serde might be useful.

I'll play around with all this and report back in a PR somewhere.

anantnrg commented 1 year ago

Hmm... that sounds about right. Last time I checked, the mlua_serde crate wasn't updated in a while so I thought that it might be abandoned but I guess if it works, then its okay. Well, I'm going to get back to studying now but I'll keep an eye out for an update. Cheers :)

anantnrg commented 1 year ago

Hey @calops, I think I've found a problem with the mlua_serde crate. Since it hasn't been updated in a while, its required version of mlua is pretty old so the install fails. Might have to fork it and update it quite a bit since the mlua APIs have undergone pretty much a whole redesign especially with version 0.9 . I'd love to know where you're at on this. I have a few hours of free time tomorrow so I could work on this a bit. Cheers :)

calops commented 1 year ago

I think we can forget that crate entirely, it seems mlua itself provides serde modules.

I'll be able to put an example together by this weekend, probably.

anantnrg commented 1 year ago

That's what I thought too. Well, I'll keep researching and let you know how it goes. Also if you could give me the config with your changes, then maybe I can work on parsing that.

calops commented 1 year ago

For the record, it's there: https://docs.rs/mlua/latest/mlua/serde/index.html. It needs the serialize feature flag.

Also if you could give me the config with your changes, then maybe I can work on parsing that.

Sure thing, I'll open a PR with my changes se we can discuss it there.

vazw commented 1 year ago

I've been able to execute the script and get the config tables. I changed the config quite a bit, more like what your suggested (or so I think). From what I've seen from one of the mlua examples for serializing data, the goto way seems to be just parsing them as JSON and then destructuring that data into a struct. This works for the config table where all the values are constant but for the bindings table, you can't parse a LuaFunction into a JSON struct.

Instead of trying to serialize the LuaFunction directly, can we represent it as a String in Rust struct?. This way, you can later evaluate this string as a LuaFunction when needed.

calops commented 1 year ago

Instead of trying to serialize the LuaFunction directly, can we represent it as a String in Rust struct?. This way, you can later evaluate this string as a LuaFunction when needed.

The thing is that you're not just getting a function, but a context as well (they're more akin to closures). Just the body of the function can't be enough since it may be capturing stuff outside of its scope.

Anyway, it's unnecessary since mlua provides a way to directly manipulate (and call) the Function objects, and it integrates perfectly with their serde framework.

vazw commented 1 year ago

Instead of trying to serialize the LuaFunction directly, can we represent it as a String in Rust struct?. This way, you can later evaluate this string as a LuaFunction when needed.

The thing is that you're not just getting a function, but a context as well (they're more akin to closures). Just the body of the function can't be enough since it may be capturing stuff outside of its scope.

Anyway, it's unnecessary since mlua provides a way to directly manipulate (and call) the Function objects, and it integrates perfectly with their serde framework.

I'll wait for your config file then maybe I can figure out what to do.

calops commented 1 year ago

There you go: https://github.com/calops/stratawm/blob/luaconf/strata.default.lua

The PR is there: #18

anantnrg commented 1 year ago

As a basic config has been achieved, I'll be closing this issue. For any further feature requests, please create a new issue.