init 0.1.0

This commit is contained in:
Aylur
2024-05-19 02:39:53 +02:00
commit 1425b396b0
60 changed files with 8205 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
build/
result
.cache/
test.sh
tmp/

219
README.md Normal file
View File

@@ -0,0 +1,219 @@
# libastal
> [!WARNING]
> WIP: everything is subject to change
The main goal of this project is to further abstract gtk bindings in higher level
languages with custom state management mechanisms namely in javascript (gjs, node),
lua (lua-lgi) and python (pygobject)
libastal, which is the library written in Vala, comes with
a few widgets built on top of gtk3 and
tools to execute external binaries and store their output
it also comes with a builtin cli client
## Developing
first install libastal or enter nix shell
```bash
bash meson-install.sh # non nix
nix develop .#astal # nix
```
python and lua should be stright forward, have a look at sample.py and sample.lua
for javascript do
```bash
cd js
npm i
npm run types
npm run build -- --watch
```
## Gtk abstractions
`Variable` and `Binding` objects and a function that turns widget constructors
into ones that can take `Binding` objects as parameters are added ontop of gtk bindings
this mechanism takes care of all state management one would need
This works the same in js/lua/python, its just demonstrated in js
```javascript
// this example will work with Variable<string>
// but it can take any type of value
const v = Variable("value")
.poll(1000, "some-executable on $PATH")
.poll(1000, ["some-executable", "with", "args"])
.poll(1000, () => "some-function")
.watch("some-executable")
.watch(["some-executable", "with", "args"])
.observe(someGObject, "signal", (...args) => "some output")
.observe([[gobj1, "signal"], [gobj2, "signal"]], (...args) => "some output")
.onError(console.error) // when the script fails
.onDropped(() => "clean-up") // cleanup resources if needed on drop() or GC
Button({
label: bind(v),
label: bind(v).as(v => "transformed"),
label: v(t => "transformed"), // shorthand for the above
// in ags we have Service.bind("prop")
// here we will do this, since gobject implementations
// will come from Vala code and not js
label: bind(anyGObject, "one-of-its-prop").as(prop => "transformed"),
// event handlers
on_signalname(self, ...args) { print(self, args) },
// setup prop is still here, but should be rarely needed
setup(self) {
self.hook(v, (self) => print(self))
self.hook(gobject, "signal", (self) => print(self))
}
})
// some additional Variable and Binding methods
v.stop_poll()
v.start_poll()
v.stop_watch()
v.start_watch()
v.get()
v.set("new-value")
const unsub = v.subscribe(value => console.log(value))
unsub() // to unsubscribe
const b = bind(v)
b.get()
// note that its value cannot be set through a Binding
// if you want to, you are doing something wrong
// same subscribe mechanism
const unsub = b.subscribe(value => console.log(value))
unsub()
const derived = Variable.derive([v, b], (vval, bval) => {
return "can take a list of Variable | Binding"
})
v.drop() // dispose when no longer needed
// handle cli client
App.start({
instanceName: "my-instance",
responseHandler(msg, response) {
console.log("message from cli", msg)
response("hi")
}
})
```
after `App.start` is called, it will open a socket, which can be used
with the cli client that comes with libastal
```bash
astal --instance-name my-instance "message was sent from cli"
```
## Lower level languages
As said before, the main goal is to make js/lua/python dx better, but libastal
can be used in **any** language that has bindings for glib/gtk.
`Binding` is not implemented in Vala, but in each language, because
they are language specific, and it doesn't make much sense for lower
level languages as they usually don't have a way to declaratively build
layouts. Subclassed widgets and `Variable` can still be used, but they will
need to be hooked **imperatively**. For languages like rust/go/c++
you will mostly benefit from the other libraries (called `Service` in ags).
I can also recommend using [blueprint](https://jwestman.pages.gitlab.gnome.org/blueprint-compiler/)
which lets you define layouts declaratively and hook functionality in your
preferred language.
I am open to add support for any other language if it makes sense,
but if using blueprint makes more sense, I would rather maintain
templates and examples instead to get started with development.
## Goals
- libastal
- Variables
- [x] poll (interval, string)
- [x] pollv (interval, string[])
- [x] pollfn (interval, closure)
- [x] watch (string)
- [x] watchv (string[])
- [ ] ?observe (object, signal, closure)
- Time
- [x] interval
- [x] timeout
- [x] idle
- [x] now signal
- Process
- [x] exec: string, error as Error
- [x] execAsync: proc, stdout, stderr signal
- [x] subprocess: proc, stdout, stderr signal
- app instance with a socket: Application
- [x] gtk settings as props
- [x] window getters
- [x] include cli client
- few additional widgets
- [x] window widget with gtk-layer-shell
- [x] box with children prop
- [ ] button with abstract signals for button-event
- [ ] ?custom calendar like gtk4
- [x] centerbox
- [ ] circularprogress
- [ ] eventbox
- [ ] icon
- [ ] overlay
- [ ] scrollable/viewport
- [ ] slider
- [ ] stack, shown, children setter
- widgets with no additional behaviour only for the sake of it
- [ ] ?drawingarea
- [ ] ?entry
- [ ] ?fixed
- [ ] ?flowbox
- [ ] ?label
- [ ] ?levelbar
- [ ] ?revealer
- [ ] ?switch
- widget prop setters
- [x] css
- [x] class-names
- [x] cursor
- [ ] click-through
- language bindings
- Binding for Variable and any GObject `bind(gobject, property).as(transform)`
- .hook() for widgets
- setup prop for widgets
- constructor overrides to take bindings
- override default `visible` for widgets to true
- wrap Variable in native object to make sure no GValue crashes
- Variable.observe for signals
- Variable.derive that takes either Variables or Bindings
## Help needed
- packaging
- I am not familiar with python or lua ecosystem at all how should they be distributed?
- node-gtk promise issue
- python types
- I don't know much python, and quite honestly I hate python
## TODO
- docs
- consider moving each language into separate repo
- I want to keep these at one place until I'm sure all of them works as expected
- support jsx
- make sure conventions like the casing of names are followed for each language
- I constantly switched between 4 languages they might be off
- port services from ags into Vala
- and add more

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1715266358,
"narHash": "sha256-doPgfj+7FFe9rfzWo1siAV2mVCasW+Bh8I1cToAXEE4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "f1010e0469db743d14519a1efd37e23f8513d714",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

58
flake.nix Normal file
View File

@@ -0,0 +1,58 @@
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
version = builtins.replaceStrings ["\n"] [""] (builtins.readFile ./version);
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
nativeBuildInputs = with pkgs; [
wrapGAppsHook
gobject-introspection
meson
pkg-config
ninja
vala
];
buildInputs = with pkgs; [
glib
gtk3
gtk-layer-shell
];
in {
packages.${system} = rec {
default = astal;
astal = pkgs.stdenv.mkDerivation {
inherit nativeBuildInputs buildInputs;
pname = "astal";
version = version;
src = ./.;
outputs = ["out" "dev"];
};
};
devShells.${system} = let
inputs = with pkgs; buildInputs ++ [
(lua.withPackages(ps: [ps.lgi]))
(python3.withPackages(ps: [ps.pygobject3]))
gjs
deno
nodejs
];
in {
default = pkgs.mkShell {
inherit nativeBuildInputs;
buildInputs = inputs;
};
astal = pkgs.mkShell {
inherit nativeBuildInputs;
buildInputs = inputs ++ [
self.packages.${system}.astal
pkgs.playerctl # FIXME: just for demo
];
};
};
};
}

128
js/.eslintrc.yml Normal file
View File

@@ -0,0 +1,128 @@
env:
es2021: true
extends:
- "eslint:recommended"
- "plugin:@typescript-eslint/recommended"
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: 2022
sourceType: "module"
project: "./tsconfig.json"
warnOnUnsupportedTypeScriptVersion: false
root: true
ignorePatterns:
- example/
- types/
- gi-types/
- _build/
- build/
- result/
plugins:
- "@typescript-eslint"
rules:
"@typescript-eslint/ban-ts-comment": error
"@typescript-eslint/no-non-null-assertion": off
"@typescript-eslint/no-explicit-any": off
"@typescript-eslint/no-unused-vars":
- error
- varsIgnorePattern: (^unused|_$)
argsIgnorePattern: ^(unused|_)
"@typescript-eslint/no-empty-interface": off
"@typescript-eslint/no-namespace": off
"@typescript-eslint/prefer-namespace-keyword": off
"@typescript-eslint/ban-types": off
arrow-parens:
- error
- as-needed
comma-dangle:
- error
- always-multiline
comma-spacing:
- error
- before: false
after: true
comma-style:
- error
- last
curly:
- error
- multi-or-nest
- consistent
dot-location:
- error
- property
eol-last:
- error
indent:
- error
- 4
- SwitchCase: 1
keyword-spacing:
- error
- before: true
lines-between-class-members:
- error
- always
- exceptAfterSingleLine: true
padded-blocks:
- error
- never
- allowSingleLineBlocks: false
prefer-const:
- error
quotes:
- error
- double
- avoidEscape: true
semi:
- error
- never
nonblock-statement-body-position:
- error
- below
no-trailing-spaces:
- error
no-useless-escape:
- off
max-len:
- error
- code: 100
func-call-spacing:
- error
array-bracket-spacing:
- error
space-before-function-paren:
- error
- anonymous: never
named: never
asyncArrow: ignore
space-before-blocks:
- error
key-spacing:
- error
object-curly-spacing:
- error
- always
globals:
pkg: readonly
ARGV: readonly
Debugger: readonly
GIRepositoryGType: readonly
globalThis: readonly
imports: readonly
Intl: readonly
log: readonly
logError: readonly
print: readonly
printerr: readonly
window: readonly
TextEncoder: readonly
TextDecoder: readonly
console: readonly
setTimeout: readonly
setInterval: readonly
clearTimeout: readonly
clearInterval: readonly

4
js/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
result
@girs/
dist/

7
js/.ts-for-girrc.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
"verbose": true,
"environments": ["gjs", "node"],
"outdir": "@girs",
"package": true,
"generateAlias": true,
}

51
js/gjs/application.ts Normal file
View File

@@ -0,0 +1,51 @@
import Astal from "gi://Astal"
import GObject from "gi://GObject"
import Gio from "gi://Gio"
import { RequestHandler, Config, runJS } from "../src/application.js"
// @ts-expect-error missing types
// https://github.com/gjsify/ts-for-gir/issues/164
import { setConsoleLogDomain } from "console"
import { exit, programArgs } from "system"
class AstalJS extends Astal.Application {
static { GObject.registerClass(this) }
eval = runJS
requestHandler?: RequestHandler
vfunc_response(msg: string, conn: Gio.SocketConnection): void {
if (typeof this.requestHandler === "function") {
this.requestHandler(msg, response => {
Astal.write_sock(conn, response, (_, res) =>
Astal.write_sock_finish(res),
)
})
} else {
super.vfunc_response(msg, conn)
}
}
start({ requestHandler, css, hold, ...cfg }: Config = {}, callback?: (args: string[]) => void) {
Object.assign(this, cfg)
setConsoleLogDomain(this.instanceName)
this.requestHandler = requestHandler
this.connect("activate", () => callback?.(programArgs))
if (!this.acquire_socket()) {
print(`Astal instance "${this.instanceName}" already running`)
exit(1)
}
if (css)
this.apply_css(css, false)
hold ??= true
if (hold)
this.hold()
this.runAsync([])
}
}
export const App = new AstalJS

37
js/gjs/astal.ts Normal file
View File

