summaryrefslogtreecommitdiff
path: root/lang/lua/astal
diff options
context:
space:
mode:
Diffstat (limited to 'lang/lua/astal')
-rw-r--r--lang/lua/astal/binding.lua71
-rw-r--r--lang/lua/astal/file.lua45
-rw-r--r--lang/lua/astal/gtk3/app.lua96
-rw-r--r--lang/lua/astal/gtk3/astalify.lua236
-rw-r--r--lang/lua/astal/gtk3/init.lua5
-rw-r--r--lang/lua/astal/gtk3/widget.lua90
-rw-r--r--lang/lua/astal/init.lua27
-rw-r--r--lang/lua/astal/process.lua78
-rw-r--r--lang/lua/astal/time.lua27
-rw-r--r--lang/lua/astal/variable.lua276
10 files changed, 951 insertions, 0 deletions
diff --git a/lang/lua/astal/binding.lua b/lang/lua/astal/binding.lua
new file mode 100644
index 0000000..ba1e6e4
--- /dev/null
+++ b/lang/lua/astal/binding.lua
@@ -0,0 +1,71 @@
+local lgi = require("lgi")
+local GObject = lgi.require("GObject", "2.0")
+
+---@class Binding
+---@field emitter table|Variable
+---@field property? string
+---@field transformFn function
+local Binding = {}
+
+---@param emitter table
+---@param property? string
+---@return Binding
+function Binding.new(emitter, property)
+ return setmetatable({
+ emitter = emitter,
+ property = property,
+ transformFn = function(v)
+ return v
+ end,
+ }, Binding)
+end
+
+function Binding:__tostring()
+ local str = "Binding<" .. tostring(self.emitter)
+ if self.property ~= nil then
+ str = str .. ", " .. self.property
+ end
+ return str .. ">"
+end
+
+function Binding:get()
+ if self.property ~= nil and GObject.Object:is_type_of(self.emitter) then
+ return self.transformFn(self.emitter[self.property])
+ end
+ if type(self.emitter.get) == "function" then
+ return self.transformFn(self.emitter:get())
+ end
+ error("can not get: Not a GObject or a Variable " + self)
+end
+
+---@param transform fun(value: any): any
+---@return Binding
+function Binding:as(transform)
+ local b = Binding.new(self.emitter, self.property)
+ b.transformFn = function(v)
+ return transform(self.transformFn(v))
+ end
+ return b
+end
+
+---@param callback fun(value: any)
+---@return function
+function Binding:subscribe(callback)
+ if self.property ~= nil and GObject.Object:is_type_of(self.emitter) then
+ local id = self.emitter.on_notify:connect(function()
+ callback(self:get())
+ end, self.property, false)
+ return function()
+ GObject.signal_handler_disconnect(self.emitter, id)
+ end
+ end
+ if type(self.emitter.subscribe) == "function" then
+ return self.emitter:subscribe(function()
+ callback(self:get())
+ end)
+ end
+ error("can not subscribe: Not a GObject or a Variable " + self)
+end
+
+Binding.__index = Binding
+return Binding
diff --git a/lang/lua/astal/file.lua b/lang/lua/astal/file.lua
new file mode 100644
index 0000000..e3be783
--- /dev/null
+++ b/lang/lua/astal/file.lua
@@ -0,0 +1,45 @@
+local lgi = require("lgi")
+local Astal = lgi.require("AstalIO", "0.1")
+local GObject = lgi.require("GObject", "2.0")
+
+local M = {}
+
+---@param path string
+---@return string
+function M.read_file(path)
+ return Astal.read_file(path)
+end
+
+---@param path string
+---@param callback fun(content: string, err: string): nil
+function M.read_file_async(path, callback)
+ Astal.read_file_async(path, function(_, res)
+ local content, err = Astal.read_file_finish(res)
+ callback(content, err)
+ end)
+end
+
+---@param path string
+---@param content string
+function M.write_file(path, content)
+ Astal.write_file(path, content)
+end
+
+---@param path string
+---@param content string
+---@param callback? fun(err: string): nil
+function M.write_file_async(path, content, callback)
+ Astal.write_file_async(path, content, function(_, res)
+ if type(callback) == "function" then
+ callback(Astal.write_file_finish(res))
+ end
+ end)
+end
+
+---@param path string
+---@param callback fun(file: string, event: integer): nil
+function M.monitor_file(path, callback)
+ return Astal.monitor_file(path, GObject.Closure(callback))
+end
+
+return M
diff --git a/lang/lua/astal/gtk3/app.lua b/lang/lua/astal/gtk3/app.lua
new file mode 100644
index 0000000..7895f69
--- /dev/null
+++ b/lang/lua/astal/gtk3/app.lua
@@ -0,0 +1,96 @@
+local lgi = require("lgi")
+local Astal = lgi.require("Astal", "3.0")
+local AstalIO = lgi.require("AstalIO", "0.1")
+
+local AstalLua = Astal.Application:derive("AstalLua")
+local request_handler
+
+function AstalLua:do_request(msg, conn)
+ if type(request_handler) == "function" then
+ request_handler(msg, function(response)
+ AstalIO.write_sock(conn, tostring(response), function(_, res)
+ AstalIO.write_sock_finish(res)
+ end)
+ end)
+ else
+ Astal.Application.do_request(self, msg, conn)
+ end
+end
+
+function AstalLua:quit(code)
+ Astal.Application.quit(self)
+ os.exit(code)
+end
+
+local app = AstalLua()
+
+---@class StartConfig
+---@field icons? string
+---@field instance_name? string
+---@field gtk_theme? string
+---@field icon_theme? string
+---@field cursor_theme? string
+---@field css? string
+---@field hold? boolean
+---@field request_handler? fun(msg: string, response: fun(res: any))
+---@field main? fun(...): unknown
+---@field client? fun(message: fun(msg: string): string, ...): unknown
+
+---@param config StartConfig | nil
+function Astal.Application:start(config)
+ if config == nil then
+ config = {}
+ end
+
+ if config.client == nil then
+ config.client = function()
+ print('Astal instance "' .. app.instance_name .. '" is already running')
+ os.exit(1)
+ end
+ end
+
+ if config.hold == nil then
+ config.hold = true
+ end
+
+ request_handler = config.request_handler
+
+ if config.css then
+ self:apply_css(config.css)
+ end
+ if config.icons then
+ self:add_icons(config.icons)
+ end
+ if config.instance_name then
+ self.instance_name = config.instance_name
+ end
+ if config.gtk_theme then
+ self.gtk_theme = config.gtk_theme
+ end
+ if config.icon_theme then
+ self.icon_theme = config.icon_theme
+ end
+ if config.cursor_theme then
+ self.cursor_theme = config.cursor_theme
+ end
+
+ app.on_activate = function()
+ if type(config.main) == "function" then
+ config.main(table.unpack(arg))
+ end
+ if config.hold then
+ self:hold()
+ end
+ end
+
+ local _, err = app:acquire_socket()
+ if err ~= nil then
+ return config.client(function(msg)
+ return AstalIO.send_message(self.instance_name, msg)
+ end, table.unpack(arg))
+ end
+
+ self:run(nil)
+end
+
+return app
diff --git a/lang/lua/astal/gtk3/astalify.lua b/lang/lua/astal/gtk3/astalify.lua
new file mode 100644
index 0000000..065de40
--- /dev/null
+++ b/lang/lua/astal/gtk3/astalify.lua
@@ -0,0 +1,236 @@
+local lgi = require("lgi")
+local Astal = lgi.require("Astal", "3.0")
+local Gtk = lgi.require("Gtk", "3.0")
+local GObject = lgi.require("GObject", "2.0")
+local Binding = require("astal.lib.binding")
+local Variable = require("astal.lib.variable")
+local exec_async = require("astal.lib.process").exec_async
+
+local function filter(tbl, fn)
+ local copy = {}
+ for key, value in pairs(tbl) do
+ if fn(value, key) then
+ if type(key) == "number" then
+ table.insert(copy, value)
+ else
+ copy[key] = value
+ end
+ end
+ end
+ return copy
+end
+
+local function map(tbl, fn)
+ local copy = {}
+ for key, value in pairs(tbl) do
+ copy[key] = fn(value)
+ end
+ return copy
+end
+
+local flatten
+flatten = function(tbl)
+ local copy = {}
+ for _, value in pairs(tbl) do
+ if type(value) == "table" and getmetatable(value) == nil then
+ for _, inner in pairs(flatten(value)) do
+ table.insert(copy, inner)
+ end
+ else
+ table.insert(copy, value)
+ end
+ end
+ return copy
+end
+
+local function includes(tbl, elem)
+ for _, value in pairs(tbl) do
+ if value == elem then
+ return true
+ end
+ end
+ return false
+end
+
+local function set_children(parent, children)
+ children = map(flatten(children), function(item)
+ if Gtk.Widget:is_type_of(item) then
+ return item
+ end
+ return Gtk.Label({
+ visible = true,
+ label = tostring(item),
+ })
+ end)
+
+ -- remove
+ if Gtk.Bin:is_type_of(parent) then
+ local ch = parent:get_child()
+ if ch ~= nil then
+ parent:remove(ch)
+ end
+ if ch ~= nil and not includes(children, ch) and not parent.no_implicit_destroy then
+ ch:destroy()
+ end
+ elseif Gtk.Container:is_type_of(parent) then
+ for _, ch in ipairs(parent:get_children()) do
+ parent:remove(ch)
+ if ch ~= nil and not includes(children, ch) and not parent.no_implicit_destroy then
+ ch:destroy()
+ end
+ end
+ end
+
+ -- TODO: add more container types
+ if Astal.Box:is_type_of(parent) then
+ parent:set_children(children)
+ elseif Astal.Stack:is_type_of(parent) then
+ parent:set_children(children)
+ elseif Astal.CenterBox:is_type_of(parent) then
+ parent.start_widget = children[1]
+ parent.center_widget = children[2]
+ parent.end_widget = children[3]
+ elseif Astal.Overlay:is_type_of(parent) then
+ parent:set_child(children[1])
+ children[1] = nil
+ parent:set_overlays(children)
+ elseif Gtk.Container:is_type_of(parent) then
+ for _, child in pairs(children) do
+ if Gtk.Widget:is_type_of(child) then
+ parent:add(child)
+ end
+ end
+ end
+end
+
+local function merge_bindings(array)
+ local function get_values(...)
+ local args = { ... }
+ local i = 0
+ return map(array, function(value)
+ if getmetatable(value) == Binding then
+ i = i + 1
+ return args[i]
+ else
+ return value
+ end
+ end)
+ end
+
+ local bindings = filter(array, function(v)
+ return getmetatable(v) == Binding
+ end)
+
+ if #bindings == 0 then
+ return array
+ end
+
+ if #bindings == 1 then
+ return bindings[1]:as(get_values)
+ end
+
+ return Variable.derive(bindings, get_values)()
+end
+
+return function(ctor)
+ function ctor:hook(object, signalOrCallback, callback)
+ if GObject.Object:is_type_of(object) and type(signalOrCallback) == "string" then
+ local id
+ if string.sub(signalOrCallback, 1, 8) == "notify::" then
+ local prop = string.gsub(signalOrCallback, "notify::", "")
+ id = object.on_notify:connect(function()
+ callback(self, object[prop])
+ end, prop, false)
+ else
+ id = object["on_" .. signalOrCallback]:connect(function(_, ...)
+ callback(self, ...)
+ end)
+ end
+ self.on_destroy = function()
+ GObject.signal_handler_disconnect(object, id)
+ end
+ elseif type(object.subscribe) == "function" then
+ local unsub = object.subscribe(function(...)
+ signalOrCallback(self, ...)
+ end)
+ self.on_destroy = unsub
+ else
+ error("can not hook: not gobject+signal or subscribable")
+ end
+ end
+
+ function ctor:toggle_class_name(name, on)
+ Astal.widget_toggle_class_name(self, name, on)
+ end
+
+ return function(tbl)
+ if tbl == nil then
+ tbl = {}
+ end
+
+ local bindings = {}
+ local setup = tbl.setup
+
+ -- collect children
+ local children = merge_bindings(flatten(filter(tbl, function(_, key)
+ return type(key) == "number"
+ end)))
+
+ -- default visible to true
+ if type(tbl.visible) ~= "boolean" then
+ tbl.visible = true
+ end
+
+ -- collect props
+ local props = filter(tbl, function(_, key)
+ return type(key) == "string" and key ~= "setup"
+ end)
+
+ -- collect signal handlers
+ for prop, value in pairs(props) do
+ if string.sub(prop, 0, 2) == "on" and type(value) ~= "function" then
+ props[prop] = function()
+ exec_async(value, print)
+ end
+ end
+ end
+
+ -- collect bindings
+ for prop, value in pairs(props) do
+ if getmetatable(value) == Binding then
+ bindings[prop] = value
+ props[prop] = value:get()
+ end
+ end
+
+ -- construct, attach bindings, add children
+ local widget = ctor()
+
+ if getmetatable(children) == Binding then
+ set_children(widget, children:get())
+ widget.on_destroy = children:subscribe(function(v)
+ set_children(widget, v)
+ end)
+ else
+ if #children > 0 then
+ set_children(widget, children)
+ end
+ end
+
+ for prop, binding in pairs(bindings) do
+ widget.on_destroy = binding:subscribe(function(v)
+ widget[prop] = v
+ end)
+ end
+
+ for prop, value in pairs(props) do
+ widget[prop] = value
+ end
+
+ if type(setup) == "function" then
+ setup(widget)
+ end
+
+ return widget
+ end
+end
diff --git a/lang/lua/astal/gtk3/init.lua b/lang/lua/astal/gtk3/init.lua
new file mode 100644
index 0000000..6fb5455
--- /dev/null
+++ b/lang/lua/astal/gtk3/init.lua
@@ -0,0 +1,5 @@
+return {
+ App = require("astal.gtk3.app"),
+ astalify = require("astal.gtk3.astalify"),
+ Widget = require("astal.gtk3.widget"),
+}
diff --git a/lang/lua/astal/gtk3/widget.lua b/lang/lua/astal/gtk3/widget.lua
new file mode 100644
index 0000000..beaad6c
--- /dev/null
+++ b/lang/lua/astal/gtk3/widget.lua
@@ -0,0 +1,90 @@
+local lgi = require("lgi")
+local Astal = lgi.require("Astal", "3.0")
+local Gtk = lgi.require("Gtk", "3.0")
+local astalify = require("astal.gtk3.astalify")
+
+local Widget = {
+ astalify = astalify,
+ Box = astalify(Astal.Box),
+ Button = astalify(Astal.Button),
+ CenterBox = astalify(Astal.CenterBox),
+ CircularProgress = astalify(Astal.CircularProgress),
+ DrawingArea = astalify(Gtk.DrawingArea),
+ Entry = astalify(Gtk.Entry),
+ EventBox = astalify(Astal.EventBox),
+ -- TODO: Fixed
+ -- TODO: FlowBox
+ Icon = astalify(Astal.Icon),
+ Label = astalify(Gtk.Label),
+ LevelBar = astalify(Astal.LevelBar),
+ -- TODO: ListBox
+ Overlay = astalify(Astal.Overlay),
+ Revealer = astalify(Gtk.Revealer),
+ Scrollable = astalify(Astal.Scrollable),
+ Slider = astalify(Astal.Slider),
+ Stack = astalify(Astal.Stack),
+ Switch = astalify(Gtk.Switch),
+ Window = astalify(Astal.Window),
+}
+
+Gtk.Widget._attribute.css = {
+ get = Astal.widget_get_css,
+ set = Astal.widget_set_css,
+}
+
+Gtk.Widget._attribute.class_name = {
+ get = function(self)
+ local result = ""
+ local strings = Astal.widget_get_class_names(self)
+ for i, str in ipairs(strings) do
+ result = result .. str
+ if i < #strings then
+ result = result .. " "
+ end
+ end
+ return result
+ end,
+ set = function(self, class_name)
+ local names = {}
+ for word in class_name:gmatch("%S+") do
+ table.insert(names, word)
+ end
+ Astal.widget_set_class_names(self, names)
+ end,
+}
+
+Gtk.Widget._attribute.cursor = {
+ get = Astal.widget_get_cursor,
+ set = Astal.widget_set_cursor,
+}
+
+Gtk.Widget._attribute.click_through = {
+ get = Astal.widget_get_click_through,
+ set = Astal.widget_set_click_through,
+}
+
+local no_implicit_destroy = {}
+Gtk.Widget._attribute.no_implicit_destroy = {
+ get = function(self)
+ return no_implicit_destroy[self] or false
+ end,
+ set = function(self, v)
+ if no_implicit_destroy[self] == nil then
+ self.on_destroy = function()
+ no_implicit_destroy[self] = nil
+ end
+ end
+ no_implicit_destroy[self] = v
+ end,
+}
+
+Astal.Box._attribute.children = {
+ get = Astal.Box.get_children,
+ set = Astal.Box.set_children,
+}
+
+return setmetatable(Widget, {
+ __call = function(_, ctor)
+ return astalify(ctor)
+ end,
+})
diff --git a/lang/lua/astal/init.lua b/lang/lua/astal/init.lua
new file mode 100644
index 0000000..f442db0
--- /dev/null
+++ b/lang/lua/astal/init.lua
@@ -0,0 +1,27 @@
+local lgi = require("lgi")
+local Binding = require("astal.binding")
+local File = require("astal.file")
+local Process = require("astal.proc")
+local Time = require("astal.time")
+local Variable = require("astal.variable")
+
+return {
+ Variable = Variable,
+ bind = Binding.new,
+
+ interval = Time.interval,
+ timeout = Time.timeout,
+ idle = Time.idle,
+
+ subprocess = Process.subprocess,
+ exec = Process.exec,
+ exec_async = Process.exec_async,
+
+ read_file = File.read_file,
+ read_file_async = File.read_file_async,
+ write_file = File.write_file,
+ write_file_async = File.write_file_async,
+ monitor_file = File.monitor_file,
+
+ require = lgi.require,
+}
diff --git a/lang/lua/astal/process.lua b/lang/lua/astal/process.lua
new file mode 100644
index 0000000..b8b7436
--- /dev/null
+++ b/lang/lua/astal/process.lua
@@ -0,0 +1,78 @@
+local lgi = require("lgi")
+local Astal = lgi.require("AstalIO", "0.1")
+
+local M = {}
+
+---@param commandline string | string[]
+---@param on_stdout? fun(out: string): nil
+---@param on_stderr? fun(err: string): nil
+---@return { kill: function } | nil proc
+function M.subprocess(commandline, on_stdout, on_stderr)
+ if on_stdout == nil then
+ on_stdout = function(out)
+ io.stdout:write(tostring(out) .. "\n")
+ end
+ end
+
+ if on_stderr == nil then
+ on_stderr = function(err)
+ io.stderr:write(tostring(err) .. "\n")
+ end
+ end
+
+ local proc, err
+ if type(commandline) == "table" then
+ proc, err = Astal.Process.subprocessv(commandline)
+ else
+ proc, err = Astal.Process.subprocess(commandline)
+ end
+ if err ~= nil then
+ err(err)
+ return nil
+ end
+ proc.on_stdout = function(_, stdoud)
+ on_stdout(stdoud)
+ end
+ proc.on_stderr = function(_, stderr)
+ on_stderr(stderr)
+ end
+ return proc
+end
+
+---@param commandline string | string[]
+---@return string, string
+function M.exec(commandline)
+ if type(commandline) == "table" then
+ return Astal.Process.execv(commandline)
+ else
+ return Astal.Process.exec(commandline)
+ end
+end
+
+---@param commandline string | string[]
+---@param callback? fun(out: string, err: string): nil
+function M.exec_async(commandline, callback)
+ if callback == nil then
+ callback = function(out, err)
+ if err ~= nil then
+ io.stdout:write(tostring(out) .. "\n")
+ else
+ io.stderr:write(tostring(err) .. "\n")
+ end
+ end
+ end
+
+ if type(commandline) == "table" then
+ Astal.Process.exec_asyncv(commandline, function(_, res)
+ local out, err = Astal.Process.exec_asyncv_finish(res)
+ callback(out, err)
+ end)
+ else
+ Astal.Process.exec_async(commandline, function(_, res)
+ local out, err = Astal.Process.exec_finish(res)
+ callback(out, err)
+ end)
+ end
+end
+
+return M
diff --git a/lang/lua/astal/time.lua b/lang/lua/astal/time.lua
new file mode 100644
index 0000000..7719da9
--- /dev/null
+++ b/lang/lua/astal/time.lua
@@ -0,0 +1,27 @@
+local lgi = require("lgi")
+local Astal = lgi.require("AstalIO", "0.1")
+local GObject = lgi.require("GObject", "2.0")
+
+local M = {}
+
+---@param interval number
+---@param fn function
+---@return { cancel: function, on_now: function }
+function M.interval(interval, fn)
+ return Astal.Time.interval(interval, GObject.Closure(fn))
+end
+
+---@param timeout number
+---@param fn function
+---@return { cancel: function, on_now: function }
+function M.timeout(timeout, fn)
+ return Astal.Time.timeout(timeout, GObject.Closure(fn))
+end
+
+---@param fn function
+---@return { cancel: function, on_now: function }
+function M.idle(fn)
+ return Astal.Time.idle(GObject.Closure(fn))
+end
+
+return M
diff --git a/lang/lua/astal/variable.lua b/lang/lua/astal/variable.lua
new file mode 100644
index 0000000..5a5e169
--- /dev/null
+++ b/lang/lua/astal/variable.lua
@@ -0,0 +1,276 @@
+local lgi = require("lgi")
+local Astal = lgi.require("AstalIO", "0.1")
+local GObject = lgi.require("GObject", "2.0")
+local Binding = require("astal.binding")
+local Time = require("astal.time")
+local Process = require("astal.process")
+
+---@class Variable
+---@field private variable table
+---@field private err_handler? function
+---@field private _value any
+---@field private _poll? table
+---@field private _watch? table
+---@field private poll_interval number
+---@field private poll_exec? string[] | string
+---@field private poll_transform? fun(next: any, prev: any): any
+---@field private poll_fn? function
+---@field private watch_transform? fun(next: any, prev: any): any
+---@field private watch_exec? string[] | string
+local Variable = {}
+Variable.__index = Variable
+
+---@param value any
+---@return Variable
+function Variable.new(value)
+ local v = Astal.VariableBase()
+ local variable = setmetatable({
+ variable = v,
+ _value = value,
+ }, Variable)
+ v.on_dropped = function()
+ variable:stop_watch()
+ variable:stop_watch()
+ end
+ v.on_error = function(_, err)
+ if variable.err_handler then
+ variable.err_handler(err)
+ end
+ end
+ return variable
+end
+
+---@param transform function
+---@return Binding
+function Variable:__call(transform)
+ if transform == nil then
+ transform = function(v)
+ return v
+ end
+ return Binding.new(self)
+ end
+ return Binding.new(self):as(transform)
+end
+
+function Variable:__tostring()
+ return "Variable<" .. tostring(self:get()) .. ">"
+end
+
+function Variable:get()
+ return self._value or nil
+end
+
+function Variable:set(value)
+ if value ~= self:get() then
+ self._value = value
+ self.variable:emit_changed()
+ end
+end
+
+function Variable:start_poll()
+ if self._poll ~= nil then
+ return
+ end
+
+ if self.poll_fn then
+ self._poll = Time.interval(self.poll_interval, function()
+ self:set(self.poll_fn(self:get()))
+ end)
+ elseif self.poll_exec then
+ self._poll = Time.interval(self.poll_interval, function()
+ Process.exec_async(self.poll_exec, function(out, err)
+ if err ~= nil then
+ return self.variable.emit_error(err)
+ end
+ self:set(self.poll_transform(out, self:get()))
+ end)
+ end)
+ end
+end
+
+function Variable:start_watch()
+ if self._watch then
+ return
+ end
+
+ self._watch = Process.subprocess(self.watch_exec, function(out)
+ self:set(self.watch_transform(out, self:get()))
+ end, function(err)
+ self.variable.emit_error(err)
+ end)
+end
+
+function Variable:stop_poll()
+ if self._poll then
+ self._poll.cancel()
+ end
+ self._poll = nil
+end
+
+function Variable:stop_watch()
+ if self._watch then
+ self._watch.kill()
+ end
+ self._watch = nil
+end
+
+function Variable:is_polling()
+ return self._poll ~= nil
+end
+
+function Variable:is_watching()
+ return self._watch ~= nil
+end
+
+function Variable:drop()
+ self.variable.emit_dropped()
+end
+
+---@param callback function
+---@return Variable
+function Variable:on_dropped(callback)
+ self.variable.on_dropped = callback
+ return self
+end
+
+---@param callback function
+---@return Variable
+function Variable:on_error(callback)
+ self.err_handler = nil
+ self.variable.on_eror = function(_, err)
+ callback(err)
+ end
+ return self
+end
+
+---@param callback fun(value: any)
+---@return function
+function Variable:subscribe(callback)
+ local id = self.variable.on_changed:connect(function()
+ callback(self:get())
+ end)
+ return function()
+ GObject.signal_handler_disconnect(self.variable, id)
+ end
+end
+
+---@param interval number
+---@param exec string | string[] | function
+---@param transform? fun(next: any, prev: any): any
+function Variable:poll(interval, exec, transform)
+ if transform == nil then
+ transform = function(next)
+ return next
+ end
+ end
+ self:stop_poll()
+ self.poll_interval = interval
+ self.poll_transform = transform
+
+ if type(exec) == "function" then
+ self.poll_fn = exec
+ self.poll_exec = nil
+ else
+ self.poll_exec = exec
+ self.poll_fn = nil
+ end
+ self:start_poll()
+ return self
+end
+
+---@param exec string | string[]
+---@param transform? fun(next: any, prev: any): any
+function Variable:watch(exec, transform)
+ if transform == nil then
+ transform = function(next)
+ return next
+ end
+ end
+ self:stop_poll()
+ self.watch_exec = exec
+ self.watch_transform = transform
+ self:start_watch()
+ return self
+end
+
+---@param object table | table[]
+---@param sigOrFn string | fun(...): any
+---@param callback fun(...): any
+---@return Variable
+function Variable:observe(object, sigOrFn, callback)
+ local f
+ if type(sigOrFn) == "function" then
+ f = sigOrFn
+ elseif type(callback) == "function" then
+ f = callback
+ else
+ f = function()
+ return self:get()
+ end
+ end
+ local set = function(...)
+ self:set(f(...))
+ end
+
+ if type(sigOrFn) == "string" then
+ object["on_" .. sigOrFn]:connect(set)
+ else
+ for _, obj in ipairs(object) do
+ obj[1]["on_" .. obj[2]]:connect(set)
+ end
+ end
+ return self
+end
+
+---@param deps Variable | (Binding | Variable)[]
+---@param transform? fun(...): any
+---@return Variable
+function Variable.derive(deps, transform)
+ if type(transform) == "nil" then
+ transform = function(...)
+ return { ... }
+ end
+ end
+
+ if getmetatable(deps) == Variable then
+ local var = Variable.new(transform(deps:get()))
+ deps:subscribe(function(v)
+ var:set(transform(v))
+ end)
+ return var
+ end
+
+ for i, var in ipairs(deps) do
+ if getmetatable(var) == Variable then
+ deps[i] = Binding.new(var)
+ end
+ end
+
+ local update = function()
+ local params = {}
+ for i, binding in ipairs(deps) do
+ params[i] = binding:get()
+ end
+ return transform(table.unpack(params), 1, #deps)
+ end
+
+ local var = Variable.new(update())
+
+ local unsubs = {}
+ for i, b in ipairs(deps) do
+ unsubs[i] = b:subscribe(update)
+ end
+
+ var.variable.on_dropped = function()
+ for _, unsub in ipairs(unsubs) do
+ unsub()
+ end
+ end
+ return var
+end
+
+return setmetatable(Variable, {
+ __call = function(_, v)
+ return Variable.new(v)
+ end,
+})