@@ -0,0 +1,37 @@
import Astal from "gi://Astal"
import Time from "../src/time.js"
import Process from "../src/process.js"
import * as variable from "../src/variable.js"
const { interval, timeout, idle } = Time(Astal.Time)
const { subprocess, exec, execAsync } = Process({
defaultOut: print,
defaultErr: console.error,
exec: Astal.Process.exec,
execv: Astal.Process.execv,
execAsync: Astal.Process.exec_async,
execAsyncv: Astal.Process.exec_asyncv,
subprocess: Astal.Process.subprocess,
subprocessv: Astal.Process.subprocessv,
})
variable.config.defaultErrHandler = print
variable.config.execAsync = execAsync
variable.config.subprocess = subprocess
variable.config.interval = interval
variable.config.VariableBase = Astal.VariableBase
Object.freeze(variable.config)
export { subprocess, exec, execAsync }
export { interval, timeout, idle }
export { bind } from "../src/binding.js"
export { Variable } from "../src/variable.js"
export * as Widget from "./widgets.js"
export { App } from "./application.js"
// for convinience
export { default as GLib } from "gi://GLib?version=2.0"
export { default as Gtk } from "gi://Gtk?version=3.0"
export { default as Gio } from "gi://Gio?version=2.0"
export { default as GObject } from "gi://GObject?version=2.0"
export { default as Astal } from "gi://Astal?version=0.1"

60
js/gjs/widgets.ts Normal file
View File

@@ -0,0 +1,60 @@
/* eslint-disable max-len */
import Gtk from "gi://Gtk"
import Astal from "gi://Astal"
import { kebabify } from "../src/binding.js"
import proxy, { type ConstructProps, type Widget } from "../src/astalify.js"
const proxify = proxy(Gtk,
prop => `set_${kebabify(prop).replaceAll("-", "_")}`,
{
cssGetter: Astal.widget_get_css,
cssSetter: Astal.widget_set_css,
classGetter: Astal.widget_get_class_names,
classSetter: Astal.widget_set_class_names,
cursorGetter: Astal.widget_get_cursor,
cursorSetter: Astal.widget_set_cursor,
})
export function astalify<
C extends typeof Gtk.Widget,
P extends Record<string, any>,
N extends string = "Widget",
>(klass: C) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Astal<N> = Omit<C, "new"> & {
new(props?: P, ...children: Gtk.Widget[]): Widget<C>
(props?: P, ...children: Gtk.Widget[]): Widget<C>
}
return proxify(klass) as unknown as Astal<N>
}
// Label
export const Label = astalify<typeof Gtk.Label, LabelProps, "Label">(Gtk.Label)
export type LabelProps = ConstructProps<typeof Gtk.Label, Gtk.Label.ConstructorProperties>
// Icon
export const Icon = astalify<typeof Astal.Icon, IconProps, "Icon">(Astal.Icon)
export type IconProps = ConstructProps<typeof Astal.Icon, Astal.Icon.ConstructorProperties>
// Button
export const Button = astalify<typeof Astal.Button, ButtonProps, "Button">(Astal.Button)
export type ButtonProps = ConstructProps<typeof Astal.Button, Astal.Button.ConstructorProperties, {
onClicked: (self: Widget<typeof Astal.Button>) => void
}>
// Window
export const Window = astalify<typeof Astal.Window, WindowProps, "Window">(Astal.Window)
export type WindowProps = ConstructProps<typeof Astal.Window, Astal.Window.ConstructorProperties>
// Box
export const Box = astalify<typeof Astal.Box, BoxProps, "Box">(Astal.Box)
export type BoxProps = ConstructProps<typeof Astal.Box, Astal.Box.ConstructorProperties>
// CenterBox
export const CenterBox = astalify<typeof Astal.CenterBox, CenterBoxProps, "CenterBox">(Astal.CenterBox)
export type CenterBoxProps = ConstructProps<typeof Astal.CenterBox, Astal.CenterBox.ConstructorProperties>
// EventBox
export const EventBox = astalify<typeof Astal.EventBox, EventBoxProps, "EventBox">(Astal.EventBox)
export type EventBoxProps = ConstructProps<typeof Astal.EventBox, Astal.EventBox.ConstructorProperties>

51
js/node/application.ts Normal file
View File

@@ -0,0 +1,51 @@
import gi from "node-gtk"
import { RequestHandler, Config, runJS } from "../src/application.js"
const Astal = gi.require("Astal", "0.1")
class AstalJS extends Astal.Application {
static GTypeName = "AstalJS"
static { gi.registerClass(this) }
eval = runJS
requestHandler?: RequestHandler
vfunc_response(msg: string, conn: any): void {
if (typeof this.requestHandler === "function") {
this.requestHandler(msg, response => {
Astal.writeSock(conn, response, (_, res) =>
Astal.writeSockFinish(res),
)
})
} else {
// @ts-expect-error missing type
super.vfunc_response(msg, conn)
}
}
start(
{ requestHandler, css, ...cfg }: Omit<Config, "hold"> = {},
callback?: (args: string[]) => any,
) {
Object.assign(this, cfg)
this.requestHandler = requestHandler
this.on("activate", () => {
callback?.(process.argv)
})
if (!this.acquireSocket()) {
console.error(`Astal instance "${this.instanceName}" already running`)
process.exit()
}
if (css)
this.applyCss(css, false)
// FIXME: promises never resolve
// https://github.com/romgrk/node-gtk/issues/121
// https://gitlab.gnome.org/GNOME/gjs/-/issues/468
App.run([])
}
}
export const App = new AstalJS

38
js/node/astal.ts Normal file
View File

@@ -0,0 +1,38 @@
import gi from "node-gtk"
import Time from "../src/time.js"
import Process from "../src/process.js"
import * as variable from "../src/variable.js"
const Astal = gi.require("Astal", "0.1")
const { interval, timeout, idle } = Time(Astal.Time)
const { subprocess, exec, execAsync } = Process({
defaultOut: console.log,
defaultErr: console.error,
exec: Astal.Process.exec,
execv: Astal.Process.execv,
execAsync: Astal.Process.execAsync,
execAsyncv: Astal.Process.execAsyncv,
subprocess: Astal.Process.subprocess,
subprocessv: Astal.Process.subprocessv,
})
variable.config.defaultErrHandler = console.log
variable.config.execAsync = execAsync
variable.config.subprocess = subprocess
variable.config.interval = interval
variable.config.VariableBase = Astal.VariableBase
Object.freeze(variable.config)
export { subprocess, exec, execAsync }
export { interval, timeout, idle }
export { bind } from "../src/binding.js"
export { Variable } from "../src/variable.js"
export * as Widget from "./widgets.js"
export { App } from "./application.js"
// for convinience
export const GLib = gi.require("GLib", "2.0")
export const Gtk = gi.require("Gtk", "3.0")
export const Gio = gi.require("Gio", "2.0")
export const GObject = gi.require("GObject", "2.0")
export { Astal, gi }

63
js/node/widgets.ts Normal file
View File

@@ -0,0 +1,63 @@
/* eslint-disable max-len */
import gi from "node-gtk"
import proxy, { type ConstructProps, type Widget } from "../src/astalify.js"
import type GtkT from "@girs/node-gtk-3.0/node-gtk-3.0"
import type AstalT from "@girs/node-astal-0.1/node-astal-0.1"
const Astal = gi.require("Astal", "0.1")
const Gtk = gi.require("Gtk", "3.0")
const proxify = proxy(Gtk,
prop => `set${prop.charAt(0).toUpperCase() + prop.slice(1)}`,
{
cssGetter: Astal.widgetGetCss,
cssSetter: Astal.widgetSetCss,
classGetter: Astal.widgetGetClassNames,
classSetter: Astal.widgetSetClassNames,
cursorGetter: Astal.widgetGetCursor,
cursorSetter: Astal.widgetSetCursor,
})
export function astalify<
C extends typeof Gtk.Widget,
P extends Record<string, any>,
N extends string = "Widget",
>(klass: C) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Astal<N> = Omit<C, "new"> & {
new(props: P, ...children: GtkT.Widget[]): Widget<C>
(props: P, ...children: GtkT.Widget[]): Widget<C>
}
return proxify(klass) as unknown as Astal<N>
}
// Label
export const Label = astalify<typeof Gtk.Label, LabelProps, "Label">(Gtk.Label)
export type LabelProps = ConstructProps<typeof Gtk.Label, GtkT.Label.ConstructorProperties>
// Icon
export const Icon = astalify<typeof Astal.Icon, IconProps, "Icon">(Astal.Icon)
export type IconProps = ConstructProps<typeof Astal.Icon, AstalT.Icon.ConstructorProperties>
// Button
export const Button = astalify<typeof Astal.Button, ButtonProps, "Button">(Astal.Button)
export type ButtonProps = ConstructProps<typeof Astal.Button, AstalT.Button.ConstructorProperties, {
onClicked: (self: Widget<typeof Astal.Button>) => void
}>
// Window
export const Window = astalify<typeof Astal.Window, WindowProps, "Window">(Astal.Window)
export type WindowProps = ConstructProps<typeof Astal.Window, AstalT.Window.ConstructorProperties>
// Box
export const Box = astalify<typeof Astal.Box, BoxProps, "Box">(Astal.Box)
export type BoxProps = ConstructProps<typeof Astal.Box, AstalT.Box.ConstructorProperties>
// CenterBox
export const CenterBox = astalify<typeof Astal.CenterBox, CenterBoxProps, "CenterBox">(Astal.CenterBox)
export type CenterBoxProps = ConstructProps<typeof Astal.CenterBox, AstalT.CenterBox.ConstructorProperties>
// EventBox
export const EventBox = astalify<typeof Astal.EventBox, EventBoxProps, "EventBox">(Astal.EventBox)
export type EventBoxProps = ConstructProps<typeof Astal.EventBox, AstalT.EventBox.ConstructorProperties>

4038
js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
js/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"type": "module",
"dependencies": {
"node-gtk": "^0.14.0",
"typescript": "^5.1.0"
},
"devDependencies": {
"@ts-for-gir/cli": "^3.3.0",
"@types/node": "^20.12.12",
"@typescript-eslint/eslint-plugin": "^5.33.0",
"@typescript-eslint/parser": "^5.33.0",
"esbuild": "^0.20.2",
"eslint": "^8.42.0"
},
"scripts": {
"lint": "eslint .",
"build": "tsc",
"types": "ts-for-gir generate"
}
}

43
js/sample.gjs.js Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env -S gjs -m
import { Variable, App, Widget, Astal, bind } from "../js/dist/gjs/astal.js"
import Playerctl from "gi://Playerctl"
// state
const player = Playerctl.Player.new("spotify")
const date = Variable("").poll(1000, "date")
const title = Variable(player.get_title()).observe(player, "metadata", () => player.get_title())
// ui
function Bar(monitor) {
return Widget.Window(
{
monitor,
application: App,
exclusivity: Astal.Exclusivity.EXCLUSIVE,
anchor: Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
},
Widget.CenterBox({
startWidget: Widget.Label({
label: date(l => `Current date: ${l}`),
}),
endWidget: Widget.Label({
label: bind(title).as(t => `Title: ${t}`)
}),
})
)
}
// main
App.start({
requestHandler(msg, res) {
switch (msg) {
case "inspector": return res(App.inspector())
case "quit": return res(App.quit())
default: return App.eval(msg).then(res).catch(console.error)
}
}
}, () => {
Bar(0)
})

49
js/sample.node.js Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { Variable, App, Widget, Astal, bind, gi } from "../js/dist/node/astal.js"
const Playerctl = gi.require("Playerctl", "2.0")
// state
const player = Playerctl.Player.new("spotify")
const title = Variable(player.getTitle()).observe(player, "metadata", () => player.getTitle())
const date = Variable("")
// FIXME: doesn't work because promises don't resolve
// .poll(1000, "date")
// FIXME: don't know why but this doesn't work either
// .watch("bash -c 'while true; do date; sleep 1; done'")
// this does
.poll(1000, Date)
// ui
function Bar(monitor) {
return Widget.Window(
{
monitor,
application: App,
exclusivity: Astal.Exclusivity.EXCLUSIVE,
anchor: Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
},
Widget.CenterBox({
startWidget: Widget.Label({
label: date(l => `Current date: ${l}`),
}),
endWidget: Widget.Label({
label: bind(title).as(t => `Title: ${t}`)
}),
})
)
}
// main
App.start({
requestHandler(msg, res) {
switch (msg) {
case "inspector": return res(App.inspector())
case "quit": return res(App.quit())
default: return App.eval(msg).then(res).catch(console.error)
}
}
}, () => {
Bar(0)
})

28
js/src/application.ts Normal file
View File

@@ -0,0 +1,28 @@
export type RequestHandler = {
(request: string, res: (response: string) => void): void
}
export type Config = Partial<{
instanceName: string
gtkTheme: string
iconTheme: string
cursorTheme: string
css: string
requestHandler: RequestHandler
hold: boolean
}>
export function runJS(body: string): Promise<any> {
return new Promise((res, rej) => {
try {
const fn = Function(`return (async function() {
${body.includes(";") ? body : `return ${body};`}
})`)
fn()()
.then(res)
.catch(rej)
} catch (error) {
rej(error)
}
})
}

213
js/src/astalify.ts Normal file
View File

@@ -0,0 +1,213 @@
import Binding, { kebabify, type Connectable, type Subscribable } from "./binding.js"
export type Widget<C extends { new(...args: any): any }> = InstanceType<C> & {
className: string
css: string
cursor: Cursor
hook(
object: Connectable,
signal: string,
callback: (self: Widget<C>, ...args: any[]) => void,
): Widget<C>
hook(
object: Subscribable,
callback: (self: Widget<C>, ...args: any[]) => void,
): Widget<C>
}
export default function <G extends { Bin: any, Container: any, Widget: any }>(
Gtk: G,
setter: (prop: string) => `set${string}`,
Astal: {
cssSetter: (w: any, css: string) => void,
cssGetter: (w: any) => string | null,
classSetter: (w: any, name: string[]) => void,
classGetter: (w: any) => string[],
cursorSetter: (w: any, cursor: string) => void,
cursorGetter: (w: any) => string | null,
},
) {
function hook(
self: any,
object: Connectable | Subscribable,
signalOrCallback: string | ((self: any, ...args: any[]) => void),
callback?: (self: any, ...args: any[]) => void,
) {
if (typeof object.connect === "function" && callback) {
const id = object.connect(signalOrCallback, (_: any, ...args: unknown[]) => {
callback(self, ...args)
})
self.connect("destroy", () => {
(object.disconnect as Connectable["disconnect"])(id)
})
}
else if (typeof object.subscribe === "function" && typeof signalOrCallback === "function") {
const unsub = object.subscribe((...args: unknown[]) => {
signalOrCallback(self, ...args)
})
self.connect("destroy", unsub)
}
return self
}
function setChild(parent: any, child: any) {
if (parent instanceof Gtk.Bin) {
if (parent.get_child())
parent.remove(parent.get_child()!)
}
if (parent instanceof Gtk.Container)
parent.add(child)
}
function ctor(self: any, config: any, ...children: any[]) {
const { setup, child, ...props } = config
props.visible ??= true
const bindings = Object.keys(props).reduce((acc: any, prop) => {
if (props[prop] instanceof Binding) {
const bind = [prop, props[prop]]
prop === "child"
? setChild(self, props[prop].get())
: self[setter(prop)](props[prop].get())
delete props[prop]
return [...acc, bind]
}
return acc
}, [])
const onHandlers = Object.keys(props).reduce((acc: any, key) => {
if (key.startsWith("on")) {
const sig = kebabify(key).split("-").slice(1).join("-")
const handler = [sig, props[key]]
delete props[key]
return [...acc, handler]
}
return acc
}, [])
Object.assign(self, props)
Object.assign(self, {
hook(obj: any, sig: any, callback: any) {
return hook(self, obj, sig, callback)
},
})
if (child instanceof Binding) {
setChild(self, child.get())
self.connect("destroy", child.subscribe(v => {
setChild(self, v)
}))
} else if (self instanceof Gtk.Container && child instanceof Gtk.Widget) {
self.add(child)
}
for (const [signal, callback] of onHandlers)
self.connect(signal, callback)
if (self instanceof Gtk.Container && children) {
for (const child of children)
self.add(child)
}
for (const [prop, bind] of bindings) {
self.connect("destroy", bind.subscribe((v: any) => {
self[`${setter(prop)}`](v)
}))
}
setup?.(self)
return self
}
return function proxify<
C extends { new(...args: any[]): any },
>(klass: C) {
Object.defineProperty(klass.prototype, "className", {
get() { return Astal.classGetter(this).join(" ") },
set(v) { Astal.classSetter(this, v.split(/\s+/)) },
})
Object.defineProperty(klass.prototype, "css", {
get() { return Astal.cssGetter(this) },
set(v) { Astal.cssSetter(this, v) },
})
Object.defineProperty(klass.prototype, "cursor", {
get() { return Astal.cursorGetter(this) },
set(v) { Astal.cursorSetter(this, v) },
})
const proxy = new Proxy(klass, {
construct(_, [conf, ...children]) {
const self = new klass
return ctor(self, conf, ...children)
},
apply(_t, _a, [conf, ...children]) {
const self = new klass
return ctor(self, conf, ...children)
},
})
return proxy
}
}
type BindableProps<T> = {
[K in keyof T]: Binding<NonNullable<T[K]>> | T[K];
}
export type ConstructProps<
Self extends { new(...args: any[]): any },
Props = unknown,
Signals = unknown
> = {
[Key in `on${string}`]: (self: Widget<Self>) => unknown
} & Partial<Signals> & BindableProps<Props & {
className?: string
css?: string
cursor?: string
}> & {
onDestroy?: (self: Widget<Self>) => unknown
onDraw?: (self: Widget<Self>) => unknown
setup?: (self: Widget<Self>) => void
}
type Cursor =
| "default"
| "help"
| "pointer"
| "context-menu"
| "progress"
| "wait"
| "cell"
| "crosshair"
| "text"
| "vertical-text"
| "alias"
| "copy"
| "no-drop"
| "move"
| "not-allowed"
| "grab"
| "grabbing"
| "all-scroll"
| "col-resize"
| "row-resize"
| "n-resize"
| "e-resize"
| "s-resize"
| "w-resize"
| "ne-resize"
| "nw-resize"
| "sw-resize"
| "se-resize"
| "ew-resize"
| "ns-resize"
| "nesw-resize"
| "nwse-resize"
| "zoom-in"
| "zoom-out"

78
js/src/binding.ts Normal file
View File

@@ -0,0 +1,78 @@
export const kebabify = (str: string) => str
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replaceAll("_", "-")
.toLowerCase()
export interface Subscribable<T = unknown> {
subscribe(callback: () => void): () => void
get(): T
[key: string]: any
}
export interface Connectable {
connect(signal: string, callback: (...args: any[]) => unknown): number
disconnect(id: number): void
[key: string]: any
}
export default class Binding<Value> {
private emitter: Subscribable<Value> | Connectable
private prop?: string
private transformFn = (v: any) => v
static bind<
T extends Connectable,
P extends keyof T,
>(object: T, property: P): Binding<T[P]>
static bind<T>(object: Subscribable<T>): Binding<T>
static bind(emitter: Connectable | Subscribable, prop?: string) {
return new Binding(emitter, prop)
}
private constructor(emitter: Connectable | Subscribable<Value>, prop?: string) {
this.emitter = emitter
this.prop = prop && kebabify(prop)
}
toString() {
return `Binding<${this.emitter}${this.prop ? `, "${this.prop}"` : ""}>`
}
as<T>(fn: (v: Value) => T): Binding<T> {
const bind = new Binding(this.emitter, this.prop)
bind.transformFn = (v: Value) => fn(this.transformFn(v))
return bind as unknown as Binding<T>
}
get(): Value {
if (typeof this.emitter.get === "function")
return this.transformFn(this.emitter.get())
if (typeof this.prop === "string")
return this.transformFn(this.emitter[this.prop])
throw Error("can not get value of binding")
}
subscribe(callback: (value: Value) => void): () => void {
if (typeof this.emitter.subscribe === "function") {
return this.emitter.subscribe(() => {
callback(this.get())
})
}
else if (typeof this.emitter.connect === "function") {
const signal = `notify::${this.prop}`
const id = this.emitter.connect(signal, () => {
callback(this.get())
})
return () => {
(this.emitter.disconnect as Connectable["disconnect"])(id)
}
}
throw Error(`${this.emitter} is not bindable`)
}
}
export const { bind } = Binding

83
js/src/process.ts Normal file
View File

@@ -0,0 +1,83 @@
type Proc = {
connect(sig: "stdout" | "stderr", fn: (_: any, out: string) => void): number
}
type Config<P extends Proc> = {
defaultOut(stdout: string): void
defaultErr(stdout: string): void
subprocess(cmd: string): P
subprocessv(cmd: string[]): P
exec(cmd: string): string | null
execv(cmd: string[]): string | null
execAsync(cmd: string): P
execAsyncv(cmd: string[]): P
}
type Args<Out = void, Err = void> = {
cmd: string | string[],
out?: (stdout: string) => Out,
err?: (stderr: string) => Err,
}
export default function <P extends Proc>(config: Config<P>) {
function args<O, E>(argsOrCmd: Args | string | string[], onOut: O, onErr: E) {
const params = Array.isArray(argsOrCmd) || typeof argsOrCmd === "string"
return {
cmd: params ? argsOrCmd : argsOrCmd.cmd,
err: params ? onErr : argsOrCmd.err || onErr,
out: params ? onOut : argsOrCmd.out || onOut,
}
}
function subprocess(args: Args): P
function subprocess(
cmd: string | string[],
onOut?: (stdout: string) => void,
onErr?: (stderr: string) => void,
): P
function subprocess(
argsOrCmd: Args | string | string[],
onOut: (stdout: string) => void = config.defaultOut,
onErr: (stderr: string) => void = config.defaultErr,
) {
const { cmd, err, out } = args(argsOrCmd, onOut, onErr)
const proc = Array.isArray(cmd)
? config.subprocessv(cmd)
: config.subprocess(cmd)
proc.connect("stdout", (_, stdout: string) => out(stdout))
proc.connect("stderr", (_, stderr: string) => err(stderr))
return proc
}
function exec<Out = string, Err = string>(
args: Args<Out, Err>
): Out | Err
function exec<Out = string, Err = string>(
cmd: string | string[],
onOut?: (stdout: string) => Out,
onErr?: (stderr: string) => Err,
): Out | Err
function exec<Out = string, Err = string>(
argsOrCmd: Args<Out, Err> | string | string[],
onOut: (stdout: string) => Out = out => out as Out,
onErr: (stderr: string) => Err = out => out as Err,
): Out | Err {
const { cmd, err, out } = args(argsOrCmd, onOut, onErr)
return Array.isArray(cmd)
? out(config.execv(cmd)!) as Out
: err(config.exec(cmd)!) as Err
}
function execAsync(cmd: string | string[]): Promise<string> {
const proc = Array.isArray(cmd)
? config.execAsyncv(cmd)
: config.execAsync(cmd)
return new Promise((resolve, reject) => {
proc.connect("stdout", (_, out: string) => resolve(out))
proc.connect("stderr", (_, err: string) => reject(err))
})
}
return { subprocess, exec, execAsync }
}

27
js/src/time.ts Normal file
View File

@@ -0,0 +1,27 @@
interface Time {
connect(sig: "now", fn: () => void): number
cancel(): void
}
export default function Time<T extends Time>(Time: {
interval(interval: number, closure: any): T
timeout(timeout: number, closure: any): T
idle(closure: any): T
}) {
function interval(interval: number, callback: () => void) {
const t = Time.interval(interval, null)
t.connect("now", callback)
return t
}
function timeout(timeout: number, callback: () => void) {
const t = Time.timeout(timeout, null)
t.connect("now", callback)
return t
}
function idle(callback: () => void) {
const t = Time.idle(null)
t.connect("now", callback)
return t
}
return { interval, timeout, idle }
}

250
js/src/variable.ts Normal file
View File

@@ -0,0 +1,250 @@
import Binding, { type Connectable } from "./binding.js"
type VariableBase = {
emit(sig: "error" | "changed" | "dropped", ...args: any[]): void
connect(sig: "error" | "changed" | "dropped", fn: (...args: any[]) => void): number
disconnect(id: number): void
runDispose?(): void // node, deno
run_dispose?(): void // gjs
}
type VariableBaseCtor = {
new(): VariableBase
}
type Time = any
type Process = any
type Config = {
defaultErrHandler(err: any): void
VariableBase: VariableBaseCtor
interval(n: number, fn: () => void): Time
execAsync(cmd: string | string[]): Promise<string>
subprocess(args: {
cmd: string | string[],
out: (s: string) => void,
err: (s: string) => void
}): Process
}
// @ts-expect-error missing values
export const config: Config = {}
class VariableWrapper<T> extends Function {
private variable!: VariableBase
private errHandler? = config.defaultErrHandler
private _value: T
private _poll?: Time
private _watch?: Process
private pollInterval = 1000
private pollExec?: string[] | string
private pollTransform?: (stdout: string, prev: T) => T
private pollFn?: (prev: T) => T | Promise<T>
private watchTransform?: (stdout: string, prev: T) => T
private watchExec?: string[] | string
constructor(init: T) {
super()
this._value = init
this.variable = new config.VariableBase
this.variable.connect("dropped", () => {
this.stopWatch()
this.stopPoll()
})
this.variable.connect("error", (_, err) => this.errHandler?.(err))
return new Proxy(this, {
apply: (target, _, args) => target._call(args[0]),
})
}
private _call<R = T>(transform?: (value: T) => R): Binding<R> {
const b = Binding.bind(this)
return transform ? b.as(transform) : b as unknown as Binding<R>
}
toString() {
return String(`Variable<${this.get()}>`)
}
get(): T { return this._value }
set(value: T) {
if (value !== this._value) {
this._value = value
this.variable.emit("changed")
}
}
startPoll() {
if (this._poll)
return
if (this.pollFn) {
this._poll = config.interval(this.pollInterval, () => {
const v = this.pollFn!(this.get())
if (v instanceof Promise) {
v.then(v => this.set(v))
.catch(err => this.variable.emit("error", err))
} else {
this.set(v)
}
})
} else if (this.pollExec) {
this._poll = config.interval(this.pollInterval, () => {
config.execAsync(this.pollExec!)
.then(v => this.set(this.pollTransform!(v, this.get())))
.catch(err => this.variable.emit("error", err))
})
}
}
startWatch() {
if (this._watch)
return
this._watch = config.subprocess({
cmd: this.watchExec!,
out: out => this.set(this.watchTransform!(out, this.get())),
err: err => this.variable.emit("error", err),
})
}
stopPoll() {
this._poll?.cancel()
delete this._poll
}
stopWatch() {
this._watch?.kill()
delete this._watch
}
isPolling() { return !!this._poll }
isWatching() { return !!this._watch }
drop() {
this.variable.emit("dropped")
this.variable.runDispose?.()
this.variable.run_dispose?.()
}
onDropped(callback: () => void) {
this.variable.connect("dropped", callback)
return this as unknown as Variable<T>
}
onError(callback: (err: string) => void) {
delete this.errHandler
this.variable.connect("error", (_, err) => callback(err))
return this as unknown as Variable<T>
}
subscribe(callback: (value: T) => void) {
const id = this.variable.connect("changed", () => {
callback(this.get())
})
return () => this.variable.disconnect(id)
}
poll(
interval: number,
exec: string | string[],
transform?: (stdout: string, prev: T) => T
): Variable<T>
poll(
interval: number,
callback: (prev: T) => T | Promise<T>
): Variable<T>
poll(
interval: number,
exec: string | string[] | ((prev: T) => T | Promise<T>),
transform: (stdout: string, prev: T) => T = out => out as T,
) {
this.stopPoll()
this.pollInterval = interval
this.pollTransform = transform
if (typeof exec === "function") {
this.pollFn = exec
delete this.pollExec
} else {
this.pollExec = exec
delete this.pollFn
}
this.startPoll()
return this as unknown as Variable<T>
}
watch(
exec: string | string[],
transform: (stdout: string, prev: T) => T = out => out as T,
) {
this.stopWatch()
this.watchExec = exec
this.watchTransform = transform
this.startWatch()
return this as unknown as Variable<T>
}
observe(
objs: Array<[obj: Connectable, signal: string]>,
callback: (...args: any[]) => T): Variable<T>
observe(
obj: Connectable,
signal: string,
callback: (...args: any[]) => T): Variable<T>
observe(
objs: Connectable | Array<[obj: Connectable, signal: string]>,
sigOrFn: string | ((...args: any[]) => T),
callback?: (...args: any[]) => T,
) {
const f = typeof sigOrFn === "function" ? sigOrFn : callback ?? (() => this.get())
const set = (_: Connectable, ...args: any[]) => this.set(f(...args))
if (Array.isArray(objs)) {
for (const obj of objs) {
const [o, s] = obj
o.connect(s, set)
}
} else {
if (typeof sigOrFn === "string")
objs.connect(sigOrFn, set)
}
return this as unknown as Variable<T>
}
static derive<V,
const Deps extends Array<Variable<any> | Binding<any>>,
Args extends {
[K in keyof Deps]: Deps[K] extends Variable<infer T>
? T : Deps[K] extends Binding<infer T> ? T : never
},
>(deps: Deps, fn: (...args: Args) => V) {
const update = () => fn(...deps.map(d => d.get()) as Args)
const derived = new Variable(update())
const unsubs = deps.map(dep => dep.subscribe(() => derived.set(update())))
derived.onDropped(() => unsubs.map(unsub => unsub()))
return derived
}
}
export interface Variable<T> extends Omit<VariableWrapper<T>, "bind"> {
<R>(transform: (value: T) => R): Binding<R>
(): Binding<T>
}
export const Variable = new Proxy(VariableWrapper as any, {
apply: (_t, _a, args) => new VariableWrapper(args[0]),
}) as {
derive: typeof VariableWrapper["derive"]
<T>(init: T): Variable<T>
new <T>(init: T): Variable<T>
}
export default Variable

23
js/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022"
],
"outDir": "dist",
"strict": true,
"moduleResolution": "node",
"baseUrl": ".",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"allowSyntheticDefaultImports": true
},
"include": [
"./@girs",
"./src",
"./gjs",
"./node"
]
}

76
lua/astal/application.lua Normal file
View File

@@ -0,0 +1,76 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "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(request)
Astal.write_sock(conn, request, function(_, res)
Astal.write_sock_finish(res)
end)
end)
else
Astal.Application.do_request(self, msg, conn)
end
end
local app = AstalLua()
---@class StartConfig
---@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: string))
---@param config StartConfig | nil
---@param callback function | nil
function Astal.Application:start(config, callback)
if config == nil then
config = {}
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.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 config.hold then
self:hold()
end
if type(callback) == "function" then
callback()
end
end
if not app:acquire_socket() then
print('Astal instance "' .. app.instance_name .. '" is already running')
os.exit(1)
end
self:run(nil)
end
return app

65
lua/astal/binding.lua Normal file
View File

@@ -0,0 +1,65 @@
local lgi = require("lgi")
local GObject = lgi.require("GObject", "2.0")
---@class Binding
---@field emitter object
---@field property? string
---@field transformFn function
local Binding = {}
---@param emitter object
---@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:get())
if self.property ~= nil then
str = str .. ", " .. self.property
end
return str .. ">"
end
function Binding:get()
if type(self.emitter.get) == "function" then
return self.transformFn(self.emitter:get())
end
return self.transformFn(self.emitter[self.property])
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 type(self.emitter.subscribe) == "function" then
return self.emitter:subscribe(function()
callback(self:get())
end)
end
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
Binding.__index = Binding
return Binding

30
lua/astal/init.lua Normal file
View File

@@ -0,0 +1,30 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "0.1")
local Gtk = lgi.require("Gtk", "3.0")
local GObject = lgi.require("GObject", "2.0")
local Widget = require("astal.widget")
local Variable = require("astal.variable")
local Binding = require("astal.binding")
local App = require("astal.application")
local Process = require("astal.process")
local Time = require("astal.time")
return {
App = App,
Variable = Variable,
Widget = Widget,
bind = Binding.new,
interval = Time.interval,
timeout = Time.timeout,
idle = Time.idle,
subprocess = Process.subprocess,
exec = Process.exec,
exec_async = Process.exec_async,
Astal = Astal,
Gtk = Gtk,
GObject = GObject,
GLib = lgi.require("GLib", "2.0"),
Gio = lgi.require("Gio", "2.0"),
require = lgi.require,
}

93
lua/astal/process.lua Normal file
View File

@@ -0,0 +1,93 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "0.1")
local M = {}
local defualt_proc_args = function(on_stdout, on_stderr)
if on_stdout == nil then
on_stdout = function(out)
io.stdout:write(tostring(out) .. "\n")
return tostring(out)
end
end
if on_stderr == nil then
on_stderr = function(err)
io.stderr:write(tostring(err) .. "\n")
return tostring(err)
end
end
return on_stdout, on_stderr
end
---@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)
local out, err = defualt_proc_args(on_stdout, on_stderr)
local proc, fail
if type(commandline) == "table" then
proc, fail = Astal.Process.subprocessv(commandline)
else
proc, fail = Astal.Process.subprocess(commandline)
end
if fail ~= nil then
err(fail)
return nil
end
proc.on_stdout = function(_, str)
out(str)
end
proc.on_stderr = function(_, str)
err(str)
end
return proc
end
---@generic T
---@param commandline string | string[]
---@param on_stdout? fun(out: string): T
---@param on_stderr? fun(err: string): T
---@return T
function M.exec(commandline, on_stdout, on_stderr)
local out, err = defualt_proc_args(on_stdout, on_stderr)
local stdout, stderr
if type(commandline) == "table" then
stdout, stderr = Astal.Process.execv(commandline)
else
stdout, stderr = Astal.Process.exec(commandline)
end
if stderr then
return err(stderr)
end
return out(stdout)
end
---@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.exec_async(commandline, on_stdout, on_stderr)
local out, err = defualt_proc_args(on_stdout, on_stderr)
local proc, fail
if type(commandline) == "table" then
proc, fail = Astal.Process.exec_asyncv(commandline)
else
proc, fail = Astal.Process.exec_async(commandline)
end
if fail ~= nil then
err(fail)
return nil
end
proc.on_stdout = function(_, str)
out(str)
end
proc.on_stderr = function(_, str)
err(str)
end
return proc
end
return M

27
lua/astal/time.lua Normal file
View File

@@ -0,0 +1,27 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "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

270
lua/astal/variable.lua Normal file
View File

@@ -0,0 +1,270 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "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 object
---@field private err_handler? function
---@field private _value any
---@field private _poll? object
---@field private _watch? object
---@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)
self:set(self.poll_transform(out, self:get()))
end, function(err)
self.variable.emit_error(err)
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()
self.variable.run_dispose()
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 object | 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 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 _, binding in ipairs(deps) do
table.insert(params, binding:get())
end
return transform(table.unpack(params))
end
local var = Variable.new(update())
local unsubs = {}
for _, b in ipairs(deps) do
table.insert(unsubs, b:subscribe(update))
end
var.variable.on_dropped = function()
for _, unsub in ipairs(unsubs) do
var:set(unsub())
end
end
return var
end
return setmetatable(Variable, {
__call = function(_, v)
return Variable.new(v)
end,
})

123
lua/astal/widget.lua Normal file
View File

@@ -0,0 +1,123 @@
local lgi = require("lgi")
local Astal = lgi.require("Astal", "0.1")
local Gtk = lgi.require("Gtk", "3.0")
local GObject = lgi.require("GObject", "2.0")
local Binding = require("astal.binding")
local function filter(tbl, fn)
local copy = {}
for key, value in pairs(tbl) do
if fn(value, key) then
copy[key] = value
end
end
return copy
end
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_set_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,
}
Astal.Box._attribute.children = {
get = Astal.Box.get_children,
set = Astal.Box.set_children,
}
local function astalify(ctor)
function ctor:hook(object, signalOrCallback, callback)
if type(object.subscribe) == "function" then
local unsub = object.subscribe(function(...)
signalOrCallback(self, ...)
end)
self.on_destroy = unsub
return
end
local id = object["on_" .. signalOrCallback](function(_, ...)
callback(self, ...)
end)
self.on_destroy = function()
GObject.signal_handler_disconnect(object, id)
end
end
return function(tbl)
local bindings = {}
local setup = tbl.setup
local visible
if type(tbl.visible) == "boolean" then
visible = tbl.visible
else
visible = true
end
local props = filter(tbl, function(_, key)
return key ~= "visible" and key ~= "setup"
end)
for prop, value in pairs(props) do
if getmetatable(value) == Binding then
bindings[prop] = value
props[prop] = value:get()
end
end
local widget = ctor(props)
for prop, binding in pairs(bindings) do
widget.on_destroy = binding:subscribe(function(v)
widget[prop] = v
end)
end
widget.visible = visible
if type(setup) == "function" then
setup(widget)
end
return widget
end
end
local Widget = {
astalify = astalify,
Box = astalify(Astal.Box),
Button = astalify(Astal.Button),
CenterBox = astalify(Astal.CenterBox),
Label = astalify(Gtk.Label),
Icon = astalify(Astal.Icon),
Window = astalify(Astal.Window),
EventBox = astalify(Astal.EventBox),
}
return setmetatable(Widget, {
__call = function(_, ctor)
return astalify(ctor)
end,
})

81
lua/sample.lua Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env lua
-- imports
local astal = require("astal.init")
local Widget, Variable, App, bind = astal.Widget, astal.Variable, astal.App, astal.bind
-- state
local player = astal.require("Playerctl").Player.new("spotify")
local title = Variable(player:get_title()):observe(player, "metadata", function()
return player:get_title()
end)
local rnd = Variable(1):poll(1000, function()
return math.random(1, 10)
end)
-- ui
local Bar = function(monitor)
return Widget.Window({
application = App,
id = "bar",
name = "bar",
monitor = monitor,
anchor = astal.Astal.WindowAnchor.BOTTOM
+ astal.Astal.WindowAnchor.LEFT
+ astal.Astal.WindowAnchor.RIGHT,
exclusivity = "EXCLUSIVE",
Widget.CenterBox({
class_name = "bar",
start_widget = Widget.Label({
valign = "CENTER",
label = "Welcome to Astal.lua",
}),
center_widget = Widget.Box({
children = bind(rnd):as(function(n)
local children = {}
for i = 1, n, 1 do
table.insert(
children,
Widget.Button({
label = tostring(i),
on_clicked = function()
print(i)
end,
})
)
end
return children
end),
}),
end_widget = Widget.Label({
valign = "CENTER",
label = bind(title),
}),
}),
})
end
-- css
local css = [[
.bar button {
color: blue;
}
]]
-- main
App:start({
request_handler = function(msg, res)
if msg == "quit" then
os.exit(0)
end
if msg == "inspector" then
res(App:inspector())
end
res("hi")
end,
css = css,
}, function()
Bar(0)
end)

3
lua/stylua.toml Normal file
View File

@@ -0,0 +1,3 @@
indent_type = "Spaces"
indent_width = 4
column_width = 100

16
meson-install.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
meson setup \
--prefix /usr \
--libexecdir lib \
--sbindir bin \
--buildtype plain \
--auto-features enabled \
--wrap-mode nodownload \
-D b_lto=false \
-D b_pie=true \
-D python.bytecompile=1 \
--wipe \
build
meson install -C build

17
meson.build Normal file
View File

@@ -0,0 +1,17 @@
project(
'astal',
'vala',
'c',
version: run_command('cat', join_paths(meson.project_source_root(), 'version')).stdout().strip(),
meson_version: '>= 0.62.0',
default_options: [
'warning_level=2',
'werror=false',
'c_std=gnu11',
],
)
# math
add_project_arguments(['-X', '-lm'], language: 'vala')
subdir('src')

2
meson_options.txt Normal file
View File

@@ -0,0 +1,2 @@
option('typelib', type: 'boolean', value: true, description: 'Needed files for runtime bindings')
option('cli_client', type: 'boolean', value: true, description: 'Minimal cli client for Astal applications')

1
python/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

16
python/astal/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
import gi
gi.require_version("Astal", "0.1")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("Gio", "2.0")
gi.require_version("GObject", "2.0")
from gi.repository import Astal, Gtk, GLib, Gio, GObject
from .application import App
from .variable import Variable
from .binding import Binding
from . import widget as Widget
bind = Binding
__all__ = ["App", "Variable", "Widget" "bind", "Astal", "Gtk", "GLib", "Gio", "GObject"]

View File

@@ -0,0 +1,62 @@
from collections.abc import Callable
from gi.repository import Astal, Gio
RequestHandler = Callable[[str, Callable[[str], None]], None]
class _Application(Astal.Application):
def __init__(self) -> None:
super().__init__()
self.request_handler: RequestHandler | None = None
def do_response(self, msg: str, conn: Gio.SocketConnection) -> None:
if self.request_handler:
self.request_handler(
msg,
lambda response: Astal.write_sock(
conn,
response,
lambda _, res: Astal.write_sock_finish(res),
),
)
else:
super().do_response(msg, conn)
def start(
self,
instance_name: str | None = None,
gtk_theme: str | None = None,
icon_theme: str | None = None,
cursor_theme: str | None = None,
css: str | None = None,
hold: bool | None = True,
request_handler: RequestHandler | None = None,
callback: Callable | None = None,
) -> None:
if request_handler:
self.request_handler = request_handler
if hold:
self.hold()
if instance_name:
self.instance_name = instance_name
if gtk_theme:
self.gtk_theme = gtk_theme
if icon_theme:
self.icon_theme = icon_theme
if cursor_theme:
self.cursor_theme = icon_theme
if css:
self.apply_css(css, False)
if not self.acquire_socket():
print(f"Astal instance {self.instance_name} already running")
return
def on_activate(app):
if callback:
callback()
self.connect("activate", on_activate)
self.run()
App = _Application()

33
python/astal/binding.py Normal file
View File

@@ -0,0 +1,33 @@
import re
def kebabify(string):
return re.sub(r"([a-z])([A-Z])", r"\1-\2", string).replace("_", "-").lower()
class Binding:
def __init__(self, emitter, prop=None):
self.emitter = emitter
self.prop = kebabify(prop) if prop else None
self.transform_fn = lambda v: v
def __str__(self):
return f"Binding<{self.emitter}{', ' + self.prop if self.prop else ''}>"
def as_(self, fn):
bind = Binding(self.emitter, self.prop)
bind.transform_fn = lambda v: fn(self.transform_fn(v))
return bind
def get(self):
if hasattr(self.emitter, "get") and callable(self.emitter.get):
return self.transform_fn(self.emitter.get())
return self.transform_fn(self.emitter[f"get_{self.prop}"]())
def subscribe(self, callback):
if hasattr(self.emitter, "subscribe") and callable(self.emitter.subscribe):
return self.emitter.subscribe(lambda _: callback(self.get()))
i = self.emitter.connect(f"notify::{self.prop}", lambda: callback(self.get()))
return lambda: self.emitter.disconnect(i)

100
python/astal/variable.py Normal file
View File

@@ -0,0 +1,100 @@
from gi.repository import Astal
from .binding import Binding
class Variable:
def __init__(self, init):
v = Astal.Variable.new(init)
self._variable = v
self._err_handler = print
v.connect("error", lambda _, err: self._err_handler(err) if self._err_handler else None)
def __call__(self, transform=None):
if transform:
return Binding(self).as_(transform)
return Binding(self)
def __str__(self):
return f"Variable<{self.get()}>"
def get(self):
return self._variable.get_value()
def set(self, value):
return self._variable.set_value(value)
def watch(self, cmd):
if isinstance(cmd, str):
self._variable.watch(cmd)
elif isinstance(cmd, list):
self._variable.watchv(cmd)
return self
def poll(self, interval, cmd):
if isinstance(cmd, str):
self._variable.poll(interval, cmd)
elif isinstance(cmd, list):
self._variable.pollv(interval, cmd)
else:
self._variable.pollfn(interval, cmd)
return self
def start_watch(self):
self._variable.start_watch()
def start_poll(self):
self._variable.start_poll()
def stop_watch(self):
self._variable.stop_watch()
def stop_poll(self):
self._variable.stop_poll()
def drop(self):
self._variable.emit_dropped()
self._variable.run_dispose()
def on_dropped(self, callback):
self._variable.connect("dropped", lambda _: callback())
return self
def on_error(self, callback):
self._err_handler = None
self._variable.connect("error", lambda _, e: callback(e))
return self
def subscribe(self, callback):
s = self._variable.connect("changed", lambda _: callback(self.get()))
return lambda: self._variable.disconnect(s)
def observe(self, objs, sigOrFn, callback=None):
if callable(sigOrFn):
f = sigOrFn
elif callable(callback):
f = callback
else:
f = lambda *_: self.get()
def setter(_, *args):
self.set(f(*args))
if isinstance(objs, list):
for obj in objs:
obj[0].connect(obj[1], setter)
elif isinstance(sigOrFn, str):
objs.connect(sigOrFn, setter)
return self
@staticmethod
def derive(deps, fn):
def update():
return fn(*[d.get() for d in deps])
derived = Variable(update())
unsubs = [dep.subscribe(lambda _: derived.set(update())) for dep in deps]
derived.on_dropped(lambda: ([unsub() for unsub in unsubs]))
return derived

65
python/astal/widget.py Normal file
View File

@@ -0,0 +1,65 @@
from gi.repository import Astal, Gtk
from .binding import Binding
def set_child(self, child):
if isinstance(self, Gtk.Bin):
self.remove(self.get_child())
if isinstance(self, Gtk.Container):
self.add(child)
def astalify(ctor):
ctor.set_css = Astal.widget_set_css
ctor.get_css = Astal.widget_get_css
ctor.set_class_name = lambda self, names: Astal.widget_set_class_names(self, names.split())
ctor.get_class_name = lambda self: " ".join(Astal.widget_set_class_names(self))
ctor.set_cursor = Astal.widget_set_cursor
ctor.get_cursor = Astal.widget_get_cursor
def widget(**kwargs):
args = {}
bindings = {}
handlers = {}
setup = None
if not hasattr(kwargs, "visible"):
kwargs["visible"] = True
for key, value in kwargs.items():
if key == "setup":
setup = value
if isinstance(value, Binding):
bindings[key] = value
if key.startswith("on_"):
handlers[key] = value
else:
args[key] = value
self = ctor(**args)
for key, value in bindings.items():
setter = getattr(self, f"set_{key}")
setter(value.get())
unsub = value.subscribe(setter)
self.connect("destroy", lambda _: unsub())
for key, value in handlers.items():
self.connect(key.replace("on_", ""), value)
if setup:
setup(self)
return self
return widget
Window = astalify(Astal.Window)
Box = astalify(Astal.Box)
Button = astalify(Astal.Button)
CenterBox = astalify(Astal.CenterBox)
Label = astalify(Gtk.Label)
Icon = astalify(Astal.Icon)
EventBox = astalify(Astal.EventBox)

14
python/pyproject.toml Normal file
View File

@@ -0,0 +1,14 @@
[tool.poetry]
name = "astal.py"
version = "0.1.0"
description = ""
authors = []
[tool.poetry.dependencies]
python = "^3.11"
gengir = "^1.0.2"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

62
python/ruff.toml Normal file
View File

@@ -0,0 +1,62 @@
target-version = "py311"
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
line-length = 100
indent-width = 4
[lint]
select = ["ALL"]
ignore = ["D", "ANN101", "ERA", "ANN"]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[format]
quote-style = "double"
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
docstring-code-format = false
# Set the line length limit used when formatting code snippets in
# docstrings.
docstring-code-line-length = "dynamic"

31
python/sample.py Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import gi
gi.require_version("Playerctl", "2.0")
from gi.repository import Playerctl
from astal import App, Astal, Variable, Widget, bind
player = Playerctl.Player.new("spotify")
v = Variable(player.get_title()).observe(player, "metadata", lambda *_: player.get_title())
def Bar(monitor):
return Widget.Window(
anchor=Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT,
monitor=monitor,
exclusivity=Astal.Exclusivity.EXCLUSIVE,
child=Widget.CenterBox(
start_widget=Widget.Label(
label="Welcome to Astal.py!",
),
end_widget=Widget.Label(label=v()),
),
)
def start():
Bar(0)
App.start(callback=start)

171
src/astal.vala Normal file
View File

@@ -0,0 +1,171 @@
namespace Astal {
public class Application : Gtk.Application {
public signal void request (string request);
private List<Gtk.CssProvider> css_providers;
private SocketService service;
private string socket;
public string instance_name { get; construct set; }
public List<Gtk.Window> windows {
get { return get_windows(); }
}
public Gtk.Settings settings {
get { return Gtk.Settings.get_default(); }
}
public Gdk.Screen screen {
get { return Gdk.Screen.get_default(); }
}
public string gtk_theme {
owned get { return settings.gtk_theme_name; }
set { settings.gtk_theme_name = value; }
}
public string icon_theme {
owned get { return settings.gtk_icon_theme_name; }
set { settings.gtk_icon_theme_name = value; }
}
public string cursor_theme {
owned get { return settings.gtk_cursor_theme_name; }
set { settings.gtk_cursor_theme_name = value; }
}
public void reset_css() {
foreach(var provider in css_providers) {
Gtk.StyleContext.remove_provider_for_screen(screen, provider);
css_providers.remove_all(provider);
}
}
public void inspector() {
Gtk.Window.set_interactive_debugging(true);
}
public Gtk.Window get_window(string name) throws WindowError {
foreach(var win in windows) {
if (win.name == name)
return win;
}
throw new WindowError.NO_WINDOW_WITH_NAME(name);
}
public void apply_css(string style, bool reset = false) throws Error {
var provider = new Gtk.CssProvider();
if (reset)
reset_css();
if (FileUtils.test(style, FileTest.EXISTS))
provider.load_from_path(style);
else
provider.load_from_data(style);
Gtk.StyleContext.add_provider_for_screen(
screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER);
css_providers.append(provider);
}
private async void _socket_request(SocketConnection conn) {
string message = yield read_sock(conn);
request(message.strip());
response(message.strip(), conn);
}
public virtual void response(string msg, SocketConnection conn) {
write_sock.begin(conn, "missing response implementation on ".concat(application_id));
}
/**
* should be called before `run()`
* the return value indicates if instance is already running
*/
public bool acquire_socket() {
socket = GLib.Environment.get_user_runtime_dir().concat(
"/",
instance_name,
".sock");
if (FileUtils.test(socket, GLib.FileTest.EXISTS)) {
info("socket %s exists", socket);
return false;
}
try {
SocketAddress _;
service = new SocketService();
service.add_address(
new UnixSocketAddress(socket),
SocketType.STREAM,
SocketProtocol.DEFAULT,
null,
out _);
service.incoming.connect((conn) => {
_socket_request.begin(conn);
return false;
});
info("socket acquired: %s\n", socket);
return true;
} catch (Error err) {
critical("could not acquire socket %s\n", application_id);
critical(err.message);
return false;
}
}
construct {
if (instance_name == null)
instance_name = "astal";
if (application_id == null)
application_id = "io.Astal.".concat(instance_name);
shutdown.connect(() => {
if (FileUtils.test(socket, GLib.FileTest.EXISTS)){
try {
File.new_for_path(socket).delete(null);
} catch (Error err) {
warning(err.message);
}
}
});
SourceFunc close = () => { quit(); };
Unix.signal_add(1, close, Priority.HIGH);
Unix.signal_add(2, close, Priority.HIGH);
Unix.signal_add(15, close, Priority.HIGH);
}
}
public errordomain WindowError {
NO_WINDOW_WITH_NAME
}
public async string read_sock(SocketConnection conn) {
try {
var stream = new DataInputStream(conn.input_stream);
size_t size;
return yield stream.read_upto_async("\x04", -1, Priority.DEFAULT, null, out size);
} catch (Error err) {
critical(err.message);
return err.message;
}
}
public async void write_sock(SocketConnection conn, string response) {
try {
yield conn.output_stream.write_async(
response.concat("\x04").data,
Priority.DEFAULT);
} catch (Error err) {
critical(err.message);
}
}
}

79
src/client.vala.in Normal file
View File

@@ -0,0 +1,79 @@
private static bool version;
private static bool help;
private static string? instance_name;
private const GLib.OptionEntry[] options = {
{ "version", 'v', OptionFlags.NONE, OptionArg.NONE, ref version, null, null },
{ "help", 'h', OptionFlags.NONE, OptionArg.NONE, ref help, null, null },
{ "instance-name", 'i', OptionFlags.NONE, OptionArg.STRING, ref instance_name, null, null },
{ null },
};
async int main(string[] argv) {
try {
var opts = new OptionContext();
opts.add_main_entries(options, null);
opts.set_help_enabled(false);
opts.set_ignore_unknown_options(false);
opts.parse(ref argv);
} catch (OptionError err) {
printerr (err.message);
return 1;
}
if (help) {
print("Client for the socket of an Astal.Application instance\n\n");
print("Usage:\n");
print(" %s [flags] message\n\n", argv[0]);
print("Flags:\n");
print(" -h, --help Print this help and exit\n");
print(" -v, --version Print version number and exit\n");
print(" -i, --instance-name Instance name of the Astal instance\n");
return 0;
}
if (version) {
print("@VERSION@");
return 0;
}
if (instance_name == null)
instance_name = "astal";
var request = "";
for (var i = 1; i < argv.length; ++i) {
request = request.concat(" ", argv[i]);
}
var client = new SocketClient();
var rundir = GLib.Environment.get_user_runtime_dir();
var socket = rundir.concat("/", instance_name, ".sock");
try {
var conn = client.connect(new UnixSocketAddress(socket), null);
try {
yield conn.output_stream.write_async(
request.concat("\x04").data,
Priority.DEFAULT);
} catch (Error err) {
printerr("could not write to app '%s'", instance_name);
}
var stream = new DataInputStream(conn.input_stream);
size_t size;
try {
var res = yield stream.read_upto_async("\x04", -1, Priority.DEFAULT, null, out size);
if (res != null)
print("%s", res);
} catch (Error err) {
printerr(err.message);
}
} catch (Error err) {
printerr("could not connect to app '%s'", instance_name);
return 1;
}
return 0;
}

85
src/meson.build Normal file
View File

@@ -0,0 +1,85 @@
version_split = meson.project_version().split('.')
api_version = version_split[0] + '.' + version_split[1]
astal_gir = 'Astal-' + api_version + '.gir'
astal_typelib = 'Astal-' + api_version + '.typelib'
astal_so = 'libastal.so.' + meson.project_version()
deps = [
dependency('glib-2.0'),
dependency('gio-unix-2.0'),
dependency('gobject-2.0'),
dependency('gio-2.0'),
dependency('gtk+-3.0'),
dependency('gdk-pixbuf-2.0'),
dependency('gtk-layer-shell-0'),
]
sources = files(
'widget/box.vala',
'widget/button.vala',
'widget/centerbox.vala',
'widget/eventbox.vala',
'widget/icon.vala',
# 'widget/circularprogress.vala', # TODO: math lib -X -lm
'widget/widget.vala',
'widget/window.vala',
'astal.vala',
'process.vala',
'time.vala',
'variable.vala',
)
libastal = library(
meson.project_name(),
sources,
dependencies: deps,
vala_header: meson.project_name() + '.h',
vala_vapi: meson.project_name() + '.vapi',
vala_gir: astal_gir,
version: meson.project_version(),
install: true,
install_dir: [true, true, true, true],
)
import('pkgconfig').generate(
description: 'libastal',
libraries: libastal,
name: meson.project_name(),
filebase: meson.project_name() + '-' + api_version,
version: meson.project_version(),
subdirs: meson.project_name(),
requires: 'gio-2.0',
install_dir: get_option('libdir') / 'pkgconfig',
)
if get_option('typelib')
custom_target(
astal_typelib,
command: [
find_program('g-ir-compiler'),
'--output', '@OUTPUT@',
'--shared-library', get_option('prefix') / get_option('libdir') / '@PLAINNAME@',
meson.current_build_dir() / astal_gir,
],
input: libastal,
output: astal_typelib,
depends: libastal,
install: true,
install_dir: get_option('libdir') / 'girepository-1.0',
)
endif
if get_option('cli_client')
executable(
meson.project_name(),
configure_file(
input: 'client.vala.in',
output: 'client.vala',
configuration: {
'VERSION': meson.project_version(),
},
),
dependencies: deps,
install: true,
)
endif

126
src/process.vala Normal file
View File

@@ -0,0 +1,126 @@
namespace Astal {
public class Process : Object {
private void read_stream(DataInputStream stream, bool err) {
stream.read_line_utf8_async.begin(Priority.DEFAULT, null, (_, res) => {
try {
var output = stream.read_line_utf8_async.end(res);
if (output != null) {
if (err)
stdout(output.strip());
else
stderr(output.strip());
read_stream(stream, err);
}
} catch (Error err) {
printerr("%s\n", err.message);
}
});
}
private DataInputStream out_stream;
private DataInputStream err_stream;
private DataOutputStream in_stream;
private Subprocess process;
public string[] argv { construct; get; }
public signal void stdout (string out);
public signal void stderr (string err);
public void kill() {
process.force_exit();
}
public void write(string in) throws Error {
in_stream.put_string(in);
}
public void write_async(string in) {
in_stream.write_all_async.begin(
in.data,
Priority.DEFAULT, null, (_, res) => {
try {
in_stream.write_all_async.end(res, null);
} catch (Error err) {
printerr("%s\n", err.message);
}
}
);
}
public Process.subprocessv(string[] cmd) throws Error {
Object(argv: cmd);
process = new Subprocess.newv(cmd,
SubprocessFlags.STDIN_PIPE |
SubprocessFlags.STDERR_PIPE |
SubprocessFlags.STDOUT_PIPE
);
out_stream = new DataInputStream(process.get_stdout_pipe());
err_stream = new DataInputStream(process.get_stderr_pipe());
in_stream = new DataOutputStream(process.get_stdin_pipe());
read_stream(out_stream, true);
read_stream(err_stream, false);
}
public static Process subprocess(string cmd) throws Error {
string[] argv;
Shell.parse_argv(cmd, out argv);
return new Process.subprocessv(argv);
}
public static string execv(string[] cmd) throws Error {
var process = new Subprocess.newv(
cmd,
SubprocessFlags.STDERR_PIPE |
SubprocessFlags.STDOUT_PIPE
);
string err_str, out_str;
process.communicate_utf8(null, null, out out_str, out err_str);
var success = process.get_successful();
process.dispose();
if (success)
return out_str.strip();
else
throw new ProcessError.FAILED(err_str.strip());
}
public static string exec(string cmd) throws Error {
string[] argv;
Shell.parse_argv(cmd, out argv);
return Process.execv(argv);
}
public Process.exec_asyncv(string[] cmd) throws Error {
Object(argv: cmd);
process = new Subprocess.newv(cmd,
SubprocessFlags.STDERR_PIPE |
SubprocessFlags.STDOUT_PIPE
);
process.communicate_utf8_async.begin(null, null, (_, res) => {
string err_str, out_str;
try {
process.communicate_utf8_async.end(res, out out_str, out err_str);
if (process.get_successful())
stdout(out_str.strip());
else
stderr(err_str.strip());
} catch (Error err) {
printerr("%s\n", err.message);
} finally {
dispose();
}
});
}
public static Process exec_async(string cmd) throws Error {
string[] argv;
Shell.parse_argv(cmd, out argv);
return new Process.exec_asyncv(argv);
}
}
errordomain ProcessError {
FAILED
}
}

68
src/time.vala Normal file
View File

@@ -0,0 +1,68 @@
namespace Astal {
public class Time : Object {
public signal void now ();
public signal void cancelled ();
private Cancellable cancellable;
private uint timeout_id;
construct {
cancellable = new Cancellable();
cancellable.cancelled.connect(() => {
Source.remove(timeout_id);
cancelled();
dispose();
});
}
private void connect_closure(Closure? closure) {
if (closure == null)
return;
now.connect(() => {
Value ret = Value(Type.POINTER); // void
closure.invoke(ref ret, {});
});
}
public Time.interval_prio(uint interval, int prio = Priority.DEFAULT, Closure? fn) {
connect_closure(fn);
Idle.add_once(() => now());
timeout_id = Timeout.add(interval, () => {
now();
return Source.CONTINUE;
}, prio);
}
public Time.timeout_prio(uint timeout, int prio = Priority.DEFAULT, Closure? fn) {
connect_closure(fn);
timeout_id = Timeout.add(timeout, () => {
now();
return Source.REMOVE;
}, prio);
}
public Time.idle_prio(int prio = Priority.DEFAULT_IDLE, Closure? fn) {
connect_closure(fn);
timeout_id = Idle.add(() => {
now();
return Source.REMOVE;
}, prio);
}
public static Time interval(uint interval, Closure? fn) {
return new Time.interval_prio(interval, Priority.DEFAULT, fn);
}
public static Time timeout(uint timeout, Closure? fn) {
return new Time.timeout_prio(timeout, Priority.DEFAULT, fn);
}
public static Time idle(Closure? fn) {
return new Time.idle_prio(Priority.DEFAULT_IDLE, fn);
}
public void cancel() {
cancellable.cancel();
}
}
}

190
src/variable.vala Normal file
View File

@@ -0,0 +1,190 @@
namespace Astal {
public class VariableBase : Object {
public signal void changed ();
public signal void dropped ();
public signal void error (string err);
// lua-lgi crashes when using its emitting mechanism
public void emit_changed() { changed(); }
public void emit_dropped() { dropped(); }
public void emit_error(string err) { this.error(err); }
~VariableBase() {
dropped();
}
}
public class Variable : VariableBase {
public Value value { owned get; set; }
private uint poll_id = 0;
private Process? watch_proc;
private uint poll_interval { get; set; default = 1000; }
private string[] poll_exec { get; set; }
private Closure? poll_transform { get; set; }
private Closure? poll_fn { get; set; }
private Closure? watch_transform { get; set; }
private string[] watch_exec { get; set; }
public Variable(Value init) {
Object(value: init);
}
public Variable poll(
uint interval,
string exec,
Closure? transform
) throws Error {
string[] argv;
Shell.parse_argv(exec, out argv);
return pollv(interval, argv, transform);
}
public Variable pollv(
uint interval,
string[] execv,
Closure? transform
) throws Error {
if (is_polling())
stop_poll();
poll_interval = interval;
poll_exec = execv;
poll_transform = transform;
poll_fn = null;
start_poll();
return this;
}
public Variable pollfn(
uint interval,
Closure fn
) throws Error {
if (is_polling())
stop_poll();
poll_interval = interval;
poll_fn = fn;
poll_exec = null;
start_poll();
return this;
}
public Variable watch(
string exec,
Closure? transform
) throws Error {
string[] argv;
Shell.parse_argv(exec, out argv);
return watchv(argv, transform);
}
public Variable watchv(
string[] execv,
Closure? transform
) throws Error {
if (is_watching())
stop_watch();
watch_exec = execv;
watch_transform = transform;
start_watch();
return this;
}
construct {
notify["value"].connect(() => changed());
dropped.connect(() => {
if (is_polling())
stop_poll();
if (is_watching())
stop_watch();
});
}
private void set_closure(string val, Closure? transform) {
if (transform != null) {
var str = Value(typeof(string));
str.set_string(val);
var ret_val = Value(this.value.type());
transform.invoke(ref ret_val, { str, this.value });
this.value = ret_val;
}
else {
if (this.value.type() == Type.STRING && this.value.get_string() == val)
return;
var str = Value(typeof(string));
str.set_string(val);
this.value = str;
}
}
private void set_fn() {
var ret_val = Value(this.value.type());
poll_fn.invoke(ref ret_val, { this.value });
this.value = ret_val;
}
public void start_poll() throws Error {
return_if_fail(poll_id == 0);
if (poll_fn != null) {
set_fn();
poll_id = Timeout.add(poll_interval, () => {
set_fn();
return Source.CONTINUE;
}, Priority.DEFAULT);
}
if (poll_exec != null) {
var proc = new Process.exec_asyncv(poll_exec);
proc.stdout.connect((str) => set_closure(str, poll_transform));
proc.stderr.connect((str) => this.error(str));
poll_id = Timeout.add(poll_interval, () => {
try {
proc = new Process.exec_asyncv(poll_exec);
proc.stdout.connect((str) => set_closure(str, poll_transform));
proc.stderr.connect((str) => this.error(str));
return Source.CONTINUE;
} catch (Error err) {
printerr("%s\n", err.message);
poll_id = 0;
return Source.REMOVE;
}
}, Priority.DEFAULT);
}
}
public void start_watch() throws Error {
return_if_fail(watch_proc == null);
return_if_fail(watch_exec != null);
watch_proc = new Process.subprocessv(watch_exec);
watch_proc.stdout.connect((str) => set_closure(str, watch_transform));
watch_proc.stderr.connect((str) => this.error(str));
}
public void stop_poll() {
return_if_fail(poll_id != 0);
Source.remove(poll_id);
poll_id = 0;
}
public void stop_watch() {
return_if_fail(watch_proc != null);
watch_proc.kill();
watch_proc = null;
}
public bool is_polling() { return poll_id > 0; }
public bool is_watching() { return watch_proc != null; }
~Variable() {
dropped();
}
}
}

60
src/widget/box.vala Normal file
View File

@@ -0,0 +1,60 @@
namespace Astal {
public class Box : Gtk.Box {
public bool vertical {
get { return orientation == Gtk.Orientation.VERTICAL; }
set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; }
}
public List<weak Gtk.Widget> children {
set { _set_children(value); }
owned get { return get_children(); }
}
public new Gtk.Widget child {
owned get { return _get_child(); }
set { _set_child(value); }
}
construct {
notify["orientation"].connect(() => {
notify_property("vertical");
});
}
private void _set_child(Gtk.Widget child) {
var list = new List<weak Gtk.Widget>();
list.append(child);
_set_children(list);
}
private Gtk.Widget? _get_child() {
foreach(var child in get_children())
return child;
return null;
}
private void _set_children(List<weak Gtk.Widget> arr) {
foreach(var child in get_children())
remove(child);
foreach(var child in arr)
add(child);
}
public Box(bool vertical, List<weak Gtk.Widget> children) {
this.vertical = vertical;
_set_children(children);
}
public Box.newh(List<weak Gtk.Widget> children) {
this.vertical = false;
_set_children(children);
}
public Box.newv(List<weak Gtk.Widget> children) {
this.vertical = true;
_set_children(children);
}
}
}

35
src/widget/button.vala Normal file
View File

@@ -0,0 +1,35 @@
namespace Astal {
public class Button : Gtk.Button {
public signal void hover (Gdk.EventCrossing event);
public signal void hover_lost (Gdk.EventCrossing event);
public signal void click (Gdk.EventButton event);
public signal void click_release (Gdk.EventButton event);
construct {
add_events(Gdk.EventMask.SCROLL_MASK);
add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK);
enter_notify_event.connect((self, event) => {
if (event.window == self.get_window() &&
event.detail != Gdk.NotifyType.INFERIOR) {
hover(event);
}
});
leave_notify_event.connect((self, event) => {
if (event.window == self.get_window() &&
event.detail != Gdk.NotifyType.INFERIOR) {
hover_lost(event);
}
});
button_press_event.connect((event) => {
click(event);
});
button_release_event.connect((event) => {
click_release(event);
});
}
}
}

35
src/widget/centerbox.vala Normal file
View File

@@ -0,0 +1,35 @@
namespace Astal {
public class CenterBox : Gtk.Box {
public bool vertical {
get { return orientation == Gtk.Orientation.VERTICAL; }
set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; }
}
private Gtk.Widget _start_widget;
public Gtk.Widget start_widget {
get { return _start_widget; }
set {
if (_start_widget != null)
remove(_start_widget);
pack_start(value, true, true, 0);
}
}
private Gtk.Widget _end_widget;
public Gtk.Widget end_widget {
get { return _end_widget; }
set {
if (_end_widget != null)
remove(_end_widget);
pack_end(value, true, true, 0);
}
}
public Gtk.Widget center_widget {
get { return get_center_widget(); }
set { set_center_widget(value); }
}
}
}

View File

@@ -0,0 +1,174 @@
namespace Astal {
public class CircularProgress : Gtk.Bin {
public new Gtk.Widget child { get; set; }
public double start_at { get; set; }
public double end_at { get; set; }
public double value { get; set; }
public bool inverted { get; set; }
public bool rounded { get; set; }
construct {
notify["start-at"].connect(queue_draw);
notify["end-at"].connect(queue_draw);
notify["value"].connect(queue_draw);
notify["inverted"].connect(queue_draw);
notify["rounded"].connect(queue_draw);
notify["child"].connect(queue_draw);
}
static construct {
set_css_name("circular-progress");
}
public new void get_preferred_height(out int minh, out int nath) {
var val = get_style_context().get_property("min-height", Gtk.StateFlags.NORMAL);
if (val.get_int() <= 0) {
minh = 40;
nath = 40;
}
minh = val.get_int();
nath = val.get_int();
}
public new void get_preferred_width(out int minw, out int natw) {
var val = get_style_context().get_property("min-width", Gtk.StateFlags.NORMAL);
if (val.get_int() <= 0) {
minw = 40;
natw = 40;
}
minw = val.get_int();
natw = val.get_int();
}
private double _to_radian(double percentage) {
percentage = Math.floor(percentage * 100);
return (percentage / 100) * (2 * Math.PI);
}
private bool _is_full_circle(double start, double end, double epsilon = 1e-10) {
// Ensure that start and end are between 0 and 1
start = (start % 1 + 1) % 1;
end = (end % 1 + 1) % 1;
// Check if the difference between start and end is close to 1
return Math.fabs(start - end) <= epsilon;
}
private double _map_arc_value_to_range(double start, double end, double value) {
// Ensure that start and end are between 0 and 1
start = (start % 1 + 1) % 1;
end = (end % 1 + 1) % 1;
// Calculate the length of the arc
var arcLength = end - start;
if (arcLength < 0)
arcLength += 1; // Adjust for circular representation
// Calculate the position on the arc based on the percentage value
var position = start + (arcLength * value);
// Ensure the position is between 0 and 1
position = (position % 1 + 1) % 1;
return position;
}
private double _min(double[] arr) {
double min = arr[0];
foreach(var i in arr)
if (min > i) min = i;
return min;
}
private double _max(double[] arr) {
double max = arr[0];
foreach(var i in arr)
if (max < i) max = i;
return max;
}
public new bool draw(Cairo.Context cr) {
Gtk.Allocation allocation;
get_allocation(out allocation);
var styles = get_style_context();
var width = allocation.width;
var height = allocation.height;
var thickness = styles.get_property("font-size", Gtk.StateFlags.NORMAL).get_double();
var margin = styles.get_margin(Gtk.StateFlags.NORMAL);
var fg = styles.get_color(Gtk.StateFlags.NORMAL);
var bg = styles.get_background_color(Gtk.StateFlags.NORMAL);
var bg_stroke = thickness + _min({margin.bottom, margin.top, margin.left, margin.right});
var fg_stroke = thickness;
var radius = _min({width, height}) / 2.0 - _max({bg_stroke, fg_stroke}) / 2.0;
var center_x = width / 2;
var center_y = height / 2;
var start_background = _to_radian(this.start_at);
var end_background = _to_radian(this.end_at);
var ranged_value = this.value + this.start_at;
var is_circle = _is_full_circle(this.start_at, this.end_at);
if (is_circle) {
// Redefine endDraw in radius to create an accurate full circle
end_background = start_background + 2 * Math.PI;
} else {
// Range the value for the arc shape
ranged_value = _map_arc_value_to_range(
this.start_at,
this.end_at,
this.value
);
}
var to = _to_radian(ranged_value);
double start_progress, end_progress;
if (this.inverted) {
start_progress = (2 * Math.PI - to) - start_background;
end_progress = (2 * Math.PI - start_background) - start_background;
} else {
start_progress = start_background;
end_progress = to;
}
// Draw background
cr.set_source_rgba(bg.red, bg.green, bg.blue, bg.alpha);
cr.arc(center_x, center_y, radius, start_background, end_background);
cr.set_line_width(bg_stroke);
cr.stroke();
// Draw progress
cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha);
cr.arc(center_x, center_y, radius, start_progress, end_progress);
cr.set_line_width(fg_stroke);
cr.stroke();
// Draw rounded ends
if (this.rounded) {
var start_x = center_x + Math.cos(start_background);
var start_y = center_y + Math.cos(start_background);
var end_x = center_x + Math.cos(to) * radius;
var end_y = center_y + Math.cos(to) * radius;
cr.set_line_width(0);
cr.arc(start_x, start_y, fg_stroke / 2, 0, 0 - 0.01);
cr.fill();
cr.arc(end_x, end_y, fg_stroke / 2, 0, 0 - 0.01);
cr.fill();
}
if (this.child != null) {
this.child.size_allocate(allocation);
this.propagate_draw(this.child, cr);
}
return true;
}
}
}

39
src/widget/eventbox.vala Normal file
View File

@@ -0,0 +1,39 @@
namespace Astal {
public class EventBox : Gtk.EventBox {
public signal void hover (Gdk.EventCrossing event);
public signal void hover_lost (Gdk.EventCrossing event);
public signal void click (Gdk.EventButton event);
public signal void click_release (Gdk.EventButton event);
construct {
add_events(Gdk.EventMask.SCROLL_MASK);
add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK);
enter_notify_event.connect((self, event) => {
if (event.window == self.get_window() &&
event.detail != Gdk.NotifyType.INFERIOR) {
this.set_state_flags(Gtk.StateFlags.PRELIGHT, false);
hover(event);
}
});
leave_notify_event.connect((self, event) => {
if (event.window == self.get_window() &&
event.detail != Gdk.NotifyType.INFERIOR) {
this.unset_state_flags(Gtk.StateFlags.PRELIGHT);
hover_lost(event);
}
});
button_press_event.connect((event) => {
// TODO: abstract event for easier use
click(event);
});
button_release_event.connect((event) => {
// TODO: abstract event for easier use
click_release(event);
});
}
}
}

87
src/widget/icon.vala Normal file
View File

@@ -0,0 +1,87 @@
namespace Astal {
public Gtk.IconInfo? lookup_icon(string icon) {
var theme = Gtk.IconTheme.get_default();
return theme.lookup_icon(icon, 16, Gtk.IconLookupFlags.USE_BUILTIN);
}
public class Icon : Gtk.Image {
private IconType type = IconType.NAMED;
private double size { get; set; default = 14; }
public new Gdk.Pixbuf pixbuf { get; set; }
public string icon { get; set; default = ""; }
private void display_icon() {
switch(type) {
case IconType.NAMED:
icon_name = icon;
pixel_size = (int)size;
break;
case IconType.FILE:
try {
var pb = new Gdk.Pixbuf.from_file_at_size(
icon,
(int)size * scale_factor,
(int)size * scale_factor
);
var cs = Gdk.cairo_surface_create_from_pixbuf(pb, 0, this.get_window());
set_from_surface(cs);
} catch (Error err) {
printerr(err.message);
}
break;
case IconType.PIXBUF:
var pb_scaled = pixbuf.scale_simple(
(int)size * scale_factor,
(int)size * scale_factor,
Gdk.InterpType.BILINEAR
);
if (pb_scaled != null) {
var cs = Gdk.cairo_surface_create_from_pixbuf(pb_scaled, 0, this.get_window());
set_from_surface(cs);
}
break;
}
}
construct {
notify["icon"].connect(() => {
if(FileUtils.test(icon, GLib.FileTest.EXISTS))
type = IconType.FILE;
else if (lookup_icon(icon) != null)
type = IconType.NAMED;
else {
type = IconType.NAMED;
warning("cannot assign %s as icon, "+
"it is not a file nor a named icon", icon);
}
display_icon();
});
notify["pixbuf"].connect(() => {
type = IconType.PIXBUF;
display_icon();
});
size_allocate.connect(() => {
size = get_style_context()
.get_property("font-size", Gtk.StateFlags.NORMAL).get_double();
display_icon();
});
get_style_context().changed.connect(() => {
size = get_style_context()
.get_property("font-size", Gtk.StateFlags.NORMAL).get_double();
display_icon();
});
}
}
private enum IconType {
NAMED,
FILE,
PIXBUF,
}
}

132
src/widget/widget.vala Normal file
View File

@@ -0,0 +1,132 @@
namespace Astal {
private class Css {
private static HashTable<Gtk.Widget, Gtk.CssProvider> _providers;
public static HashTable<Gtk.Widget, Gtk.CssProvider> providers {
get {
if (_providers == null) {
_providers = new HashTable<Gtk.Widget, Gtk.CssProvider>(
(w) => (uint)w,
(a, b) => a == b);
}
return _providers;
}
}
}
private void remove_provider(Gtk.Widget widget) {
var providers = Css.providers;
if (providers.contains(widget)) {
var p = providers.get(widget);
widget.get_style_context().remove_provider(p);
providers.remove(widget);
p.dispose();
}
}
public void widget_set_css(Gtk.Widget widget, string css) {
var providers = Css.providers;
if (providers.contains(widget)) {
remove_provider(widget);
} else {
widget.destroy.connect(() => {
remove_provider(widget);
});
}
var style = !css.contains("{") || !css.contains("}")
? "* { ".concat(css, "}") : css;
var p = new Gtk.CssProvider();
widget.get_style_context()
.add_provider(p, Gtk.STYLE_PROVIDER_PRIORITY_USER);
try {
p.load_from_data(style, style.length);
providers.set(widget, p);
} catch (Error err) {
warning(err.message);
}
}
public string widget_get_css(Gtk.Widget widget) {
var providers = Css.providers;
if (providers.contains(widget))
return providers.get(widget).to_string();
return "";
}
public void widget_set_class_names(Gtk.Widget widget, string[] class_names) {
foreach (var name in widget_get_class_names(widget))
widget_toggle_class_name(widget, name, false);
foreach (var name in class_names)
widget_toggle_class_name(widget, name, true);
}
public List<weak string> widget_get_class_names(Gtk.Widget widget) {
return widget.get_style_context().list_classes();
}
public void widget_toggle_class_name(Gtk.Widget widget,
string class_name,
bool condition) {
var c = widget.get_style_context();
if (condition)
c.add_class(class_name);
else
c.remove_class(class_name);
}
private class Cursor {
private static HashTable<Gtk.Widget, string> _cursors;
public static HashTable<Gtk.Widget, string> cursors {
get {
if (_cursors == null) {
_cursors = new HashTable<Gtk.Widget, string>(
(w) => (uint)w,
(a, b) => a == b);
}
return _cursors;
}
}
}
private void widget_setup_cursor(Gtk.Widget widget) {
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK);
widget.add_events(Gdk.EventMask.LEAVE_NOTIFY_MASK);
widget.enter_notify_event.connect(() => {
widget.get_window().set_cursor(
new Gdk.Cursor.from_name(
Gdk.Display.get_default(),
Cursor.cursors.get(widget)));
return false;
});
widget.leave_notify_event.connect(() => {
widget.get_window().set_cursor(
new Gdk.Cursor.from_name(
Gdk.Display.get_default(),
"default"));
return false;
});
widget.destroy.connect(() => {
if (Cursor.cursors.contains(widget))
Cursor.cursors.remove(widget);
});
}
public void widget_set_cursor(Gtk.Widget widget, string cursor) {
if (!Cursor.cursors.contains(widget))
widget_setup_cursor(widget);
Cursor.cursors.set(widget, cursor);
}
public string widget_get_cursor(Gtk.Widget widget) {
return Cursor.cursors.get(widget);
}
}

136
src/widget/window.vala Normal file
View File

@@ -0,0 +1,136 @@
using GtkLayerShell;
namespace Astal {
public enum WindowAnchor {
NONE = 0,
TOP = 1,
RIGHT = 2,
LEFT = 4,
BOTTOM = 8,
}
public enum Exclusivity {
NORMAL,
EXCLUSIVE,
IGNORE,
}
public enum Layer {
TOP = GtkLayerShell.Layer.TOP,
OVERLAY = GtkLayerShell.Layer.OVERLAY,
BOTTOM = GtkLayerShell.Layer.BOTTOM,
BACKGROUND = GtkLayerShell.Layer.BACKGROUND,
}
public enum Keymode {
NONE = GtkLayerShell.KeyboardMode.NONE,
ON_DEMAND = GtkLayerShell.KeyboardMode.ON_DEMAND,
EXCLUSIVE = GtkLayerShell.KeyboardMode.EXCLUSIVE,
}
public class Window : Gtk.Window {
construct {
height_request = 1;
width_request = 1;
init_for_window(this);
set_namespace(this, name);
notify["name"].connect(() => set_namespace(this, name));
}
public int anchor {
set {
set_anchor(this, Edge.TOP, WindowAnchor.TOP in value);
set_anchor(this, Edge.BOTTOM, WindowAnchor.BOTTOM in value);
set_anchor(this, Edge.LEFT, WindowAnchor.LEFT in value);
set_anchor(this, Edge.RIGHT, WindowAnchor.RIGHT in value);
}
get {
var a = WindowAnchor.NONE;
if (get_anchor(this, Edge.TOP))
a = a | WindowAnchor.TOP;
if (get_anchor(this, Edge.RIGHT))
a = a | WindowAnchor.RIGHT;
if (get_anchor(this, Edge.LEFT))
a = a | WindowAnchor.LEFT;
if (get_anchor(this, Edge.BOTTOM))
a = a | WindowAnchor.BOTTOM;
return a;
}
}
public Exclusivity exclusivity {
set {
switch (value) {
case Exclusivity.NORMAL:
set_exclusive_zone(this, 0);
break;
case Exclusivity.EXCLUSIVE:
auto_exclusive_zone_enable (this);
break;
case Exclusivity.IGNORE:
set_exclusive_zone(this, -1);
break;
}
}
get {
if (auto_exclusive_zone_is_enabled (this))
return Exclusivity.EXCLUSIVE;
if (get_exclusive_zone(this) == -1)
return Exclusivity.IGNORE;
return Exclusivity.NORMAL;
}
}
public Layer layer {
get { return (Layer)get_layer(this); }
set { set_layer(this, (GtkLayerShell.Layer)value); }
}
public Keymode keymode {
set { set_keyboard_mode(this, (GtkLayerShell.KeyboardMode)value); }
get { return (Keymode)get_keyboard_mode(this); }
}
public Gdk.Monitor gdkmonitor {
set { set_monitor (this, value); }
get { return get_monitor(this); }
}
/**
* CAUTION: the id might not be the same mapped by the compositor
* to reset and let the compositor map it pass a negative number
*/
public int monitor {
set {
if (value < 0)
set_monitor(this, (Gdk.Monitor)null);
var m = Gdk.Display.get_default().get_monitor(value);
set_monitor(this, m);
}
get {
var m = get_monitor(this);
var d = Gdk.Display.get_default();
for (var i = 0; i < d.get_n_monitors(); ++i) {
if (m == d.get_monitor(i))
return i;
}
return -1;
}
}
}
/**
* CAUTION: the id might not be the same mapped by the compositor
*/
public uint get_num_monitors() {
return Gdk.Display.get_default().get_n_monitors();
}
}

1
version Normal file
View File

@@ -0,0 +1 @@
0.1.0