diff options
author | kotontrion <[email protected]> | 2024-10-29 13:50:41 +0100 |
---|---|---|
committer | kotontrion <[email protected]> | 2024-10-29 13:50:41 +0100 |
commit | 57f20666e716fde56579b8aa638eed1264f793de (patch) | |
tree | 59b2ebbd770c80049cea4df82109d28f617675fe /lib | |
parent | 4d9ae88b0bab75779876d465f986791d052414ca (diff) | |
parent | 7e484188e7492ac7945c854bcc3f26cec1863c91 (diff) |
Merge branch 'main' into feat/cava
Diffstat (limited to 'lib')
65 files changed, 4518 insertions, 482 deletions
diff --git a/lib/apps/application.vala b/lib/apps/application.vala index 5748fc6..0a2f73c 100644 --- a/lib/apps/application.vala +++ b/lib/apps/application.vala @@ -1,23 +1,68 @@ -namespace AstalApps { -public class Application : Object { +public class AstalApps.Application : Object { + /** + * The underlying DesktopAppInfo. + */ public DesktopAppInfo app { get; construct set; } + + /** + * The number of times [[email protected]] was called on this Application. + */ public int frequency { get; set; default = 0; } + + /** + * The name of this Application. + */ public string name { get { return app.get_name(); } } + + /** + * Name of the .desktop of this Application. + */ public string entry { get { return app.get_id(); } } + + /** + * Description of this Application. + */ public string description { get { return app.get_description(); } } + + /** + * `StartupWMClass` field from the desktop file. + * This represents the `WM_CLASS` property of the main window of the application. + */ public string wm_class { get { return app.get_startup_wm_class(); } } + + /** + * `Exec` field from the desktop file. + * Note that if you want to launch this Application you should use the [[email protected]] method. + */ public string executable { owned get { return app.get_string("Exec"); } } + + /** + * `Icon` field from the desktop file. + * This is usually a named icon or a path to a file. + */ public string icon_name { owned get { return app.get_string("Icon"); } } + /** + * `Keywords` field from the desktop file. + */ + public string[] keywords { owned get { return app.get_keywords(); } } + internal Application(string id, int? frequency = 0) { Object(app: new DesktopAppInfo(id)); this.frequency = frequency; } + /** + * Get a value from the .desktop file by its key. + */ public string get_key(string key) { return app.get_string(key); } + /** + * Launches this application. + * The launched application inherits the environment of the launching process + */ public bool launch() { try { var s = app.launch(null, null); @@ -29,22 +74,36 @@ public class Application : Object { } } + /** + * Calculate a score for an application using fuzzy matching algorithm. + */ public Score fuzzy_match(string term) { var score = Score(); + if (name != null) - score.name = levenshtein(term, name); + score.name = fuzzy_match_string(term, name); if (entry != null) - score.entry = levenshtein(term, entry); + score.entry = fuzzy_match_string(term, entry); if (executable != null) - score.executable = levenshtein(term, executable); + score.executable = fuzzy_match_string(term, executable); if (description != null) - score.description = levenshtein(term, description); + score.description = fuzzy_match_string(term, description); + foreach (var keyword in keywords) { + var s = fuzzy_match_string(term, keyword); + if (s > score.keywords) { + score.keywords = s; + } + } return score; } + /** + * Calculate a score using exact string algorithm. + */ public Score exact_match(string term) { var score = Score(); + if (name != null) score.name = name.down().contains(term.down()) ? 1 : 0; if (entry != null) @@ -53,12 +112,17 @@ public class Application : Object { score.executable = executable.down().contains(term.down()) ? 1 : 0; if (description != null) score.description = description.down().contains(term.down()) ? 1 : 0; + foreach (var keyword in keywords) { + if (score.keywords == 0) { + score.keywords = keyword.down().contains(term.down()) ? 1 : 0; + } + } return score; } internal Json.Node to_json() { - return new Json.Builder() + var builder = new Json.Builder() .begin_object() .set_member_name("name").add_string_value(name) .set_member_name("entry").add_string_value(entry) @@ -66,53 +130,24 @@ public class Application : Object { .set_member_name("description").add_string_value(description) .set_member_name("icon_name").add_string_value(icon_name) .set_member_name("frequency").add_int_value(frequency) - .end_object() - .get_root(); - } -} - -int min3(int a, int b, int c) { - return (a < b) ? ((a < c) ? a : c) : ((b < c) ? b : c); -} - -double levenshtein(string s1, string s2) { - int len1 = s1.length; - int len2 = s2.length; - - int[, ] d = new int[len1 + 1, len2 + 1]; - - for (int i = 0; i <= len1; i++) { - d[i, 0] = i; - } - for (int j = 0; j <= len2; j++) { - d[0, j] = j; - } + .set_member_name("keywords") + .begin_array(); - for (int i = 1; i <= len1; i++) { - for (int j = 1; j <= len2; j++) { - int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; - d[i, j] = min3( - d[i - 1, j] + 1, // deletion - d[i, j - 1] + 1, // insertion - d[i - 1, j - 1] + cost // substitution - ); + foreach (string keyword in keywords) { + builder.add_string_value(keyword); } - } - var distance = d[len1, len2]; - int max_len = len1 > len2 ? len1 : len2; - - if (max_len == 0) { - return 1.0; + return builder + .end_array() + .end_object() + .get_root(); } - - return 1.0 - ((double)distance / max_len); } -public struct Score { - double name; - double entry; - double executable; - double description; -} +public struct AstalApps.Score { + int name; + int entry; + int executable; + int description; + int keywords; } diff --git a/lib/apps/apps.vala b/lib/apps/apps.vala index 2a0d507..dde7d44 100644 --- a/lib/apps/apps.vala +++ b/lib/apps/apps.vala @@ -1,25 +1,84 @@ -namespace AstalApps { -public class Apps : Object { +public class AstalApps.Apps : Object { private string cache_directory; private string cache_file; private List<Application> _list; private HashTable<string, int> frequents { get; private set; } + /** + * Indicates wether hidden applications should included in queries. + */ public bool show_hidden { get; set; } + + /** + * Full list of available applications. + */ public List<weak Application> list { owned get { return _list.copy(); } } - public double min_score { get; set; default = 0.5; } + /** + * The minimum score the application has to meet in order to be included in queries. + */ + public double min_score { get; set; default = 0; } + /** + * Extra multiplier to apply when matching the `name` of an application. + * Defaults to `2` + */ public double name_multiplier { get; set; default = 2; } + + /** + * Extra multiplier to apply when matching the entry of an application. + * Defaults to `1` + */ public double entry_multiplier { get; set; default = 1; } + + /** + * Extra multiplier to apply when matching the executable of an application. + * Defaults to `1` + */ public double executable_multiplier { get; set; default = 1; } + + /** + * Extra multiplier to apply when matching the description of an application. + * Defaults to `0.5` + */ public double description_multiplier { get; set; default = 0.5; } + /** + * Extra multiplier to apply when matching the keywords of an application. + * Defaults to `0.5` + */ + public double keywords_multiplier { get; set; default = 0.5; } + + /** + * Consider the name of an application during queries. + * Defaults to `true` + */ public bool include_name { get; set; default = true; } + + /** + * Consider the entry of an application during queries. + * Defaults to `false` + */ public bool include_entry { get; set; default = false; } + + /** + * Consider the executable of an application during queries. + * Defaults to `false` + */ public bool include_executable { get; set; default = false; } + + /** + * Consider the description of an application during queries. + * Defaults to `false` + */ public bool include_description { get; set; default = false; } + /** + * Consider the keywords of an application during queries. + * Defaults to `false` + */ + public bool include_keywords { get; set; default = false; } + construct { cache_directory = Environment.get_user_cache_dir() + "/astal"; cache_file = cache_directory + "/apps-frequents.json"; @@ -49,23 +108,39 @@ public class Apps : Object { reload(); } - private double score (string search, Application a, bool exact) { - var am = exact ? a.exact_match(search) : a.fuzzy_match(search); + private double score(string search, Application a, SearchAlgorithm alg) { + var s = Score(); double r = 0; - if (include_name) - r += am.name * name_multiplier; - if (include_entry) - r += am.entry * entry_multiplier; - if (include_executable) - r += am.executable * executable_multiplier; - if (include_description) - r += am.description * description_multiplier; + if (alg == FUZZY) s = a.fuzzy_match(search); + if (alg == EXACT) s = a.exact_match(search); + + if (include_name) r += s.name * name_multiplier; + if (include_entry) r += s.entry * entry_multiplier; + if (include_executable) r += s.executable * executable_multiplier; + if (include_description) r += s.description * description_multiplier; + if (include_keywords) r += s.keywords * keywords_multiplier; return r; } - public List<weak Application> query(string? search = "", bool exact = false) { + /** + * Calculate a score for an application using fuzzy matching algorithm. + * Taking this Apps' include settings into consideration . + */ + public double fuzzy_score(string search, Application a) { + return score(search, a, FUZZY); + } + + /** + * Calculate a score for an application using exact string algorithm. + * Taking this Apps' include settings into consideration . + */ + public double exact_score(string search, Application a) { + return score(search, a, EXACT); + } + + internal List<weak Application> query(string? search = "", SearchAlgorithm alg = FUZZY) { if (search == null) search = ""; @@ -83,7 +158,7 @@ public class Apps : Object { // single character, sort by frequency and exact match if (search.length == 1) { foreach (var app in list) { - if (score(search, app, true) == 0) + if (score(search, app, alg) == 0) arr.remove(app); } @@ -96,14 +171,14 @@ public class Apps : Object { // filter foreach (var app in list) { - if (score(search, app, exact) < min_score) + if (score(search, app, alg) < min_score) arr.remove(app); } // sort by score, frequency arr.sort_with_data((a, b) => { - var s1 = score(search, a, exact); - var s2 = score(search, b, exact); + var s1 = score(search, a, alg); + var s2 = score(search, b, alg); if (s1 == s2) return (int)b.frequency - (int)a.frequency; @@ -114,14 +189,23 @@ public class Apps : Object { return arr; } + /** + * Query the `list` of applications with a fuzzy matching algorithm. + */ public List<weak Application> fuzzy_query(string? search = "") { - return query(search, false); + return query(search, FUZZY); } + /** + * Query the `list` of applications with a simple string matching algorithm. + */ public List<weak Application> exact_query(string? search = "") { - return query(search, true); + return query(search, EXACT); } + /** + * Reload the `list` of Applications. + */ public void reload() { var arr = AppInfo.get_all(); @@ -169,4 +253,8 @@ public class Apps : Object { } } } + +private enum AstalApps.SearchAlgorithm { + EXACT, + FUZZY, } diff --git a/lib/apps/fuzzy.vala b/lib/apps/fuzzy.vala new file mode 100644 index 0000000..7745320 --- /dev/null +++ b/lib/apps/fuzzy.vala @@ -0,0 +1,73 @@ +namespace AstalApps { +private int fuzzy_match_string(string pattern, string str) { + const int unmatched_letter_penalty = -1; + int score = 100; + + if (pattern.length == 0) return score; + if (str.length < pattern.length) return int.MIN; + + bool found = fuzzy_match_recurse(pattern, str, score, true, out score); + score += unmatched_letter_penalty * (str.length - pattern.length); + + if(!found) score = -10; + + return score; +} + +private bool fuzzy_match_recurse(string pattern, string str, int score, bool first_char, out int result) { + result = score; + if (pattern.length == 0) return true; + + int match_idx = 0; + int offset = 0; + unichar search = pattern.casefold().get_char(0); + int best_score = int.MIN; + + while ((match_idx = str.casefold().substring(offset).index_of_char(search)) >= 0) { + offset += match_idx; + int subscore; + bool found = fuzzy_match_recurse( + pattern.substring(1), + str.substring(offset + 1), + compute_score(offset, first_char, str, offset), false, out subscore); + if(!found) break; + best_score = int.max(best_score, subscore); + offset++; + } + + if (best_score == int.MIN) return false; + result += best_score; + return true; +} + +private int compute_score(int jump, bool first_char, string match, int idx) { + const int adjacency_bonus = 15; + const int separator_bonus = 30; + const int camel_bonus = 30; + const int first_letter_bonus = 15; + const int leading_letter_penalty = -5; + const int max_leading_letter_penalty = -15; + + int score = 0; + + if (!first_char && jump == 0) { + score += adjacency_bonus; + } + if (!first_char || jump > 0) { + if (match[idx].isupper() && match[idx-1].islower()) { + score += camel_bonus; + } + if (match[idx].isalnum() && !match[idx-1].isalnum()) { + score += separator_bonus; + } + } + if (first_char && jump == 0) { + score += first_letter_bonus; + } + if (first_char) { + score += int.max(leading_letter_penalty * jump, max_leading_letter_penalty); + } + + return score; +} +} diff --git a/lib/apps/gir.py b/lib/apps/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/apps/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/apps/meson.build b/lib/apps/meson.build index fb87e22..eb7a90b 100644 --- a/lib/apps/meson.build +++ b/lib/apps/meson.build @@ -39,34 +39,41 @@ deps = [ dependency('json-glib-1.0'), ] -sources = [ - config, - 'apps.vala', +sources = [config] + files( 'application.vala', + 'apps.vala', 'cli.vala', -] + 'fuzzy.vala', +) if get_option('lib') lib = library( meson.project_name(), sources, dependencies: deps, + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) - import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: deps, - install_dir: get_option('libdir') / 'pkgconfig', + pkgs = [] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -79,10 +86,20 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) endif if get_option('cli') diff --git a/lib/astal/gtk3/gir.py b/lib/astal/gtk3/gir.py new file mode 120000 index 0000000..16a3a64 --- /dev/null +++ b/lib/astal/gtk3/gir.py @@ -0,0 +1 @@ +../../gir.py
\ No newline at end of file diff --git a/lib/astal/gtk3/meson.build b/lib/astal/gtk3/meson.build new file mode 100644 index 0000000..48d3058 --- /dev/null +++ b/lib/astal/gtk3/meson.build @@ -0,0 +1,18 @@ +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', + ], +) + +libdir = get_option('prefix') / get_option('libdir') +pkgdatadir = get_option('prefix') / get_option('datadir') / 'astal' +girpy = files('gir.py') + +subdir('src') diff --git a/lib/astal/gtk3/src/application.vala b/lib/astal/gtk3/src/application.vala new file mode 100644 index 0000000..82ee797 --- /dev/null +++ b/lib/astal/gtk3/src/application.vala @@ -0,0 +1,265 @@ +[DBus (name="io.Astal.Application")] +public class Astal.Application : Gtk.Application, AstalIO.Application { + private List<Gtk.CssProvider> css_providers = new List<Gtk.CssProvider>(); + private SocketService service; + private DBusConnection conn; + private string _instance_name = "astal"; + private string socket_path { get; private set; } + + /** + * Emitted when a new monitor is added to [[email protected]]. + */ + [DBus (visible=false)] + public signal void monitor_added(Gdk.Monitor monitor); + + /** + * Emitted when a monitor is disconnected from [[email protected]]. + */ + [DBus (visible=false)] + public signal void monitor_removed(Gdk.Monitor monitor); + + /** + * Emitted when a window that has been added using + * [[email protected]_window] changes its visibility . + */ + [DBus (visible=false)] + public signal void window_toggled(Gtk.Window window); + + /** + * Get all monitors from [[email protected]]. + */ + [DBus (visible=false)] + public List<weak Gdk.Monitor> monitors { + owned get { + var display = Gdk.Display.get_default(); + var list = new List<weak Gdk.Monitor>(); + for (var i = 0; i <= display.get_n_monitors(); ++i) { + var mon = display.get_monitor(i); + if (mon != null) { + list.append(mon); + } + } + return list; + } + } + + /** + * A unique instance name. + * + * This is the identifier used by the AstalIO package and the CLI. + */ + [DBus (visible=false)] + public string instance_name { + owned get { return _instance_name; } + construct set { + _instance_name = value != null ? value : "astal"; + application_id = @"io.Astal.$_instance_name"; + } + } + + /** + * Windows that has been added to this app using [[email protected]_window]. + */ + [DBus (visible=false)] + public List<Gtk.Window> windows { + get { return get_windows(); } + } + + private Gtk.Settings settings { + get { return Gtk.Settings.get_default(); } + } + + private Gdk.Screen screen { + get { return Gdk.Screen.get_default(); } + } + + /** + * Shortcut for [[email protected]:gtk_theme_name] + */ + [DBus (visible=false)] + public string gtk_theme { + owned get { return settings.gtk_theme_name; } + set { settings.gtk_theme_name = value; } + } + + /** + * Shortcut for [[email protected]:gtk_icon_theme_name] + */ + [DBus (visible=false)] + public string icon_theme { + owned get { return settings.gtk_icon_theme_name; } + set { settings.gtk_icon_theme_name = value; } + } + + /** + * Shortcut for [[email protected]:gtk_cursor_theme_name] + */ + [DBus (visible=false)] + public string cursor_theme { + owned get { return settings.gtk_cursor_theme_name; } + set { settings.gtk_cursor_theme_name = value; } + } + + /** + * Remove all [[email protected]] providers. + */ + [DBus (visible=false)] + public void reset_css() { + foreach(var provider in css_providers) { + Gtk.StyleContext.remove_provider_for_screen(screen, provider); + } + css_providers = new List<Gtk.CssProvider>(); + } + + /** + * Shortcut for [[email protected]_interactive_debugging]. + */ + public void inspector() throws DBusError, IOError { + Gtk.Window.set_interactive_debugging(true); + } + + /** + * Get a window by its [[email protected]:name] that has been added to this app + * using [[email protected]_window]. + */ + [DBus (visible=false)] + public Gtk.Window? get_window(string name) { + foreach(var win in windows) { + if (win.name == name) + return win; + } + + critical("no window with name \"%s\"".printf(name)); + return null; + } + + /** + * Toggle the visibility of a window by its [[email protected]:name] + * that has been added to this app using [[email protected]_window]. + */ + public void toggle_window(string window) throws Error { + var win = get_window(window); + if (win != null) { + win.visible = !win.visible; + } else { + throw new IOError.FAILED("window not found"); + } + } + + /** + * Add a new [[email protected]] provider. + * + * @param style Css string or a path to a css file. + */ + [DBus (visible=false)] + public void apply_css(string style, bool reset = false) { + var provider = new Gtk.CssProvider(); + + if (reset) + reset_css(); + + try { + if (FileUtils.test(style, FileTest.EXISTS)) + provider.load_from_path(style); + else + provider.load_from_data(style); + } catch (Error err) { + critical(err.message); + } + + Gtk.StyleContext.add_provider_for_screen( + screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER); + + css_providers.append(provider); + } + + /** + * Shortcut for [[email protected]_search_path]. + */ + [DBus (visible=false)] + public void add_icons(string? path) { + if (path != null) { + Gtk.IconTheme.get_default().prepend_search_path(path); + } + } + + /** + * Handler for an incoming request. + * + * @param msg Body of the message + * @param conn The connection which expects the response. + */ + [DBus (visible=false)] + public virtual void request(string msg, SocketConnection conn) { + AstalIO.write_sock.begin(conn, @"missing response implementation on $application_id"); + } + + /** + * Attempt to acquire the astal socket for this app identified by its [[email protected]:instance_name]. + * If the socket is in use by another app with the same name an [[email protected]_OCCUPIED] is thrown. + */ + [DBus (visible=false)] + public void acquire_socket() throws Error { + string path; + service = AstalIO.acquire_socket(this, out path); + socket_path = path; + + Bus.own_name( + BusType.SESSION, + application_id, + BusNameOwnerFlags.NONE, + (conn) => { + try { + this.conn = conn; + conn.register_object("/io/Astal/Application", this); + } catch (Error err) { + critical(err.message); + } + }, + () => {}, + () => {} + ); + } + + /** + * Quit and stop the socket if it was acquired. + */ + public new void quit() throws DBusError, IOError { + if (service != null) { + service.stop(); + service.close(); + } + + base.quit(); + } + + construct { + activate.connect(() => { + var display = Gdk.Display.get_default(); + display.monitor_added.connect((mon) => { + monitor_added(mon); + notify_property("monitors"); + }); + display.monitor_removed.connect((mon) => { + monitor_removed(mon); + notify_property("monitors"); + }); + }); + + window_added.connect((window) => { + ulong id1, id2; + id1 = window.notify["visible"].connect(() => window_toggled(window)); + id2 = window_removed.connect((removed) => { + if (removed == window) { + window.disconnect(id1); + this.disconnect(id2); + } + }); + }); + + shutdown.connect(() => { try { quit(); } catch(Error err) {} }); + Unix.signal_add(1, () => { try { quit(); } catch(Error err) {} }, Priority.HIGH); + Unix.signal_add(2, () => { try { quit(); } catch(Error err) {} }, Priority.HIGH); + Unix.signal_add(15, () => { try { quit(); } catch(Error err) {} }, Priority.HIGH); + } +} diff --git a/lib/astal/gtk3/src/config.vala.in b/lib/astal/gtk3/src/config.vala.in new file mode 100644 index 0000000..88bfe9c --- /dev/null +++ b/lib/astal/gtk3/src/config.vala.in @@ -0,0 +1,6 @@ +namespace Astal { + public const int MAJOR_VERSION = @MAJOR_VERSION@; + public const int MINOR_VERSION = @MINOR_VERSION@; + public const int MICRO_VERSION = @MICRO_VERSION@; + public const string VERSION = "@VERSION@"; +} diff --git a/lib/astal/gtk3/src/idle-inhibit.c b/lib/astal/gtk3/src/idle-inhibit.c new file mode 100644 index 0000000..48f2471 --- /dev/null +++ b/lib/astal/gtk3/src/idle-inhibit.c @@ -0,0 +1,114 @@ +#include "idle-inhibit.h" + +#include <gdk/gdk.h> +#include <gdk/gdkwayland.h> +#include <gio/gio.h> +#include <glib-object.h> +#include <glib.h> +#include <gtk/gtk.h> +#include <wayland-client-protocol.h> +#include <wayland-client.h> + +#include "idle-inhibit-unstable-v1-client.h" + +struct _AstalInhibitManager { + GObject parent_instance; +}; + +typedef struct { + gboolean init; + struct wl_registry* wl_registry; + struct wl_display* display; + struct zwp_idle_inhibit_manager_v1* idle_inhibit_manager; +} AstalInhibitManagerPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE(AstalInhibitManager, astal_inhibit_manager, G_TYPE_OBJECT) + +AstalInhibitor* astal_inhibit_manager_inhibit(AstalInhibitManager* self, GtkWindow* window) { + AstalInhibitManagerPrivate* priv = astal_inhibit_manager_get_instance_private(self); + g_assert_true(priv->init); + GdkWindow* gdk_window = gtk_widget_get_window(GTK_WIDGET(window)); + struct wl_surface* surface = gdk_wayland_window_get_wl_surface(gdk_window); + return zwp_idle_inhibit_manager_v1_create_inhibitor(priv->idle_inhibit_manager, surface); +} + +static void global_registry_handler(void* data, struct wl_registry* registry, uint32_t id, + const char* interface, uint32_t version) { + AstalInhibitManager* self = ASTAL_INHIBIT_MANAGER(data); + AstalInhibitManagerPrivate* priv = astal_inhibit_manager_get_instance_private(self); + + if (strcmp(interface, zwp_idle_inhibit_manager_v1_interface.name) == 0) { + priv->idle_inhibit_manager = + wl_registry_bind(registry, id, &zwp_idle_inhibit_manager_v1_interface, 1); + } +} + +static void global_registry_remover(void* data, struct wl_registry* registry, uint32_t id) { + // neither inhibit_manager nor inhibitor is going to be removed by the compositor, so we don't + // need do anything here. +} + +static const struct wl_registry_listener registry_listener = {global_registry_handler, + global_registry_remover}; + +static gboolean astal_inhibit_manager_wayland_init(AstalInhibitManager* self) { + AstalInhibitManagerPrivate* priv = astal_inhibit_manager_get_instance_private(self); + + if (priv->init) return TRUE; + + GdkDisplay* gdk_display = gdk_display_get_default(); + priv->display = gdk_wayland_display_get_wl_display(gdk_display); + + priv->wl_registry = wl_display_get_registry(priv->display); + wl_registry_add_listener(priv->wl_registry, ®istry_listener, self); + + wl_display_roundtrip(priv->display); + + if (priv->idle_inhibit_manager == NULL) { + g_critical("Can not connect idle inhibitor protocol"); + return FALSE; + } + + priv->init = TRUE; + return TRUE; +} + +AstalInhibitManager* astal_inhibit_manager_get_default() { + static AstalInhibitManager* self = NULL; + + if (self == NULL) { + self = g_object_new(ASTAL_TYPE_INHIBIT_MANAGER, NULL); + if (!astal_inhibit_manager_wayland_init(self)) { + g_object_unref(self); + self = NULL; + } + } + + return self; +} + +static void astal_inhibit_manager_init(AstalInhibitManager* self) { + AstalInhibitManagerPrivate* priv = astal_inhibit_manager_get_instance_private(self); + priv->init = FALSE; + priv->display = NULL; + priv->wl_registry = NULL; + priv->idle_inhibit_manager = NULL; +} + +static void astal_inhibit_manager_finalize(GObject* object) { + AstalInhibitManager* self = ASTAL_INHIBIT_MANAGER(object); + AstalInhibitManagerPrivate* priv = astal_inhibit_manager_get_instance_private(self); + + if (priv->display != NULL) wl_display_roundtrip(priv->display); + + if (priv->wl_registry != NULL) wl_registry_destroy(priv->wl_registry); + if (priv->idle_inhibit_manager != NULL) + zwp_idle_inhibit_manager_v1_destroy(priv->idle_inhibit_manager); + + G_OBJECT_CLASS(astal_inhibit_manager_parent_class)->finalize(object); +} + +static void astal_inhibit_manager_class_init(AstalInhibitManagerClass* class) { + GObjectClass* object_class = G_OBJECT_CLASS(class); + object_class->finalize = astal_inhibit_manager_finalize; +} diff --git a/lib/astal/gtk3/src/idle-inhibit.h b/lib/astal/gtk3/src/idle-inhibit.h new file mode 100644 index 0000000..5e9a3ab --- /dev/null +++ b/lib/astal/gtk3/src/idle-inhibit.h @@ -0,0 +1,22 @@ +#ifndef ASTAL_IDLE_INHIBITOR_H +#define ASTAL_IDLE_INHIBITOR_H + +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "idle-inhibit-unstable-v1-client.h" + +G_BEGIN_DECLS + +#define ASTAL_TYPE_INHIBIT_MANAGER (astal_inhibit_manager_get_type()) + +G_DECLARE_FINAL_TYPE(AstalInhibitManager, astal_inhibit_manager, ASTAL, INHIBIT_MANAGER, GObject) + +typedef struct zwp_idle_inhibitor_v1 AstalInhibitor; + +AstalInhibitManager* astal_inhibit_manager_get_default(); +AstalInhibitor* astal_inhibit_manager_inhibit(AstalInhibitManager* self, GtkWindow* window); + +G_END_DECLS + +#endif // !ASTAL_IDLE_INHIBITOR_H diff --git a/lib/astal/gtk3/src/meson.build b/lib/astal/gtk3/src/meson.build new file mode 100644 index 0000000..bf8f72a --- /dev/null +++ b/lib/astal/gtk3/src/meson.build @@ -0,0 +1,145 @@ +version_split = meson.project_version().split('.') +api_version = version_split[0] + '.' + version_split[1] +gir = 'Astal-' + api_version + '.gir' +typelib = 'Astal-' + api_version + '.typelib' + +vapi_dir = meson.current_source_dir() / 'vapi' +add_project_arguments(['--vapidir', vapi_dir], language: 'vala') + +config = configure_file( + input: 'config.vala.in', + output: 'config.vala', + configuration: { + 'VERSION': meson.project_version(), + 'MAJOR_VERSION': version_split[0], + 'MINOR_VERSION': version_split[1], + 'MICRO_VERSION': version_split[2], + }, +) + +pkgconfig_deps = [ + dependency('astal-io-0.1'), + 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'), + dependency('wayland-client'), +] + +deps = pkgconfig_deps + meson.get_compiler('c').find_library('m') + +wayland_protos = dependency('wayland-protocols') +wayland_scanner = find_program('wayland-scanner') + +wl_protocol_dir = wayland_protos.get_variable(pkgconfig: 'pkgdatadir') + +gen_client_header = generator( + wayland_scanner, + output: ['@[email protected]'], + arguments: ['-c', 'client-header', '@INPUT@', '@BUILD_DIR@/@[email protected]'], +) + +gen_private_code = generator( + wayland_scanner, + output: ['@[email protected]'], + arguments: ['-c', 'private-code', '@INPUT@', '@BUILD_DIR@/@[email protected]'], +) + +protocols = [ + join_paths(wl_protocol_dir, 'unstable/idle-inhibit/idle-inhibit-unstable-v1.xml'), +] + +client_protocol_srcs = [] + +foreach protocol : protocols + client_header = gen_client_header.process(protocol) + code = gen_private_code.process(protocol) + client_protocol_srcs += [client_header, code] +endforeach + +vala_sources = [config] + files( + 'widget/box.vala', + 'widget/button.vala', + 'widget/centerbox.vala', + 'widget/circularprogress.vala', + 'widget/eventbox.vala', + 'widget/icon.vala', + 'widget/label.vala', + 'widget/levelbar.vala', + 'widget/overlay.vala', + 'widget/scrollable.vala', + 'widget/slider.vala', + 'widget/stack.vala', + 'widget/widget.vala', + 'widget/window.vala', + 'application.vala', + 'idle-inhibit.c', +) + +sources = vala_sources + client_protocol_srcs + files( + 'idle-inhibit.h', +) + +lib = library( + meson.project_name(), + sources, + dependencies: deps, + vala_args: ['--vapi-comments', '--pkg', 'AstalInhibitManager'], + vala_header: meson.project_name() + '.h', + vala_vapi: meson.project_name() + '-' + api_version + '.vapi', + version: meson.project_version(), + install: true, + install_dir: [true, true, true], +) + +pkgs = [] +foreach dep : pkgconfig_deps + pkgs += ['--pkg=' + dep.name()] +endforeach + +gir_tgt = custom_target( + gir, + command: [ + find_program('python3'), + girpy, + meson.project_name(), + gir + ':src/' + gir, + ] + + pkgs + + vala_sources + + [meson.project_source_root() / 'src' / 'vapi' / 'AstalInhibitManager.vapi'], + + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', +) + +custom_target( + typelib, + command: [ + find_program('g-ir-compiler'), + '--output', '@OUTPUT@', + '--shared-library', libdir / '@PLAINNAME@', + meson.current_build_dir() / gir, + ], + input: lib, + output: typelib, + depends: [lib, gir_tgt], + install: true, + install_dir: libdir / 'girepository-1.0', +) + +import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: pkgconfig_deps, + install_dir: libdir / 'pkgconfig', +) diff --git a/lib/astal/gtk3/src/vapi/AstalInhibitManager.vapi b/lib/astal/gtk3/src/vapi/AstalInhibitManager.vapi new file mode 100644 index 0000000..b2b3b34 --- /dev/null +++ b/lib/astal/gtk3/src/vapi/AstalInhibitManager.vapi @@ -0,0 +1,12 @@ +[CCode (cprefix = "Astal", gir_namespace = "Astal", lower_case_cprefix = "astal_")] +namespace Astal { + [CCode (cheader_filename = "idle-inhibit.h", type_id = "astal_idle_inhibit_manager_get_type()")] + public class InhibitManager : GLib.Object { + public static unowned InhibitManager? get_default(); + public Inhibitor inhibit (Gtk.Window window); + } + + [CCode (cheader_filename = "idle-inhibit.h", free_function = "zwp_idle_inhibitor_v1_destroy")] + [Compact] + public class Inhibitor { } +} diff --git a/lib/astal/gtk3/src/widget/box.vala b/lib/astal/gtk3/src/widget/box.vala new file mode 100644 index 0000000..d049161 --- /dev/null +++ b/lib/astal/gtk3/src/widget/box.vala @@ -0,0 +1,53 @@ +public class Astal.Box : Gtk.Box { + /** + * Corresponds to [[email protected] :orientation]. + */ + [CCode (notify = false)] + 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); + } +} diff --git a/lib/astal/gtk3/src/widget/button.vala b/lib/astal/gtk3/src/widget/button.vala new file mode 100644 index 0000000..2d3095a --- /dev/null +++ b/lib/astal/gtk3/src/widget/button.vala @@ -0,0 +1,111 @@ +/** + * This button has no extra functionality on top if its base [[email protected]] class. + * + * The purpose of this Button subclass is to have a destructable + * struct as the argument in GJS event handlers. + */ +public class Astal.Button : Gtk.Button { + public signal void hover (HoverEvent event); + public signal void hover_lost (HoverEvent event); + public signal void click (ClickEvent event); + public signal void click_release (ClickEvent event); + public signal void scroll (ScrollEvent event); + + construct { + add_events(Gdk.EventMask.SCROLL_MASK); + add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK); + + enter_notify_event.connect((self, event) => { + hover(HoverEvent(event) { lost = false }); + }); + + leave_notify_event.connect((self, event) => { + hover_lost(HoverEvent(event) { lost = true }); + }); + + button_press_event.connect((event) => { + click(ClickEvent(event) { release = false }); + }); + + button_release_event.connect((event) => { + click_release(ClickEvent(event) { release = true }); + }); + + scroll_event.connect((event) => { + scroll(ScrollEvent(event)); + }); + } +} + +public enum Astal.MouseButton { + PRIMARY = 1, + MIDDLE = 2, + SECONDARY = 3, + BACK = 4, + FORWARD = 5, +} + +/** + * Struct for [[email protected]] + */ +public struct Astal.ClickEvent { + bool release; + uint time; + double x; + double y; + Gdk.ModifierType modifier; + MouseButton button; + + public ClickEvent(Gdk.EventButton event) { + this.time = event.time; + this.x = event.x; + this.y = event.y; + this.button = (MouseButton)event.button; + this.modifier = event.state; + } +} + +/** + * Struct for [[email protected]] + */ +public struct Astal.HoverEvent { + bool lost; + uint time; + double x; + double y; + Gdk.ModifierType modifier; + Gdk.CrossingMode mode; + Gdk.NotifyType detail; + + public HoverEvent(Gdk.EventCrossing event) { + this.time = event.time; + this.x = event.x; + this.y = event.y; + this.modifier = event.state; + this.mode = event.mode; + this.detail = event.detail; + } +} + +/** + * Struct for [[email protected]] + */ +public struct Astal.ScrollEvent { + uint time; + double x; + double y; + Gdk.ModifierType modifier; + Gdk.ScrollDirection direction; + double delta_x; + double delta_y; + + public ScrollEvent(Gdk.EventScroll event) { + this.time = event.time; + this.x = event.x; + this.y = event.y; + this.modifier = event.state; + this.direction = event.direction; + this.delta_x = event.delta_x; + this.delta_y = event.delta_y; + } +} diff --git a/lib/astal/gtk3/src/widget/centerbox.vala b/lib/astal/gtk3/src/widget/centerbox.vala new file mode 100644 index 0000000..d74a2c4 --- /dev/null +++ b/lib/astal/gtk3/src/widget/centerbox.vala @@ -0,0 +1,55 @@ +public class Astal.CenterBox : Gtk.Box { + /** + * Corresponds to [[email protected] :orientation]. + */ + [CCode (notify = false)] + public bool vertical { + get { return orientation == Gtk.Orientation.VERTICAL; } + set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; } + } + + construct { + notify["orientation"].connect(() => { + notify_property("vertical"); + }); + } + + static construct { + set_css_name("centerbox"); + } + + private Gtk.Widget _start_widget; + public Gtk.Widget start_widget { + get { return _start_widget; } + set { + if (_start_widget != null) + remove(_start_widget); + + if (value != null) + 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); + + if (value != null) + pack_end(value, true, true, 0); + } + } + + public Gtk.Widget center_widget { + get { return get_center_widget(); } + set { + if (center_widget != null) + remove(center_widget); + + if (value != null) + set_center_widget(value); + } + } +} diff --git a/lib/astal/gtk3/src/widget/circularprogress.vala b/lib/astal/gtk3/src/widget/circularprogress.vala new file mode 100644 index 0000000..a3ecdf1 --- /dev/null +++ b/lib/astal/gtk3/src/widget/circularprogress.vala @@ -0,0 +1,206 @@ +/** + * CircularProgress is a subclass of [[email protected]] which provides a circular progress bar + * with customizable properties such as starting and ending points, + * progress value, and visual features like rounded ends and inversion of progress direction. + */ +public class Astal.CircularProgress : Gtk.Bin { + /** + * The starting point of the progress circle, + * where 0 represents 3 o'clock position or 0° degrees and 1 represents 360°. + */ + public double start_at { get; set; } + + /** + * The cutoff point of the background color of the progress circle. + */ + public double end_at { get; set; } + + /** + * The value which determines the arc of the drawn foreground color. + * Should be a value between 0 and 1. + */ + public double value { get; set; } + + /** + * Inverts the progress direction, making it draw counterclockwise. + */ + public bool inverted { get; set; } + + /** + * Renders rounded ends at both the start and the end of the progress bar. + */ + 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 override 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 override 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 scale_arc_value(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 arc_length = end - start; + if (arc_length < 0) + arc_length += 1; // Adjust for circular representation + + // Calculate the position on the arc based on the percentage value + var scaled = arc_length + value; + + // Ensure the position is between 0 and 1 + return (scaled % 1 + 1) % 1; + } + + 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 override 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(start_at); + var end_background = to_radian(end_at); + var ranged_value = value + start_at; + + var is_circle = is_full_circle(this.start_at, this.end_at); + + if (is_circle) { + // Redefine end_draw in radius to create an accurate full circle + end_background = start_background + 2 * Math.PI; + ranged_value = to_radian(value); + } else { + // Range the value for the arc shape + ranged_value = to_radian(scale_arc_value( + start_at, + end_at, + value + )); + } + + double start_progress, end_progress; + + if (inverted) { + start_progress = end_background - ranged_value; + end_progress = end_background; + } else { + start_progress = start_background; + end_progress = start_background + ranged_value; + } + + // 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 rounded background ends + if (rounded) { + var start_x = center_x + Math.cos(start_background) * radius; + var start_y = center_y + Math.sin(start_background) * radius; + var end_x = center_x + Math.cos(end_background) * radius; + var end_y = center_y + Math.sin(end_background) * radius; + cr.set_line_width(0); + cr.arc(start_x, start_y, bg_stroke / 2, 0, 0 - 0.01); + cr.fill(); + cr.arc(end_x, end_y, bg_stroke / 2, 0, 0 - 0.01); + cr.fill(); + } + + // 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 progress ends + if (rounded) { + var start_x = center_x + Math.cos(start_progress) * radius; + var start_y = center_y + Math.sin(start_progress) * radius; + var end_x = center_x + Math.cos(end_progress) * radius; + var end_y = center_y + Math.sin(end_progress) * 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 (get_child() != null) { + get_child().size_allocate(allocation); + propagate_draw(get_child(), cr); + } + + return true; + } +} diff --git a/lib/astal/gtk3/src/widget/eventbox.vala b/lib/astal/gtk3/src/widget/eventbox.vala new file mode 100644 index 0000000..0b588e9 --- /dev/null +++ b/lib/astal/gtk3/src/widget/eventbox.vala @@ -0,0 +1,73 @@ +/** + * EventBox is a [[email protected]] subclass which is meant to fix an issue with its + * [[email protected]::enter_notify_event] and [[email protected]::leave_notify_event] when nesting EventBoxes + * + * Its css selector is `eventbox`. + */ +public class Astal.EventBox : Gtk.EventBox { + public signal void hover (HoverEvent event); + public signal void hover_lost (HoverEvent event); + public signal void click (ClickEvent event); + public signal void click_release (ClickEvent event); + public signal void scroll (ScrollEvent event); + public signal void motion (MotionEvent event); + + static construct { + set_css_name("eventbox"); + } + + construct { + add_events(Gdk.EventMask.SCROLL_MASK); + add_events(Gdk.EventMask.SMOOTH_SCROLL_MASK); + add_events(Gdk.EventMask.POINTER_MOTION_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(HoverEvent(event) { lost = false }); + } + }); + + 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(HoverEvent(event) { lost = true }); + } + }); + + button_press_event.connect((event) => { + click(ClickEvent(event) { release = false }); + }); + + button_release_event.connect((event) => { + click_release(ClickEvent(event) { release = true }); + }); + + scroll_event.connect((event) => { + scroll(ScrollEvent(event)); + }); + + motion_notify_event.connect((event) => { + motion(MotionEvent(event)); + }); + } +} + +/** + * Struct for [[email protected]] + */ +public struct Astal.MotionEvent { + uint time; + double x; + double y; + Gdk.ModifierType modifier; + + public MotionEvent(Gdk.EventMotion event) { + this.time = event.time; + this.x = event.x; + this.y = event.y; + this.modifier = event.state; + } +} diff --git a/lib/astal/gtk3/src/widget/icon.vala b/lib/astal/gtk3/src/widget/icon.vala new file mode 100644 index 0000000..9a20359 --- /dev/null +++ b/lib/astal/gtk3/src/widget/icon.vala @@ -0,0 +1,115 @@ +/** + * [[email protected]] subclass meant to be used only for icons. + * + * It's size is calculated from `font-size` css property. + * Its css selector is `icon`. + */ +public class Astal.Icon : Gtk.Image { + private IconType type = IconType.NAMED; + private double size { get; set; default = 14; } + + public new Gdk.Pixbuf pixbuf { get; set; } + public GLib.Icon g_icon { get; set; } + + /** + * Either a named icon or a path to a file. + */ + public string icon { get; set; default = ""; } + + public static Gtk.IconInfo? lookup_icon(string icon) { + var theme = Gtk.IconTheme.get_default(); + return theme.lookup_icon(icon, 16, Gtk.IconLookupFlags.USE_BUILTIN); + } + + private async void display_icon() { + switch(type) { + case IconType.NAMED: + icon_name = icon; + pixel_size = (int)size; + break; + case IconType.FILE: + try { + var file = File.new_for_path(icon); + var stream = yield file.read_async(); + var pb = yield new Gdk.Pixbuf.from_stream_at_scale_async( + stream, + (int)size * scale_factor, + (int)size * scale_factor, + true, + null + ); + 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; + case IconType.GICON: + pixel_size = (int)size; + gicon = g_icon; + break; + + } + } + + static construct { + set_css_name("icon"); + } + + 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.begin(); + }); + + notify["pixbuf"].connect(() => { + type = IconType.PIXBUF; + display_icon.begin(); + }); + + notify["g-icon"].connect(() => { + type = IconType.GICON; + display_icon.begin(); + }); + + size_allocate.connect(() => { + size = get_style_context() + .get_property("font-size", Gtk.StateFlags.NORMAL).get_double(); + + display_icon.begin(); + }); + + get_style_context().changed.connect(() => { + size = get_style_context() + .get_property("font-size", Gtk.StateFlags.NORMAL).get_double(); + + display_icon.begin(); + }); + } +} + +private enum Astal.IconType { + NAMED, + FILE, + PIXBUF, + GICON, +} diff --git a/lib/astal/gtk3/src/widget/label.vala b/lib/astal/gtk3/src/widget/label.vala new file mode 100644 index 0000000..899cba9 --- /dev/null +++ b/lib/astal/gtk3/src/widget/label.vala @@ -0,0 +1,24 @@ +using Pango; + +public class Astal.Label : Gtk.Label { + /** + * Shortcut for setting [[email protected]:ellipsize] to [[email protected]] + */ + public bool truncate { + set { ellipsize = value ? EllipsizeMode.END : EllipsizeMode.NONE; } + get { return ellipsize == EllipsizeMode.END; } + } + + /** + * Shortcut for setting [[email protected]:justify] to [[email protected]] + */ + public new bool justify_fill { + set { justify = value ? Gtk.Justification.FILL : Gtk.Justification.LEFT; } + get { return justify == Gtk.Justification.FILL; } + } + + construct { + notify["ellipsize"].connect(() => notify_property("truncate")); + notify["justify"].connect(() => notify_property("justify_fill")); + } +} diff --git a/lib/astal/gtk3/src/widget/levelbar.vala b/lib/astal/gtk3/src/widget/levelbar.vala new file mode 100644 index 0000000..3e98afb --- /dev/null +++ b/lib/astal/gtk3/src/widget/levelbar.vala @@ -0,0 +1,16 @@ +public class Astal.LevelBar : Gtk.LevelBar { + /** + * Corresponds to [[email protected] :orientation]. + */ + [CCode (notify = false)] + public bool vertical { + get { return orientation == Gtk.Orientation.VERTICAL; } + set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; } + } + + construct { + notify["orientation"].connect(() => { + notify_property("vertical"); + }); + } +} diff --git a/lib/astal/gtk3/src/widget/overlay.vala b/lib/astal/gtk3/src/widget/overlay.vala new file mode 100644 index 0000000..ed5f03b --- /dev/null +++ b/lib/astal/gtk3/src/widget/overlay.vala @@ -0,0 +1,65 @@ +public class Astal.Overlay : Gtk.Overlay { + public bool pass_through { get; set; } + + /** + * First [[email protected]:overlays] element. + * + * WARNING: setting this value will remove every overlay but the first. + */ + public Gtk.Widget? overlay { + get { return overlays.nth_data(0); } + set { + foreach (var ch in get_children()) { + if (ch != child) + remove(ch); + } + + if (value != null) + add_overlay(value); + } + } + + /** + * Sets the overlays of this Overlay. [[email protected]_overlay]. + */ + public List<weak Gtk.Widget> overlays { + owned get { return get_children(); } + set { + foreach (var ch in get_children()) { + if (ch != child) + remove(ch); + } + + foreach (var ch in value) + add_overlay(ch); + } + } + + public new Gtk.Widget? child { + get { return get_child(); } + set { + var ch = get_child(); + if (ch != null) + remove(ch); + + if (value != null) + add(value); + } + } + + construct { + notify["pass-through"].connect(() => { + update_pass_through(); + }); + } + + private void update_pass_through() { + foreach (var child in get_children()) + set_overlay_pass_through(child, pass_through); + } + + public new void add_overlay(Gtk.Widget widget) { + base.add_overlay(widget); + set_overlay_pass_through(widget, pass_through); + } +} diff --git a/lib/astal/gtk3/src/widget/scrollable.vala b/lib/astal/gtk3/src/widget/scrollable.vala new file mode 100644 index 0000000..57a440c --- /dev/null +++ b/lib/astal/gtk3/src/widget/scrollable.vala @@ -0,0 +1,48 @@ +/** + * Subclass of [[email protected]] which has its policy default to + * [[email protected]]. + * + * Its css selector is `scrollable`. + * Its child getter returns the child of the inner + * [[email protected]], instead of the viewport. + */ +public class Astal.Scrollable : Gtk.ScrolledWindow { + private Gtk.PolicyType _hscroll = Gtk.PolicyType.AUTOMATIC; + private Gtk.PolicyType _vscroll = Gtk.PolicyType.AUTOMATIC; + + public Gtk.PolicyType hscroll { + get { return _hscroll; } + set { + _hscroll = value; + set_policy(value, vscroll); + } + } + + public Gtk.PolicyType vscroll { + get { return _vscroll; } + set { + _vscroll = value; + set_policy(hscroll, value); + } + } + + static construct { + set_css_name("scrollable"); + } + + construct { + if (hadjustment != null) + hadjustment = new Gtk.Adjustment(0,0,0,0,0,0); + + if (vadjustment != null) + vadjustment = new Gtk.Adjustment(0,0,0,0,0,0); + } + + public new Gtk.Widget get_child() { + var ch = base.get_child(); + if (ch is Gtk.Viewport) { + return ch.get_child(); + } + return ch; + } +} diff --git a/lib/astal/gtk3/src/widget/slider.vala b/lib/astal/gtk3/src/widget/slider.vala new file mode 100644 index 0000000..97cfb69 --- /dev/null +++ b/lib/astal/gtk3/src/widget/slider.vala @@ -0,0 +1,94 @@ +/** + * Subclass of [[email protected]] which adds a signal and property for the drag state. + */ +public class Astal.Slider : Gtk.Scale { + /** + * Corresponds to [[email protected] :orientation]. + */ + [CCode (notify = false)] + public bool vertical { + get { return orientation == Gtk.Orientation.VERTICAL; } + set { orientation = value ? Gtk.Orientation.VERTICAL : Gtk.Orientation.HORIZONTAL; } + } + + /** + * Emitted when the user drags the slider or uses keyboard arrows and its value changes. + */ + public signal void dragged(); + + construct { + draw_value = false; + + if (adjustment == null) + adjustment = new Gtk.Adjustment(0,0,0,0,0,0); + + if (max == 0 && min == 0) { + max = 1; + } + + if (step == 0) { + step = 0.05; + } + + notify["orientation"].connect(() => { + notify_property("vertical"); + }); + + button_press_event.connect(() => { dragging = true; }); + key_press_event.connect(() => { dragging = true; }); + button_release_event.connect(() => { dragging = false; }); + key_release_event.connect(() => { dragging = false; }); + scroll_event.connect((event) => { + dragging = true; + if (event.delta_y > 0) + value -= step; + else + value += step; + dragging = false; + }); + + value_changed.connect(() => { + if (dragging) + dragged(); + }); + } + + /** + * `true` when the user drags the slider or uses keyboard arrows. + */ + public bool dragging { get; private set; } + + /** + * Value of this slider. Defaults to `0`. + */ + public double value { + get { return adjustment.value; } + set { if (!dragging) adjustment.value = value; } + } + + /** + * Minimum possible value of this slider. Defaults to `0`. + */ + public double min { + get { return adjustment.lower; } + set { adjustment.lower = value; } + } + + /** + * Maximum possible value of this slider. Defaults to `1`. + */ + public double max { + get { return adjustment.upper; } + set { adjustment.upper = value; } + } + + /** + * Size of step increments. Defaults to `0.05`. + */ + public double step { + get { return adjustment.step_increment; } + set { adjustment.step_increment = value; } + } + + // TODO: marks +} diff --git a/lib/astal/gtk3/src/widget/stack.vala b/lib/astal/gtk3/src/widget/stack.vala new file mode 100644 index 0000000..4e856a6 --- /dev/null +++ b/lib/astal/gtk3/src/widget/stack.vala @@ -0,0 +1,40 @@ +/** + * Subclass of [[email protected]] that has a children setter which + * invokes [[email protected]_named] with the child's [[email protected]:name] property. + */ +public class Astal.Stack : Gtk.Stack { + /** + * Same as [[email protected]:visible-child-name]. + */ + [CCode (notify = false)] + public string shown { + get { return visible_child_name; } + set { visible_child_name = value; } + } + + public List<weak Gtk.Widget> children { + set { _set_children(value); } + owned get { return get_children(); } + } + + private void _set_children(List<weak Gtk.Widget> arr) { + foreach(var child in get_children()) { + remove(child); + } + + var i = 0; + foreach(var child in arr) { + if (child.name != null) { + add_named(child, child.name); + } else { + add_named(child, (++i).to_string()); + } + } + } + + construct { + notify["visible_child_name"].connect(() => { + notify_property("shown"); + }); + } +} diff --git a/lib/astal/gtk3/src/widget/widget.vala b/lib/astal/gtk3/src/widget/widget.vala new file mode 100644 index 0000000..2506bc8 --- /dev/null +++ b/lib/astal/gtk3/src/widget/widget.vala @@ -0,0 +1,157 @@ +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 = true +) { + 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); +} + +private class ClickThrough { + private static HashTable<Gtk.Widget, bool> _click_through; + public static HashTable<Gtk.Widget, bool> click_through { + get { + if (_click_through == null) { + _click_through = new HashTable<Gtk.Widget, bool>( + (w) => (uint)w, + (a, b) => a == b); + } + return _click_through; + } + } +} + +public void widget_set_click_through(Gtk.Widget widget, bool click_through) { + ClickThrough.click_through.set(widget, click_through); + widget.input_shape_combine_region(click_through ? new Cairo.Region() : null); +} + +public bool widget_get_click_through(Gtk.Widget widget) { + return ClickThrough.click_through.get(widget); +} +} diff --git a/lib/astal/gtk3/src/widget/window.vala b/lib/astal/gtk3/src/widget/window.vala new file mode 100644 index 0000000..9287200 --- /dev/null +++ b/lib/astal/gtk3/src/widget/window.vala @@ -0,0 +1,293 @@ +using GtkLayerShell; + +[Flags] +public enum Astal.WindowAnchor { + NONE, + TOP, + RIGHT, + LEFT, + BOTTOM, +} + +public enum Astal.Exclusivity { + NORMAL, + /** + * Request the compositor to allocate space for this window. + */ + EXCLUSIVE, + /** + * Request the compositor to stack layers on top of each other. + */ + IGNORE, +} + +public enum Astal.Layer { + BACKGROUND = 0, // GtkLayerShell.Layer.BACKGROUND + BOTTOM = 1, // GtkLayerShell.Layer.BOTTOM + TOP = 2, // GtkLayerShell.Layer.TOP + OVERLAY = 3, // GtkLayerShell.Layer.OVERLAY +} + +public enum Astal.Keymode { + /** + * Window should not receive keyboard events. + */ + NONE = 0, // GtkLayerShell.KeyboardMode.NONE + /** + * Window should have exclusive focus if it is on the top or overlay layer. + */ + EXCLUSIVE = 1, // GtkLayerShell.KeyboardMode.EXCLUSIVE + /** + * Focus and Unfocues the window as needed. + */ + ON_DEMAND = 2, // GtkLayerShell.KeyboardMode.ON_DEMAND +} + +/** + * Subclass of [[email protected]] which integrates GtkLayerShell as class fields. + */ +public class Astal.Window : Gtk.Window { + private static bool check(string action) { + if (!is_supported()) { + critical(@"can not $action on window: layer shell not supported"); + print("tip: running from an xwayland terminal can cause this, for example VsCode"); + return true; + } + return false; + } + + private InhibitManager? inhibit_manager; + private Inhibitor? inhibitor; + + construct { + if (check("initialize layer shell")) + return; + + // If the window has no size allocatoted when it gets mapped. + // It won't show up later either when it size changes by adding children. + height_request = 1; + width_request = 1; + + init_for_window(this); + inhibit_manager = InhibitManager.get_default(); + } + + /** + * When `true` it will permit inhibiting the idle behavior such as screen blanking, locking, and screensaving. + */ + public bool inhibit { + set { + if (inhibit_manager == null) { + return; + } + if (value && inhibitor == null) { + inhibitor = inhibit_manager.inhibit(this); + } + else if (!value && inhibitor != null) { + inhibitor = null; + } + } + get { + return inhibitor != null; + } + } + + public override void show() { + base.show(); + if(inhibit) { + inhibitor = inhibit_manager.inhibit(this); + } + } + + /** + * Namespace of this window. This can be used to target the layer in compositor rules. + */ + public string namespace { + get { return get_namespace(this); } + set { set_namespace(this, value); } + } + + /** + * Edges to anchor the window to. + * + * If two perpendicular edges are anchored, the surface will be anchored to that corner. + * If two opposite edges are anchored, the window will be stretched across the screen in that direction. + */ + public WindowAnchor anchor { + set { + if (check("set anchor")) + return; + + 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; + } + } + + /** + * Exclusivity of this window. + */ + public Exclusivity exclusivity { + set { + if (check("set exclusivity")) + return; + + 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; + } + } + + /** + * Which layer to appear this window on. + */ + public Layer layer { + get { return (Layer)get_layer(this); } + set { + if (check("set layer")) + return; + + set_layer(this, (GtkLayerShell.Layer)value); + } + } + + /** + * Keyboard mode of this window. + */ + public Keymode keymode { + get { return (Keymode)get_keyboard_mode(this); } + set { + if (check("set keymode")) + return; + + set_keyboard_mode(this, (GtkLayerShell.KeyboardMode)value); + } + } + + /** + * Which monitor to appear this window on. + */ + public Gdk.Monitor gdkmonitor { + get { return get_monitor(this); } + set { + if (check("set gdkmonitor")) + return; + + set_monitor (this, value); + } + } + + public new int margin_top { + get { return GtkLayerShell.get_margin(this, Edge.TOP); } + set { + if (check("set margin_top")) + return; + + GtkLayerShell.set_margin(this, Edge.TOP, value); + } + } + + public new int margin_bottom { + get { return GtkLayerShell.get_margin(this, Edge.BOTTOM); } + set { + if (check("set margin_bottom")) + return; + + GtkLayerShell.set_margin(this, Edge.BOTTOM, value); + } + } + + public new int margin_left { + get { return GtkLayerShell.get_margin(this, Edge.LEFT); } + set { + if (check("set margin_left")) + return; + + GtkLayerShell.set_margin(this, Edge.LEFT, value); + } + } + + public new int margin_right { + get { return GtkLayerShell.get_margin(this, Edge.RIGHT); } + set { + if (check("set margin_right")) + return; + + GtkLayerShell.set_margin(this, Edge.RIGHT, value); + } + } + + public new int margin { + set { + if (check("set margin")) + return; + + margin_top = value; + margin_right = value; + margin_bottom = value; + margin_left = value; + } + } + + /** + * Which monitor to appear this window on. + * + * CAUTION: the id might not be the same mapped by the compositor. + */ + public int monitor { + set { + if (check("set monitor")) + return; + + 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; + } + } +} diff --git a/lib/astal/gtk3/version b/lib/astal/gtk3/version new file mode 100644 index 0000000..4a36342 --- /dev/null +++ b/lib/astal/gtk3/version @@ -0,0 +1 @@ +3.0.0 diff --git a/lib/astal/io/application.vala b/lib/astal/io/application.vala new file mode 100644 index 0000000..09b61b5 --- /dev/null +++ b/lib/astal/io/application.vala @@ -0,0 +1,186 @@ +namespace AstalIO { +public errordomain AppError { + NAME_OCCUPIED, + TAKEOVER_FAILED, +} + +/** + * This interface is used as a placeholder for the Astal Application class. + * It is not meant to be used by consumers. + */ +public interface Application : Object { + public abstract void quit() throws Error; + public abstract void inspector() throws Error; + public abstract void toggle_window(string window) throws Error; + + public abstract string instance_name { owned get; construct set; } + public abstract void acquire_socket() throws Error; + public virtual void request(string msg, SocketConnection conn) throws Error { + write_sock.begin(conn, @"missing response implementation on $instance_name"); + } +} + +/** + * Starts a [[email protected]] and binds `XDG_RUNTIME_DIR/astal/<instance_name>.sock`. + * This socket is then used by the astal cli. Not meant for public usage, but for [[email protected]_socket]. + */ +public SocketService acquire_socket(Application app, out string sock) throws Error { + var name = app.instance_name; + foreach (var instance in get_instances()) { + if (instance == name) { + throw new AppError.NAME_OCCUPIED(@"$name is occupied"); + } + } + + var rundir = Environment.get_user_runtime_dir(); + var dir = @"$rundir/astal"; + var path = @"$dir/$name.sock"; + sock = path; + + if (!FileUtils.test(dir, FileTest.IS_DIR)) { + File.new_for_path(path).make_directory_with_parents(null); + } + + if (FileUtils.test(path, FileTest.EXISTS)) { + try { + File.new_for_path(path).delete(null); + } catch (Error err) { + throw new AppError.TAKEOVER_FAILED("could not delete previous socket"); + } + } + + var service = new SocketService(); + service.add_address( + new UnixSocketAddress(path), + SocketType.STREAM, + SocketProtocol.DEFAULT, + null, + null + ); + + service.incoming.connect((conn) => { + read_sock.begin(conn, (_, res) => { + try { + string message = read_sock.end(res); + app.request(message != null ? message.strip() : "", conn); + } catch (Error err) { + critical(err.message); + } + }); + return false; + }); + + return service; +} + +/** + * Get a list of running Astal.Application instances. + * It is the equivalent of `astal --list`. + */ +public static List<string> get_instances() { + var list = new List<string>(); + var prefix = "io.Astal."; + + try { + DBusImpl dbus = Bus.get_proxy_sync( + BusType.SESSION, + "org.freedesktop.DBus", + "/org/freedesktop/DBus" + ); + + foreach (var busname in dbus.list_names()) { + if (busname.has_prefix(prefix)) + list.append(busname.replace(prefix, "")); + } + } catch (Error err) { + critical(err.message); + } + + return list; +} + +/** + * Quit an an Astal instances. + * It is the equivalent of `astal --quit -i instance`. + */ +public static void quit_instance(string instance) throws Error { + IApplication proxy = Bus.get_proxy_sync( + BusType.SESSION, + "io.Astal." + instance, + "/io/Astal/Application" + ); + + proxy.quit(); +} + +/** + * Open the Gtk debug tool of an an Astal instances. + * It is the equivalent of `astal --inspector -i instance`. + */ +public static void open_inspector(string instance) throws Error { + IApplication proxy = Bus.get_proxy_sync( + BusType.SESSION, + "io.Astal." + instance, + "/io/Astal/Application" + ); + + proxy.inspector(); +} + +/** + * Toggle a Window of an Astal instances. + * It is the equivalent of `astal -i instance --toggle window`. + */ +public static void toggle_window_by_name(string instance, string window) throws Error { + IApplication proxy = Bus.get_proxy_sync( + BusType.SESSION, + "io.Astal." + instance, + "/io/Astal/Application" + ); + + proxy.toggle_window(window); +} + +/** + * Send a message to an Astal instances. + * It is the equivalent of `astal -i instance content of the message`. + */ +public static string send_message(string instance, string msg) throws Error { + var rundir = Environment.get_user_runtime_dir(); + var socket_path = @"$rundir/astal/$instance.sock"; + var client = new SocketClient(); + + var conn = client.connect(new UnixSocketAddress(socket_path), null); + conn.output_stream.write(msg.concat("\x04").data); + + var stream = new DataInputStream(conn.input_stream); + return stream.read_upto("\x04", -1, null, null); +} + +/** + * Read the socket of an Astal.Application instance. + */ +public async string read_sock(SocketConnection conn) throws IOError { + var stream = new DataInputStream(conn.input_stream); + return yield stream.read_upto_async("\x04", -1, Priority.DEFAULT, null, null); +} + +/** + * Write the socket of an Astal.Application instance. + */ +public async void write_sock(SocketConnection conn, string response) throws IOError { + yield conn.output_stream.write_async(@"$response\x04".data, Priority.DEFAULT); +} + +[DBus (name="io.Astal.Application")] +private interface IApplication : DBusProxy { + public abstract void quit() throws GLib.Error; + public abstract void inspector() throws GLib.Error; + public abstract void toggle_window(string window) throws GLib.Error; +} + +[DBus (name="org.freedesktop.DBus")] +private interface DBusImpl : DBusProxy { + public abstract string[] list_names() throws Error; +} +} diff --git a/lib/astal/io/cli.vala b/lib/astal/io/cli.vala new file mode 100644 index 0000000..f69cf0b --- /dev/null +++ b/lib/astal/io/cli.vala @@ -0,0 +1,104 @@ +static bool version; +static bool help; +static bool list; +static bool quit; +static bool inspector; +static string? toggle_window; +static string? instance_name; + +const OptionEntry[] options = { + { "version", 'v', OptionFlags.NONE, OptionArg.NONE, ref version, null, null }, + { "help", 'h', OptionFlags.NONE, OptionArg.NONE, ref help, null, null }, + { "list", 'l', OptionFlags.NONE, OptionArg.NONE, ref list, null, null }, + { "quit", 'q', OptionFlags.NONE, OptionArg.NONE, ref quit, null, null }, + { "inspector", 'I', OptionFlags.NONE, OptionArg.NONE, ref inspector, null, null }, + { "toggle-window", 't', OptionFlags.NONE, OptionArg.STRING, ref toggle_window, null, null }, + { "instance", 'i', OptionFlags.NONE, OptionArg.STRING, ref instance_name, null, null }, + { null }, +}; + +int err(string msg) { + var red = "\x1b[31m"; + var r = "\x1b[0m"; + printerr(@"$(red)error: $(r)$msg"); + return 1; +} + +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 e) { + return err(e.message); + } + + if (help) { + print("Client for Astal.Application instances\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(" -l, --list List running Astal instances and exit\n"); + print(" -q, --quit Quit an Astal.Application instance\n"); + print(" -i, --instance Instance name of the Astal instance\n"); + print(" -I, --inspector Open up Gtk debug tool\n"); + print(" -t, --toggle-window Show or hide a window\n"); + return 0; + } + + if (version) { + print(AstalIO.VERSION); + return 0; + } + + if (instance_name == null) + instance_name = "astal"; + + if (list) { + foreach (var name in AstalIO.get_instances()) + print(@"$name\n"); + + return 0; + } + + try { + if (quit) { + AstalIO.quit_instance(instance_name); + return 0; + } + + if (inspector) { + AstalIO.open_inspector(instance_name); + return 0; + } + + if (toggle_window != null) { + AstalIO.toggle_window_by_name(instance_name, toggle_window); + return 0; + } + } catch (DBusError.SERVICE_UNKNOWN e) { + return err(@"there is no \"$instance_name\" instance runnning"); + } catch (Error e) { + return err(e.message); + } + + var request = ""; + for (var i = 1; i < argv.length; ++i) { + request = request.concat(" ", argv[i]); + } + + try { + var reply = AstalIO.send_message(instance_name, request); + print("%s\n", reply); + } catch (IOError.NOT_FOUND e) { + return err(@"there is no \"$instance_name\" instance runnning"); + } catch (Error e) { + return err(e.message); + } + + return 0; +} diff --git a/lib/astal/io/config.vala.in b/lib/astal/io/config.vala.in new file mode 100644 index 0000000..fe1e450 --- /dev/null +++ b/lib/astal/io/config.vala.in @@ -0,0 +1,6 @@ +namespace AstalIO { + public const int MAJOR_VERSION = @MAJOR_VERSION@; + public const int MINOR_VERSION = @MINOR_VERSION@; + public const int MICRO_VERSION = @MICRO_VERSION@; + public const string VERSION = "@VERSION@"; +} diff --git a/lib/astal/io/file.vala b/lib/astal/io/file.vala new file mode 100644 index 0000000..57b6dc0 --- /dev/null +++ b/lib/astal/io/file.vala @@ -0,0 +1,98 @@ +namespace AstalIO { +/** + * Read the contents of a file synchronously. + */ +public string read_file(string path) { + var str = ""; + try { + FileUtils.get_contents(path, out str, null); + } catch (Error error) { + critical(error.message); + } + return str; +} + +/** + * Read the contents of a file asynchronously. + */ +public async string read_file_async(string path) throws Error { + uint8[] content; + yield File.new_for_path(path).load_contents_async(null, out content, null); + return (string)content; +} + +/** + * Write content to a file synchronously. + */ +public void write_file(string path, string content) { + try { + FileUtils.set_contents(path, content); + } catch (Error error) { + critical(error.message); + } +} + +/** + * Write content to a file asynchronously. + */ +public async void write_file_async(string path, string content) throws Error { + yield File.new_for_path(path).replace_contents_async( + content.data, + null, + false, + FileCreateFlags.REPLACE_DESTINATION, + null, + null); +} + +/** + * Monitor a file for changes. If the path is a directory, monitor it recursively. + * The callback will be called passed two parameters: the path of the file + * that changed and an [[email protected]] indicating the reason. + */ +public FileMonitor? monitor_file(string path, Closure callback) { + try { + var file = File.new_for_path(path); + var mon = file.monitor(FileMonitorFlags.NONE); + + mon.changed.connect((file, _file, event) => { + var f = Value(Type.STRING); + var e = Value(Type.INT); + var ret = Value(Type.POINTER); + + f.set_string(file.get_path()); + e.set_int(event); + + callback.invoke(ref ret, { f, e }); + }); + + if (FileUtils.test(path, FileTest.IS_DIR)) { + var enumerator = file.enumerate_children("standard::*", + FileQueryInfoFlags.NONE, null); + + var i = enumerator.next_file(null); + while (i != null) { + if (i.get_file_type() == FileType.DIRECTORY) { + var filepath = file.get_child(i.get_name()).get_path(); + if (filepath != null) { + var m = monitor_file(path, callback); + mon.notify["cancelled"].connect(() => { + m.cancel(); + }); + } + } + i = enumerator.next_file(null); + } + } + + mon.ref(); + mon.notify["cancelled"].connect(() => { + mon.unref(); + }); + return mon; + } catch (Error error) { + critical(error.message); + return null; + } +} +} diff --git a/lib/astal/io/gir.py b/lib/astal/io/gir.py new file mode 100644 index 0000000..9ef680f --- /dev/null +++ b/lib/astal/io/gir.py @@ -0,0 +1,58 @@ +""" +Vala's generated gir does not contain comments, +so we use valadoc to generate them. However, they are formatted +for valadoc and not gi-docgen so we need to fix it. +""" + +import xml.etree.ElementTree as ET +import html +import sys +import subprocess + + +def fix_gir(name: str, gir: str, out: str): + namespaces = { + "": "http://www.gtk.org/introspection/core/1.0", + "c": "http://www.gtk.org/introspection/c/1.0", + "glib": "http://www.gtk.org/introspection/glib/1.0", + } + for prefix, uri in namespaces.items(): + ET.register_namespace(prefix, uri) + + tree = ET.parse(gir) + root = tree.getroot() + + for doc in root.findall(".//doc", namespaces): + if doc.text: + doc.text = ( + html.unescape(doc.text).replace("<para>", "").replace("</para>", "") + ) + + if (inc := root.find("c:include", namespaces)) is not None: + inc.set("name", f"{name}.h") + else: + print("no c:include tag found", file=sys.stderr) + exit(1) + + tree.write(out, encoding="utf-8", xml_declaration=True) + + +def valadoc(name: str, gir: str, args: list[str]): + cmd = ["valadoc", "-o", "docs", "--package-name", name, "--gir", gir, *args] + try: + subprocess.run(cmd, check=True, text=True, capture_output=True) + except subprocess.CalledProcessError as e: + print(e.stderr, file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + name = sys.argv[1] + in_out = sys.argv[2].split(":") + args = sys.argv[3:] + + gir = in_out[0] + out = in_out[1] if len(in_out) > 1 else gir + + valadoc(name, gir, args) + fix_gir(name, gir, out) diff --git a/lib/astal/io/meson.build b/lib/astal/io/meson.build new file mode 100644 index 0000000..023dece --- /dev/null +++ b/lib/astal/io/meson.build @@ -0,0 +1,106 @@ +project( + 'astal-io', + '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', + ], +) + +version_split = meson.project_version().split('.') +api_version = version_split[0] + '.' + version_split[1] +gir = 'AstalIO-' + api_version + '.gir' +typelib = 'AstalIO-' + api_version + '.typelib' +libdir = get_option('prefix') / get_option('libdir') +pkgdatadir = get_option('prefix') / get_option('datadir') / 'astal' + +config = configure_file( + input: 'config.vala.in', + output: 'config.vala', + configuration: { + 'VERSION': meson.project_version(), + 'MAJOR_VERSION': version_split[0], + 'MINOR_VERSION': version_split[1], + 'MICRO_VERSION': version_split[2], + }, +) + +deps = [ + dependency('glib-2.0'), + dependency('gio-unix-2.0'), + dependency('gobject-2.0'), + dependency('gio-2.0'), +] + +sources = [config] + files( + 'application.vala', + 'file.vala', + 'process.vala', + 'time.vala', + 'variable.vala', +) + +lib = library( + meson.project_name(), + sources, + dependencies: deps, + vala_args: ['--vapi-comments'], + vala_header: meson.project_name() + '.h', + vala_vapi: meson.project_name() + '-' + api_version + '.vapi', + version: meson.project_version(), + install: true, + install_dir: [true, true, true], +) + +pkgs = [] +foreach dep : deps + pkgs += ['--pkg=' + dep.name()] +endforeach + +gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', +) + +custom_target( + typelib, + command: [ + find_program('g-ir-compiler'), + '--output', '@OUTPUT@', + '--shared-library', get_option('prefix') / get_option('libdir') / '@PLAINNAME@', + meson.current_build_dir() / gir, + ], + input: lib, + output: typelib, + depends: [lib, gir_tgt], + install: true, + install_dir: get_option('libdir') / 'girepository-1.0', +) + +import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', +) + +executable( + 'astal', + ['cli.vala', sources], + dependencies: deps, + install: true, +) diff --git a/lib/astal/io/process.vala b/lib/astal/io/process.vala new file mode 100644 index 0000000..cfd05b9 --- /dev/null +++ b/lib/astal/io/process.vala @@ -0,0 +1,172 @@ +/** + * `Process` provides shortcuts for [[email protected]] with sane defaults. + */ +public class AstalIO.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; } + + + /** + * When the underlying subprocess writes to its stdout + * this signal is emitted with that line. + */ + public signal void stdout (string out); + + /** + * When the underlying subprocess writes to its stderr + * this signal is emitted with that line. + */ + public signal void stderr (string err); + + /** + * Force quit the subprocess. + */ + public void kill() { + process.force_exit(); + } + + /** + * Send a signal to the subprocess. + */ + public void signal(int signal_num) { + process.send_signal(signal_num); + } + + /** + * Write a line to the subprocess' stdin synchronously. + */ + public void write(string in) throws Error { + in_stream.put_string(in); + } + + /** + * Write a line to the subprocess' stdin asynchronously. + */ + public async void write_async(string in) { + try { + yield in_stream.write_all_async(in.data, in.data.length, null, null); + } catch (Error err) { + printerr("%s\n", err.message); + } + } + + /** + * Start a new subprocess with the given command. + * + * The first element of the vector is executed with the remaining elements as the argument list. + */ + 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); + } + + /** + * Start a new subprocess with the given command + * which is parsed using [[email protected]_parse_argv]. + */ + public static Process subprocess(string cmd) throws Error { + string[] argv; + Shell.parse_argv(cmd, out argv); + return new Process.subprocessv(argv); + } + + /** + * Execute a command synchronously. + * The first element of the vector is executed with the remaining elements as the argument list. + * + * @return stdout of the subprocess + */ + 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 IOError.FAILED(err_str.strip()); + } + + /** + * Execute a command synchronously. + * The command is parsed using [[email protected]_parse_argv]. + * + * @return stdout of the subprocess + */ + public static string exec(string cmd) throws Error { + string[] argv; + Shell.parse_argv(cmd, out argv); + return Process.execv(argv); + } + + /** + * Execute a command asynchronously. + * The first element of the vector is executed with the remaining elements as the argument list. + * + * @return stdout of the subprocess + */ + public static async string exec_asyncv(string[] cmd) throws Error { + var process = new Subprocess.newv( + cmd, + SubprocessFlags.STDERR_PIPE | + SubprocessFlags.STDOUT_PIPE + ); + + string err_str, out_str; + yield process.communicate_utf8_async(null, null, out out_str, out err_str); + var success = process.get_successful(); + process.dispose(); + if (success) + return out_str.strip(); + else + throw new IOError.FAILED(err_str.strip()); + } + + /** + * Execute a command asynchronously. + * The command is parsed using [[email protected]_parse_argv]. + * + * @return stdout of the subprocess + */ + public static async string exec_async(string cmd) throws Error { + string[] argv; + Shell.parse_argv(cmd, out argv); + return yield exec_asyncv(argv); + } +} diff --git a/lib/astal/io/time.vala b/lib/astal/io/time.vala new file mode 100644 index 0000000..a799f2b --- /dev/null +++ b/lib/astal/io/time.vala @@ -0,0 +1,111 @@ +/** + * `Time` provides shortcuts for GLib timeout functions. + */ +public class AstalIO.Time : Object { + private Cancellable cancellable; + private uint timeout_id; + private bool fulfilled = false; + + /** + * Emitted when the timer ticks. + */ + public signal void now (); + + /** + * Emitted when the timere is cancelled. + */ + public signal void cancelled (); + + construct { + cancellable = new Cancellable(); + cancellable.cancelled.connect(() => { + if (!fulfilled) { + 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, {}); + }); + } + + /** + * Start an interval timer with default Priority. + */ + 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); + } + + /** + * Start a timeout timer with default Priority. + */ + public Time.timeout_prio(uint timeout, int prio = Priority.DEFAULT, Closure? fn) { + connect_closure(fn); + timeout_id = Timeout.add(timeout, () => { + now(); + fulfilled = true; + return Source.REMOVE; + }, prio); + } + + /** + * Start an idle timer with default priority. + */ + public Time.idle_prio(int prio = Priority.DEFAULT_IDLE, Closure? fn) { + connect_closure(fn); + timeout_id = Idle.add(() => { + now(); + fulfilled = true; + return Source.REMOVE; + }, prio); + } + + /** + * Start an interval timer. Ticks immediately then every `interval` milliseconds. + * + * @param interval Tick every milliseconds. + * @param fn Optional callback. + */ + public static Time interval(uint interval, Closure? fn) { + return new Time.interval_prio(interval, Priority.DEFAULT, fn); + } + + /** + * Start a timeout timer which ticks after `timeout` milliseconds. + * + * @param timeout Tick after milliseconds. + * @param fn Optional callback. + */ + public static Time timeout(uint timeout, Closure? fn) { + return new Time.timeout_prio(timeout, Priority.DEFAULT, fn); + } + + /** + * Start a timer which will tick when there are no higher priority tasks pending. + * + * @param fn Optional callback. + */ + public static Time idle(Closure? fn) { + return new Time.idle_prio(Priority.DEFAULT_IDLE, fn); + } + + /** + * Cancel timer and emit [[email protected]::cancelled] + */ + public void cancel() { + cancellable.cancel(); + } +} diff --git a/lib/astal/io/variable.vala b/lib/astal/io/variable.vala new file mode 100644 index 0000000..312a27a --- /dev/null +++ b/lib/astal/io/variable.vala @@ -0,0 +1,198 @@ +/* + * Base class for [[email protected]] mainly meant to be used + * in higher level language bindings such as Lua and Gjs. + */ +public class AstalIO.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 AstalIO.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) { + Process.exec_asyncv.begin(poll_exec, (_, res) => { + try { + var str = Process.exec_asyncv.end(res); + set_closure(str, poll_transform); + } catch (Error err) { + this.error(err.message); + } + }); + poll_id = Timeout.add(poll_interval, () => { + Process.exec_asyncv.begin(poll_exec, (_, res) => { + try { + var str = Process.exec_asyncv.end(res); + set_closure(str, poll_transform); + } catch (Error err) { + this.error(err.message); + Source.remove(poll_id); + poll_id = 0; + } + }); + return Source.CONTINUE; + }, 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(); + } +} diff --git a/lib/astal/io/version b/lib/astal/io/version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/lib/astal/io/version @@ -0,0 +1 @@ +0.1.0 diff --git a/lib/battery/device.vala b/lib/battery/device.vala index a39d789..db69574 100644 --- a/lib/battery/device.vala +++ b/lib/battery/device.vala @@ -1,70 +1,250 @@ namespace AstalBattery { -public Device get_default() { - return Device.get_default(); + /** Get the DisplayDevice. */ + public Device get_default() { + return Device.get_default(); + } } -public class Device : Object { +/** + * Client for a UPower [[https://upower.freedesktop.org/docs/Device.html|device]]. + */ +public class AstalBattery.Device : Object { private static Device display_device; + + /** Get the DisplayDevice. */ public static Device? get_default() { - if (display_device != null) + if (display_device != null) { return display_device; + } try { - display_device = new Device("/org/freedesktop/UPower/devices/DisplayDevice"); - + display_device = new Device((ObjectPath)"/org/freedesktop/UPower/devices/DisplayDevice"); return display_device; } catch (Error error) { critical(error.message); } + return null; } private IUPowerDevice proxy; - public Device(string path) throws Error { + public Device(ObjectPath path) throws Error { proxy = Bus.get_proxy_sync(BusType.SYSTEM, "org.freedesktop.UPower", path); proxy.g_properties_changed.connect(sync); sync(); } + /** + * If it is [[email protected]], you will need to verify that the + * property power-supply has the value `true` before considering it as a laptop battery. + * Otherwise it will likely be the battery for a device of an unknown type. + */ public Type device_type { get; private set; } + + /** + * Native path of the power source. This is the sysfs path, + * for example /sys/devices/LNXSYSTM:00/device:00/PNP0C0A:00/power_supply/BAT0. + * It is blank if the device is being driven by a user space driver. + */ public string native_path { owned get; private set; } + + /** Name of the vendor of the battery. */ public string vendor { owned get; private set; } + + /** Name of the model of this battery. */ public string model { owned get; private set; } + + /** Unique serial number of the battery. */ public string serial { owned get; private set; } + + /** + * The point in time (seconds since the Epoch) + * that data was read from the power source. + */ public uint64 update_time { get; private set; } + + /** + * If the power device is used to supply the system. + * This would be set `true` for laptop batteries and UPS devices, + * but set to `false` for wireless mice or PDAs. + */ public bool power_supply { get; private set; } - public bool has_history { get; private set; } - public bool has_statistics { get; private set; } + + /** If the power device has history. */ + // TODO: public bool has_history { get; private set; } + + /** If the power device has statistics. */ + // TODO: public bool has_statistics { get; private set; } + + /** + * Whether power is currently being provided through line power. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]_POWER]. + */ public bool online { get; private set; } + + /** + * Amount of energy (measured in Wh) currently available in the power source. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double energy { get; private set; } + + /** + * Amount of energy (measured in Wh) in the power source when it's considered to be empty. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double energy_empty { get; private set; } + + /** + * Amount of energy (measured in Wh) in the power source when it's considered full. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double energy_full { get; private set; } + + /** + * Amount of energy (measured in Wh) the power source is designed to hold when it's considered full. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double energy_full_design { get; private set; } + + /** + * Amount of energy being drained from the source, measured in W. + * If positive, the source is being discharged, if negative it's being charged. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double energy_rate { get; private set; } + + /** Voltage in the Cell or being recorded by the meter. */ public double voltage { get; private set; } + + /** + * The number of charge cycles as defined by the TCO certification, + * or -1 if that value is unknown or not applicable. + */ public int charge_cycles { get; private set; } + + /** Luminosity being recorded by the meter. */ public double luminosity { get; private set; } + + /** + * Number of seconds until the power source is considered empty. Is set to 0 if unknown. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public int64 time_to_empty { get; private set; } + + /** + * Number of seconds until the power source is considered full. Is set to 0 if unknown. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public int64 time_to_full { get; private set;} + + /** + * The amount of energy left in the power source expressed as a percentage between 0 and 1. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + * The percentage will be an approximation if [[email protected]:battery_level] + * is set to something other than None. + */ public double percentage { get; private set; } + + /** + * The temperature of the device in degrees Celsius. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double temperature { get; private set; } + + /** + * If the power source is present in the bay. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public bool is_present { get; private set; } + + /** + * The battery power state. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public State state { get; private set; } + + /** + * If the power source is rechargeable. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public bool is_rechargable { get; private set; } + + /** + * The capacity of the power source expressed as a percentage between 0 and 1. + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public double capacity { get; private set; } + + /** + * Technology used in the battery: + * + * This property is only valid if [[email protected]:device_type] is [[email protected]]. + */ public Technology technology { get; private set; } + + /** Warning level of the battery. */ public WarningLevel warning_level { get; private set; } + + /** + * The level of the battery for devices which do not report a percentage + * but rather a coarse battery level. If the value is None. + * then the device does not support coarse battery reporting, + * and the [[email protected]:percentage] should be used instead. + */ public BatteryLevel battery_level { get; private set; } + + /** + * An icon name representing this Device. + * + * NOTE: [[email protected]:battery_icon_name] might be a better fit + * as it is calculated from percentage. + */ public string icon_name { owned get; private set; } + /** + * Indicates if [[email protected]:state] is charging or fully charged. + */ public bool charging { get; private set; } + + /** + * Indicates if [[email protected]:device_type] is not line power or unknown. + */ public bool is_battery { get; private set; } + + /** + * An icon name in the form of "battery-level-$percentage-$state-symbolic". + */ public string battery_icon_name { get; private set; } + + /** + * A string representation of this device's [[email protected]:device_type]. + */ public string device_type_name { get; private set; } + + /** + * An icon name that can be used to represent this device's [[email protected]:device_type]. + */ public string device_type_icon { get; private set; } - public void sync() { + // TODO: get_history + // TODO: get_statistics + + private void sync() { device_type = (Type)proxy.Type; native_path = proxy.native_path; vendor = proxy.vendor; @@ -72,8 +252,8 @@ public class Device : Object { serial = proxy.serial; update_time = proxy.update_time; power_supply = proxy.power_supply; - has_history = proxy.has_history; - has_statistics = proxy.has_statistics; + // TODO: has_history = proxy.has_history; + // TODO: has_statistics = proxy.has_statistics; online = proxy.online; energy = proxy.energy; energy_empty = proxy.energy_empty; @@ -90,7 +270,7 @@ public class Device : Object { is_present = proxy.is_present; state = (State)proxy.state; is_rechargable = proxy.is_rechargable; - capacity = proxy.capacity; + capacity = proxy.capacity / 100; technology = (Technology)proxy.technology; warning_level = (WarningLevel)proxy.warning_level; battery_level = (BatteryLevel)proxy.battery_level; @@ -115,7 +295,7 @@ public class Device : Object { } [CCode (type_signature = "u")] -public enum State { +public enum AstalBattery.State { UNKNOWN, CHARGING, DISCHARGING, @@ -126,7 +306,7 @@ public enum State { } [CCode (type_signature = "u")] -public enum Technology { +public enum AstalBattery.Technology { UNKNOWN, LITHIUM_ION, LITHIUM_POLYMER, @@ -137,7 +317,7 @@ public enum Technology { } [CCode (type_signature = "u")] -public enum WarningLevel { +public enum AstalBattery.WarningLevel { UNKNOWN, NONE, DISCHARGING, @@ -147,7 +327,7 @@ public enum WarningLevel { } [CCode (type_signature = "u")] -public enum BatteryLevel { +public enum AstalBattery.BatteryLevel { UNKNOWN, NONE, LOW, @@ -158,7 +338,7 @@ public enum BatteryLevel { } [CCode (type_signature = "u")] -public enum Type { +public enum AstalBattery.Type { UNKNOWN, LINE_POWER, BATTERY, @@ -190,7 +370,7 @@ public enum Type { BLUETOOTH_GENERIC; // TODO: add more icon names - public string? get_icon_name () { + internal string? get_icon_name () { switch (this) { case UPS: return "uninterruptible-power-supply"; @@ -213,7 +393,7 @@ public enum Type { } } - public unowned string? get_name () { + internal unowned string? get_name () { switch (this) { case LINE_POWER: return "Plugged In"; @@ -276,4 +456,3 @@ public enum Type { } } } -} diff --git a/lib/battery/gir.py b/lib/battery/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/battery/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/battery/ifaces.vala b/lib/battery/ifaces.vala index e6eb849..e2d21fe 100644 --- a/lib/battery/ifaces.vala +++ b/lib/battery/ifaces.vala @@ -1,12 +1,11 @@ -namespace AstalBattery { [DBus (name = "org.freedesktop.UPower")] -interface IUPower : DBusProxy { - public abstract string[] enumerate_devices() throws Error; - public abstract string get_display_device() throws Error; +private interface AstalBattery.IUPower : DBusProxy { + public abstract ObjectPath[] enumerate_devices() throws Error; + public abstract ObjectPath get_display_device() throws Error; public abstract string get_critical_action() throws Error; - public signal void device_added(string object_path); - public signal void device_removed(string object_path); + public signal void device_added(ObjectPath object_path); + public signal void device_removed(ObjectPath object_path); public abstract string daemon_version { owned get; } public abstract bool on_battery { get; } @@ -15,10 +14,10 @@ interface IUPower : DBusProxy { } [DBus (name = "org.freedesktop.UPower.Device")] -public interface IUPowerDevice : DBusProxy { - public abstract HistoryDataPoint[] get_history (string type, uint32 timespan, uint32 resolution) throws GLib.Error; - public abstract StatisticsDataPoint[] get_statistics (string type) throws GLib.Error; - public abstract void refresh () throws GLib.Error; +private interface AstalBattery.IUPowerDevice : DBusProxy { + // public abstract HistoryDataPoint[] get_history (string type, uint32 timespan, uint32 resolution) throws GLib.Error; + // public abstract StatisticsDataPoint[] get_statistics (string type) throws GLib.Error; + // public abstract void refresh () throws GLib.Error; public abstract uint Type { get; } public abstract string native_path { owned get; } @@ -52,14 +51,13 @@ public interface IUPowerDevice : DBusProxy { public abstract string icon_name { owned get; } } -public struct HistoryDataPoint { - uint32 time; - double value; - uint32 state; -} - -public struct StatisticsDataPoint { - double value; - double accuracy; -} -} +// private struct AstalBattery.HistoryDataPoint { +// uint32 time; +// double value; +// uint32 state; +// } +// +// private struct AstalBattery.StatisticsDataPoint { +// double value; +// double accuracy; +// } diff --git a/lib/battery/meson.build b/lib/battery/meson.build index 584f66d..054e9db 100644 --- a/lib/battery/meson.build +++ b/lib/battery/meson.build @@ -41,34 +41,40 @@ pkgconfig_deps = [ deps = pkgconfig_deps + meson.get_compiler('c').find_library('m') -sources = [ - config, - 'ifaces.vala', +sources = [config] + files( 'device.vala', + 'ifaces.vala', 'upower.vala', -] +) if get_option('lib') lib = library( meson.project_name(), sources, dependencies: deps, + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) - import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: pkgconfig_deps, - install_dir: get_option('libdir') / 'pkgconfig', + pkgs = [] + foreach dep : pkgconfig_deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -81,10 +87,20 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: pkgconfig_deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) endif if get_option('cli') diff --git a/lib/battery/upower.vala b/lib/battery/upower.vala index 9c18ffd..223633e 100644 --- a/lib/battery/upower.vala +++ b/lib/battery/upower.vala @@ -1,23 +1,40 @@ -namespace AstalBattery { -public class UPower : Object { +/** + * Client for the UPower [[https://upower.freedesktop.org/docs/UPower.html|dbus interface]]. + */ +public class AstalBattery.UPower : Object { private IUPower proxy; private HashTable<string, Device> _devices = new HashTable<string, Device>(str_hash, str_equal); + /** List of UPower devices. */ public List<weak Device> devices { owned get { return _devices.get_values(); } } + /** Emitted when a new device is connected. */ public signal void device_added(Device device); + + /** Emitted a new device is disconnected. */ public signal void device_removed(Device device); + /** A composite device that represents the battery status. */ public Device display_device { owned get { return Device.get_default(); }} public string daemon_version { owned get { return proxy.daemon_version; } } + + /** Indicates whether the system is running on battery power. */ public bool on_battery { get { return proxy.on_battery; } } + + /** Indicates if the laptop lid is closed where the display cannot be seen. */ public bool lid_is_closed { get { return proxy.lid_is_closed; } } + + /** Indicates if the system has a lid device. */ public bool lis_is_present { get { return proxy.lid_is_closed; } } + /** + * When the system's power supply is critical (critically low batteries or UPS), + * the system will take this action. + */ public string critical_action { owned get { try { @@ -41,8 +58,14 @@ public class UPower : Object { _devices.set(path, new Device(path)); proxy.device_added.connect((path) => { - _devices.set(path, new Device(path)); - notify_property("devices"); + try { + var d = new Device(path); + _devices.set(path, d); + device_added(d); + notify_property("devices"); + } catch (Error err) { + critical(err.message); + } }); proxy.device_removed.connect((path) => { @@ -55,4 +78,3 @@ public class UPower : Object { } } } -} diff --git a/lib/bluetooth/adapter.vala b/lib/bluetooth/adapter.vala index 0c9d00e..99a59fb 100644 --- a/lib/bluetooth/adapter.vala +++ b/lib/bluetooth/adapter.vala @@ -1,27 +1,10 @@ -namespace AstalBluetooth { -[DBus (name = "org.bluez.Adapter1")] -internal interface IAdapter : DBusProxy { - public abstract void remove_device(ObjectPath device) throws Error; - public abstract void start_discovery() throws Error; - public abstract void stop_discovery() throws Error; - - public abstract string[] uuids { owned get; } - public abstract bool discoverable { get; set; } - public abstract bool discovering { get; } - public abstract bool pairable { get; set; } - public abstract bool powered { get; set; } - public abstract string address { owned get; } - public abstract string alias { owned get; set; } - public abstract string modalias { owned get; } - public abstract string name { owned get; } - public abstract uint class { get; } - public abstract uint discoverable_timeout { get; set; } - public abstract uint pairable_timeout { get; set; } -} - -public class Adapter : Object { +/** + * Object representing an [[https://github.com/RadiusNetworks/bluez/blob/master/doc/adapter-api.txt|adapter]]. + */ +public class AstalBluetooth.Adapter : Object { private IAdapter proxy; - public string object_path { owned get; construct set; } + + internal string object_path { owned get; private set; } internal Adapter(IAdapter proxy) { this.proxy = proxy; @@ -37,53 +20,127 @@ public class Adapter : Object { }); } + /** + * List of 128-bit UUIDs that represents the available local services. + */ public string[] uuids { owned get { return proxy.uuids; } } + + /** + * Indicates that a device discovery procedure is active. + */ public bool discovering { get { return proxy.discovering; } } + + /** + * Local Device ID information in modalias format used by the kernel and udev. + */ public string modalias { owned get { return proxy.modalias; } } + + /** + * The Bluetooth system name (pretty hostname). + */ public string name { owned get { return proxy.name; } } + + /** + * The Bluetooth class of device. + */ public uint class { get { return proxy.class; } } + + /** + * The Bluetooth device address. + */ public string address { owned get { return proxy.address; } } + + /** + * Switch an adapter to discoverable or non-discoverable + * to either make it visible or hide it. + */ public bool discoverable { get { return proxy.discoverable; } set { proxy.discoverable = value; } } + + /** + * Switch an adapter to pairable or non-pairable. + */ public bool pairable { get { return proxy.pairable; } set { proxy.pairable = value; } } + + /** + * Switch an adapter on or off. + */ public bool powered { get { return proxy.powered; } set { proxy.powered = value; } } + + /** + * The Bluetooth friendly name. + * + * In case no alias is set, it will return [[email protected]:name]. + */ public string alias { owned get { return proxy.alias; } set { proxy.alias = value; } } + + /** + * The discoverable timeout in seconds. + * A value of zero means that the timeout is disabled + * and it will stay in discoverable/limited mode forever + * until [[email protected]_discovery] is invoked. + * The default value for the discoverable timeout should be `180`. + */ public uint discoverable_timeout { get { return proxy.discoverable_timeout; } set { proxy.discoverable_timeout = value; } } + + /** + * The pairable timeout in seconds. + * + * A value of zero means that the timeout is disabled and it will stay in pairable mode forever. + * The default value for pairable timeout should be disabled `0`. + */ public uint pairable_timeout { get { return proxy.pairable_timeout; } set { proxy.pairable_timeout = value; } } - public void remove_device(Device device) { - try { proxy.remove_device((ObjectPath)device.object_path); } catch (Error err) { critical(err.message); } + + /** + * This removes the remote device and the pairing information. + * + * Possible errors: `InvalidArguments`, `Failed`. + */ + public void remove_device(Device device) throws Error { + proxy.remove_device(device.object_path); } - public void start_discovery() { - try { proxy.start_discovery(); } catch (Error err) { critical(err.message); } + + /** + * This method starts the device discovery procedure. + * + * Possible errors: `NotReady`, `Failed`. + */ + public void start_discovery() throws Error { + proxy.start_discovery(); } - public void stop_discovery() { - try { proxy.stop_discovery(); } catch (Error err) { critical(err.message); } + + /** + * This method will cancel any previous [[email protected]_discovery] procedure. + * + * Possible errors: `NotReady`, `Failed`, `NotAuthorized`. + */ + public void stop_discovery() throws Error { + proxy.stop_discovery(); } } -} diff --git a/lib/bluetooth/bluetooth.vala b/lib/bluetooth/bluetooth.vala index ce086ba..6eb6b76 100644 --- a/lib/bluetooth/bluetooth.vala +++ b/lib/bluetooth/bluetooth.vala @@ -1,11 +1,21 @@ namespace AstalBluetooth { -public Bluetooth get_default() { - return Bluetooth.get_default(); + /** + * Gets the default singleton Bluetooth object. + */ + public Bluetooth get_default() { + return Bluetooth.get_default(); + } } -public class Bluetooth : Object { +/** + * Manager object for `org.bluez`. + */ +public class AstalBluetooth.Bluetooth : Object { private static Bluetooth _instance; + /** + * Gets the default singleton Bluetooth object. + */ public static Bluetooth get_default() { if (_instance == null) _instance = new Bluetooth(); @@ -21,30 +31,59 @@ public class Bluetooth : Object { private HashTable<string, Device> _devices = new HashTable<string, Device>(str_hash, str_equal); + /** + * Emitted when a new device is registered on the `org.bluez` bus. + */ public signal void device_added (Device device) { notify_property("devices"); } + /** + * Emitted when a device is unregistered on the `org.bluez` bus. + */ public signal void device_removed (Device device) { notify_property("devices"); } + /** + * Emitted when an adapter is registered on the `org.bluez` bus. + */ public signal void adapter_added (Adapter adapter) { notify_property("adapters"); } + /** + * Emitted when an adapter is unregistered on the `org.bluez` bus. + */ public signal void adapter_removed (Adapter adapter) { notify_property("adapters"); } + /** + * `true` if any of the [[email protected]:adapters] are powered. + */ public bool is_powered { get; private set; default = false; } + + /** + * `true` if any of the [[email protected]:devices] is connected. + */ public bool is_connected { get; private set; default = false; } + + /** + * The first registered adapter which is usually the only adapter. + */ public Adapter? adapter { get { return adapters.nth_data(0); } } + /** + * List of adapters available on the host device. + */ public List<weak Adapter> adapters { owned get { return _adapters.get_values(); } } + /** + * List of registered devices on the `org.bluez` bus. + */ public List<weak Device> devices { owned get { return _devices.get_values(); } } @@ -85,6 +124,10 @@ public class Bluetooth : Object { } } + /** + * Toggle the [[email protected]:powered] + * property of the [[email protected]:adapter]. + */ public void toggle() { adapter.powered = !adapter.powered; } @@ -178,4 +221,3 @@ public class Bluetooth : Object { return false; } } -} diff --git a/lib/bluetooth/device.vala b/lib/bluetooth/device.vala index 8fe086f..3f00cd9 100644 --- a/lib/bluetooth/device.vala +++ b/lib/bluetooth/device.vala @@ -1,37 +1,14 @@ -namespace AstalBluetooth { -[DBus (name = "org.bluez.Device1")] -internal interface IDevice : DBusProxy { - public abstract void cancel_pairing() throws Error; - public abstract async void connect() throws Error; - public abstract void connect_profile(string uuid) throws Error; - public abstract async void disconnect() throws Error; - public abstract void disconnect_profile(string uuid) throws Error; - public abstract void pair() throws Error; - - public abstract string[] uuids { owned get; } - public abstract bool blocked { get; set; } - public abstract bool connected { get; } - public abstract bool legacy_pairing { get; } - public abstract bool paired { get; } - public abstract bool trusted { get; set; } - public abstract int16 rssi { get; } - public abstract ObjectPath adapter { owned get; } - public abstract string address { owned get; } - public abstract string alias { owned get; set; } - public abstract string icon { owned get; } - public abstract string modalias { owned get; } - public abstract string name { owned get; } - public abstract uint16 appearance { get; } - public abstract uint32 class { get; } -} - -public class Device : Object { +/** + * Object representing a [[https://github.com/luetzel/bluez/blob/master/doc/device-api.txt|device]]. + */ +public class AstalBluetooth.Device : Object { private IDevice proxy; - public string object_path { owned get; construct set; } + + internal ObjectPath object_path { owned get; private set; } internal Device(IDevice proxy) { this.proxy = proxy; - this.object_path = proxy.g_object_path; + this.object_path = (ObjectPath)proxy.g_object_path; proxy.g_properties_changed.connect((props) => { var map = (HashTable<string, Variant>)props; foreach (var key in map.get_keys()) { @@ -43,64 +20,164 @@ public class Device : Object { }); } + /** + * List of 128-bit UUIDs that represents the available remote services. + */ public string[] uuids { owned get { return proxy.uuids; } } + + /** + * Indicates if the remote device is currently connected. + */ public bool connected { get { return proxy.connected; } } + + /** + * `true` if the device only supports the pre-2.1 pairing mechanism. + */ public bool legacy_pairing { get { return proxy.legacy_pairing; } } + + /** + * Indicates if the remote device is paired. + */ public bool paired { get { return proxy.paired; } } + + /** + * Received Signal Strength Indicator of the remote device (inquiry or advertising). + */ public int16 rssi { get { return proxy.rssi; } } + + /** + * The object path of the adapter the device belongs to. + */ public ObjectPath adapter { owned get { return proxy.adapter; } } + + /** + * The Bluetooth device address of the remote device. + */ public string address { owned get { return proxy.address; } } + + /** + * Proposed icon name. + */ public string icon { owned get { return proxy.icon; } } + + /** + * Remote Device ID information in modalias format used by the kernel and udev. + */ public string modalias { owned get { return proxy.modalias; } } + + /** + * The Bluetooth remote name. + * + * It is always better to use [[email protected]:alias]. + */ public string name { owned get { return proxy.name; } } + + /** + * External appearance of device, as found on GAP service. + */ public uint16 appearance { get { return proxy.appearance; } } + + /** + * The Bluetooth class of device of the remote device. + */ public uint32 class { get { return proxy.class; } } + + /** + * Indicates if this device is currently trying to be connected. + */ public bool connecting { get; private set; } + /** + * If set to `true` any incoming connections from the device will be immediately rejected. + */ public bool blocked { get { return proxy.blocked; } set { proxy.blocked = value; } } + /** + * Indicates if the remote is seen as trusted. + */ public bool trusted { get { return proxy.trusted; } set { proxy.trusted = value; } } + /** + * The name alias for the remote device. + * + * In case no alias is set, it will return the remote device [[email protected]:name]. + */ public string alias { owned get { return proxy.alias; } set { proxy.alias = value; } } - public void cancel_pairing() { - try { proxy.cancel_pairing(); } catch (Error err) { critical(err.message); } - } - - public async void connect_device() { + /** + * This is a generic method to connect any profiles + * the remote device supports that can be connected to. + * + * Possible errors: `NotReady`, `Failed`, `InProgress`, `AlreadyConnected`. + */ + public async void connect_device() throws Error { try { connecting = true; yield proxy.connect(); - } catch (Error err) { - critical(err.message); } finally { connecting = false; } } - public async void disconnect_device() { - try { yield proxy.disconnect(); } catch (Error err) { critical(err.message); } + /** + * This method gracefully disconnects all connected profiles. + * + * Possible errors: `NotConnected`. + */ + public async void disconnect_device() throws Error { + yield proxy.disconnect(); } - public void connect_profile(string uuid) { - try { proxy.connect_profile(uuid); } catch (Error err) { critical(err.message); } + /** + * This method connects a specific profile of this device. + * The UUID provided is the remote service UUID for the profile. + * + * Possible errors: `Failed`, `InProgress`, `InvalidArguments`, `NotAvailable`, `NotReady`. + * + * @param uuid the remote service UUID. + */ + public void connect_profile(string uuid) throws Error { + proxy.connect_profile(uuid); } - public void disconnect_profile(string uuid) { - try { proxy.disconnect_profile(uuid); } catch (Error err) { critical(err.message); } + /** + * This method disconnects a specific profile of this device. + * + * Possible errors: `Failed`, `InProgress`, `InvalidArguments`, `NotSupported`. + * + * @param uuid the remote service UUID. + */ + public void disconnect_profile(string uuid) throws Error { + proxy.disconnect_profile(uuid); } - public void pair() { - try { proxy.pair(); } catch (Error err) { critical(err.message); } + /** + * This method will connect to the remote device and initiate pairing. + * + * Possible errors: `InvalidArguments`, `Failed`, `AlreadyExists`, + * `AuthenticationCanceled`, `AuthenticationFailed`, `AuthenticationRejected`, + * `AuthenticationTimeout`, `ConnectionAttemptFailed`. + */ + public void pair() throws Error { + proxy.pair(); + } + + /** + * This method can be used to cancel a pairing operation + * initiated by [[email protected]]. + * + * Possible errors: `DoesNotExist`, `Failed`. + */ + public void cancel_pairing() throws Error { + proxy.cancel_pairing(); } -} } diff --git a/lib/bluetooth/gir.py b/lib/bluetooth/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/bluetooth/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/bluetooth/interfaces.vala b/lib/bluetooth/interfaces.vala new file mode 100644 index 0000000..dcb1c4b --- /dev/null +++ b/lib/bluetooth/interfaces.vala @@ -0,0 +1,46 @@ +[DBus (name = "org.bluez.Adapter1")] +private interface AstalBluetooth.IAdapter : DBusProxy { + public abstract void remove_device(ObjectPath device) throws Error; + public abstract void start_discovery() throws Error; + public abstract void stop_discovery() throws Error; + + public abstract string[] uuids { owned get; } + public abstract bool discoverable { get; set; } + public abstract bool discovering { get; } + public abstract bool pairable { get; set; } + public abstract bool powered { get; set; } + public abstract string address { owned get; } + public abstract string alias { owned get; set; } + public abstract string modalias { owned get; } + public abstract string name { owned get; } + public abstract uint class { get; } + public abstract uint discoverable_timeout { get; set; } + public abstract uint pairable_timeout { get; set; } +} + +[DBus (name = "org.bluez.Device1")] +private interface AstalBluetooth.IDevice : DBusProxy { + public abstract void cancel_pairing() throws Error; + public abstract async void connect() throws Error; + public abstract void connect_profile(string uuid) throws Error; + public abstract async void disconnect() throws Error; + public abstract void disconnect_profile(string uuid) throws Error; + public abstract void pair() throws Error; + + public abstract string[] uuids { owned get; } + public abstract bool blocked { get; set; } + public abstract bool connected { get; } + public abstract bool legacy_pairing { get; } + public abstract bool paired { get; } + public abstract bool trusted { get; set; } + public abstract int16 rssi { get; } + public abstract ObjectPath adapter { owned get; } + public abstract string address { owned get; } + public abstract string alias { owned get; set; } + public abstract string icon { owned get; } + public abstract string modalias { owned get; } + public abstract string name { owned get; } + public abstract uint16 appearance { get; } + public abstract uint32 class { get; } +} + diff --git a/lib/bluetooth/meson.build b/lib/bluetooth/meson.build index 934d380..347b463 100644 --- a/lib/bluetooth/meson.build +++ b/lib/bluetooth/meson.build @@ -33,34 +33,41 @@ deps = [ dependency('gio-2.0'), ] -sources = [ - config, - 'utils.vala', - 'device.vala', +sources = [config] + files( 'adapter.vala', 'bluetooth.vala', -] + 'device.vala', + 'interfaces.vala', + 'utils.vala', +) lib = library( meson.project_name(), sources, dependencies: deps, + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) -import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: deps, - install_dir: get_option('libdir') / 'pkgconfig', +pkgs = [] +foreach dep : deps + pkgs += ['--pkg=' + dep.name()] +endforeach + +gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -73,7 +80,17 @@ custom_target( ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + +import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', +) diff --git a/lib/gir.py b/lib/gir.py new file mode 100644 index 0000000..a0a81dc --- /dev/null +++ b/lib/gir.py @@ -0,0 +1,67 @@ +""" +Vala's generated gir does not contain comments, +so we use valadoc to generate them. However, they are formatted +for valadoc and not gi-docgen so we need to fix it. +""" + +import xml.etree.ElementTree as ET +import html +import sys +import subprocess +import re + + +# valac fails on gi-docgen compliant markdown +# gi-docgen removes valac compliant ulink +# so we use vala notation and turn it into markdown +def ulink_to_markdown(text: str): + pattern = r'<ulink url="(.*?)">(.*?)</ulink>' + return re.sub(pattern, r"[\2](\1)", text) + + +def fix_gir(name: str, gir: str, out: str): + namespaces = { + "": "http://www.gtk.org/introspection/core/1.0", + "c": "http://www.gtk.org/introspection/c/1.0", + "glib": "http://www.gtk.org/introspection/glib/1.0", + } + for prefix, uri in namespaces.items(): + ET.register_namespace(prefix, uri) + + tree = ET.parse(gir) + root = tree.getroot() + + for doc in root.findall(".//doc", namespaces): + if doc.text: + doc.text = ulink_to_markdown( + html.unescape(doc.text).replace("<para>", "").replace("</para>", "") + ) + + if (inc := root.find("c:include", namespaces)) is not None: + inc.set("name", f"{name}.h") + else: + print("no c:include tag found", file=sys.stderr) + exit(1) + + tree.write(out, encoding="utf-8", xml_declaration=True) + + +def valadoc(name: str, gir: str, args: list[str]): + cmd = ["valadoc", "-o", "docs", "--package-name", name, "--gir", gir, *args] + try: + subprocess.run(cmd, check=True, text=True, capture_output=True) + except subprocess.CalledProcessError as e: + print(e.stderr, file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + name = sys.argv[1] + in_out = sys.argv[2].split(":") + args = sys.argv[3:] + + gir = in_out[0] + out = in_out[1] if len(in_out) > 1 else gir + + valadoc(name, gir, args) + fix_gir(name, gir, out) diff --git a/lib/hyprland/client.vala b/lib/hyprland/client.vala index 3df644b..3f2d0fb 100644 --- a/lib/hyprland/client.vala +++ b/lib/hyprland/client.vala @@ -73,10 +73,11 @@ public class Client : Object { } } +[Flags] public enum Fullscreen { CURRENT = -1, NONE = 0, - FULLSCREEN = 1, - MAXIMIZED = 2, + MAXIMIZED = 1, + FULLSCREEN = 2, } } diff --git a/lib/hyprland/hyprland.vala b/lib/hyprland/hyprland.vala index 3886486..ea95cab 100644 --- a/lib/hyprland/hyprland.vala +++ b/lib/hyprland/hyprland.vala @@ -158,43 +158,43 @@ public class Hyprland : Object { out DataInputStream stream ) throws Error { conn = connection("socket"); - conn.output_stream.write(message.data, null); - stream = new DataInputStream(conn.input_stream); + if (conn != null) { + conn.output_stream.write(message.data, null); + stream = new DataInputStream(conn.input_stream); + } else { + stream = null; + critical("could not write to the Hyprland socket"); + } } public string message(string message) { - SocketConnection conn; - DataInputStream stream; + SocketConnection? conn; + DataInputStream? stream; try { write_socket(message, out conn, out stream); - return stream.read_upto("\x04", -1, null, null); + if (stream != null && conn != null) { + var res = stream.read_upto("\x04", -1, null, null); + conn.close(null); + return res; + } } catch (Error err) { critical(err.message); - } finally { - try { - if (conn != null) - conn.close(null); - } catch (Error err) { - critical(err.message); - } } return ""; } public async string message_async(string message) { - SocketConnection conn; - DataInputStream stream; + SocketConnection? conn; + DataInputStream? stream; try { write_socket(message, out conn, out stream); - return yield stream.read_upto_async("\x04", -1, Priority.DEFAULT, null, null); - } catch (Error err) { - critical(err.message); - } finally { - try { + if (stream != null && conn != null) { + var res = yield stream.read_upto_async("\x04", -1, Priority.DEFAULT, null, null); conn.close(null); - } catch (Error err) { - critical(err.message); + return res; } + } catch (Error err) { + critical(err.message); } return ""; } diff --git a/lib/mpris/gir.py b/lib/mpris/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/mpris/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/mpris/ifaces.vala b/lib/mpris/ifaces.vala index 4a9d715..298a288 100644 --- a/lib/mpris/ifaces.vala +++ b/lib/mpris/ifaces.vala @@ -1,19 +1,18 @@ -namespace AstalMpris { [DBus (name="org.freedesktop.DBus")] -internal interface DBusImpl : DBusProxy { - public abstract string[] list_names () throws GLib.Error; - public signal void name_owner_changed (string name, string old_owner, string new_owner); +private interface AstalMpris.DBusImpl : DBusProxy { + public abstract string[] list_names() throws GLib.Error; + public signal void name_owner_changed(string name, string old_owner, string new_owner); } [DBus (name="org.freedesktop.DBus.Properties")] -internal interface PropsIface : DBusProxy { - public abstract HashTable<string, Variant> get_all (string iface); +private interface AstalMpris.PropsIface : DBusProxy { + public abstract HashTable<string, Variant> get_all(string iface); } [DBus (name="org.mpris.MediaPlayer2")] -internal interface IMpris : PropsIface { - public abstract void raise () throws GLib.Error; - public abstract void quit () throws GLib.Error; +private interface AstalMpris.IMpris : PropsIface { + public abstract void raise() throws GLib.Error; + public abstract void quit() throws GLib.Error; public abstract bool can_quit { get; } public abstract bool fullscreen { get; set; } @@ -27,18 +26,18 @@ internal interface IMpris : PropsIface { } [DBus (name="org.mpris.MediaPlayer2.Player")] -internal interface IPlayer : IMpris { - public abstract void next () throws GLib.Error; - public abstract void previous () throws GLib.Error; - public abstract void pause () throws GLib.Error; - public abstract void play_pause () throws GLib.Error; - public abstract void stop () throws GLib.Error; - public abstract void play () throws GLib.Error; - public abstract void seek (int64 offset) throws GLib.Error; - public abstract void set_position (ObjectPath track_id, int64 position) throws GLib.Error; - public abstract void open_uri (string uri) throws GLib.Error; +private interface AstalMpris.IPlayer : IMpris { + public abstract void next() throws GLib.Error; + public abstract void previous() throws GLib.Error; + public abstract void pause() throws GLib.Error; + public abstract void play_pause() throws GLib.Error; + public abstract void stop() throws GLib.Error; + public abstract void play() throws GLib.Error; + public abstract void seek(int64 offset) throws GLib.Error; + public abstract void set_position(ObjectPath track_id, int64 position) throws GLib.Error; + public abstract void open_uri(string uri) throws GLib.Error; - public signal void seeked (int64 position); + public signal void seeked(int64 position); public abstract string playback_status { owned get; } public abstract string loop_status { owned get; set; } @@ -57,4 +56,3 @@ internal interface IPlayer : IMpris { public abstract bool can_seek { get; } public abstract bool can_control { get; } } -} diff --git a/lib/mpris/meson.build b/lib/mpris/meson.build index c9a5c53..bf215c9 100644 --- a/lib/mpris/meson.build +++ b/lib/mpris/meson.build @@ -38,34 +38,40 @@ deps = [ dependency('json-glib-1.0'), ] -sources = [ - config, +sources = [config] + files( 'ifaces.vala', - 'player.vala', 'mpris.vala', -] + 'player.vala', +) if get_option('lib') lib = library( meson.project_name(), sources, dependencies: deps, + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) - import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: deps, - install_dir: get_option('libdir') / 'pkgconfig', + pkgs = [] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -78,10 +84,20 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) endif if get_option('cli') diff --git a/lib/mpris/mpris.vala b/lib/mpris/mpris.vala index 0e55a2e..8eaffa5 100644 --- a/lib/mpris/mpris.vala +++ b/lib/mpris/mpris.vala @@ -1,12 +1,24 @@ namespace AstalMpris { -public Mpris get_default() { - return Mpris.get_default(); + /** + * Gets the default singleton Mpris instance. + */ + public Mpris get_default() { + return Mpris.get_default(); + } } -public class Mpris : Object { +/** + * Object that monitors dbus for players to appear and disappear. + */ +public class AstalMpris.Mpris : Object { internal static string PREFIX = "org.mpris.MediaPlayer2."; private static Mpris instance; + private DBusImpl proxy; + + /** + * Gets the default singleton Mpris instance. + */ public static Mpris get_default() { if (instance == null) instance = new Mpris(); @@ -14,15 +26,23 @@ public class Mpris : Object { return instance; } - private DBusImpl proxy; - private HashTable<string, Player> _players = new HashTable<string, Player> (str_hash, str_equal); + /** + * List of currently available players. + */ public List<weak Player> players { owned get { return _players.get_values(); } } - public signal void player_added (Player player); - public signal void player_closed (Player player); + /** + * Emitted when a new mpris Player appears. + */ + public signal void player_added(Player player); + + /** + * Emitted when a Player disappears. + */ + public signal void player_closed(Player player); construct { try { @@ -53,14 +73,15 @@ public class Mpris : Object { var p = new Player(busname); _players.set(busname, p); - p.closed.connect(() => { - player_closed(p); - _players.remove(busname); - notify_property("players"); + p.notify["available"].connect(() => { + if (!p.available) { + player_closed(p); + _players.remove(busname); + notify_property("players"); + } }); player_added(p); notify_property("players"); } } -} diff --git a/lib/mpris/player.vala b/lib/mpris/player.vala index 6764d2b..b72c15e 100644 --- a/lib/mpris/player.vala +++ b/lib/mpris/player.vala @@ -1,75 +1,179 @@ -namespace AstalMpris { -public class Player : Object { +/** + * Object which tracks players through their mpris dbus interface. + * The most simple way is to use [[email protected]] which tracks every player, + * but [[email protected]] can be constructed for a dedicated players too. + */ +public class AstalMpris.Player : Object { private static string COVER_CACHE = Environment.get_user_cache_dir() + "/astal/mpris"; private IPlayer proxy; - - public signal void appeared () { available = true; } - public signal void closed () { available = false; } + private uint pollid; // periodically notify position // identifiers - public string bus_name { owned get; construct set; } - public bool available { get; private set; } + public string bus_name { owned get; private set; } - // periodically notify position - private uint pollid; + /** + * Indicates if [[email protected]:bus_name] is available on dbus. + */ + public bool available { get; private set; } // mpris + + /** + * Brings the player's user interface to the front + * using any appropriate mechanism available. + * + * The media player may be unable to control how its user interface is displayed, + * or it may not have a graphical user interface at all. + * In this case, the [[email protected]:can_raise] is `false` and this method does nothing. + */ public void raise() { - try { proxy.raise(); } catch (Error error) { critical(error.message); } + try { proxy.raise(); } catch (Error err) { critical(err.message); } } - public void quit() { - try { proxy.quit(); } catch (Error error) { critical(error.message); } + /** + * Causes the media player to stop running. + * + * The media player may refuse to allow clients to shut it down. + * In this case, the [[email protected]:can_quit] property is false and this method does nothing. + */ + public void quit() throws Error { + try { proxy.quit(); } catch (Error err) { critical(err.message); } } + /** + * Indicates if [[email protected]] has any effect. + */ public bool can_quit { get; private set; } + + /** + * Indicates if the player is occupying the fullscreen. This is typically used for videos. + * Use [[email protected]_fullscreen] to toggle fullscreen state. + */ public bool fullscreen { get; private set; } + + /** + * Indicates if [[email protected]_fullscreen] has any effect. + */ public bool can_set_fullscreen { get; private set; } + + /** + * Indicates if [[email protected]] has any effect. + */ public bool can_raise { get; private set; } - public bool has_track_list { get; private set; } + + // TODO: Tracklist interface + // public bool has_track_list { get; private set; } + + /** + * A human friendly name to identify the player. + */ public string identity { owned get; private set; } + + /** + * The base name of a .desktop file + */ public string entry { owned get; private set; } + + /** + * The URI schemes supported by the media player. + * + * This can be viewed as protocols supported by the player in almost all cases. + * Almost every media player will include support for the "file" scheme. + * Other common schemes are "http" and "rtsp". + */ public string[] supported_uri_schemas { owned get; private set; } + + /** + * The mime-types supported by the player. + */ public string[] supported_mime_types { owned get; private set; } + /** + * Toggle [[email protected]:fullscreen] state. + */ public void toggle_fullscreen() { if (!can_set_fullscreen) - critical("can not set fullscreen on " + bus_name); + critical(@"can not set fullscreen on $bus_name"); proxy.fullscreen = !fullscreen; } - // player + /** + * Skips to the next track in the tracklist. + * If there is no next track (and endless playback and track repeat are both off), stop playback. + * If [[email protected]:can_go_next] is `false` this method has no effect. + */ public void next() { try { proxy.next(); } catch (Error error) { critical(error.message); } } + /** + * Skips to the previous track in the tracklist. + * If there is no previous track (and endless playback and track repeat are both off), stop playback. + * If [[email protected]:can_go_previous] is `false` this method has no effect. + */ public void previous() { try { proxy.previous(); } catch (Error error) { critical(error.message); } } + /** + * Pauses playback. + * If playback is already paused, this has no effect. + * If [[email protected]:can_pause] is `false` this method has no effect. + */ public void pause() { try { proxy.pause(); } catch (Error error) { critical(error.message); } } + /** + * Pauses playback. + * If playback is already paused, resumes playback. + * If playback is stopped, starts playback. + */ public void play_pause() { try { proxy.play_pause(); } catch (Error error) { critical(error.message); } } + /** + * Stops playback. + * If playback is already stopped, this has no effect. + * If [[email protected]:can_control] is `false` this method has no effect. + */ public void stop() { try { proxy.stop(); } catch (Error error) { critical(error.message); } } + /** + * Starts or resumes playback. + * If already playing, this has no effect. + * If paused, playback resumes from the current position. + * If [[email protected]:can_play] is `false` this method has no effect. + */ public void play() { try { proxy.play(); } catch (Error error) { critical(error.message); } } + /** + * uri scheme should be an element of [[email protected]:supported_uri_schemas] + * and the mime-type should match one of the elements of [[email protected]:supported_mime_types]. + * + * @param uri Uri of the track to load. + */ public void open_uri(string uri) { try { proxy.open_uri(uri); } catch (Error error) { critical(error.message); } } + /** + * Change [[email protected]:loop_status] from none to track, + * from track to playlist, from playlist to none. + */ public void loop() { + if (loop_status == Loop.UNSUPPORTED) { + critical(@"loop is unsupported by $bus_name"); + return; + } + switch (loop_status) { case Loop.NONE: loop_status = Loop.TRACK; @@ -85,15 +189,21 @@ public class Player : Object { } } + /** + * Toggle [[email protected]:shuffle_status]. + */ public void shuffle() { + if (shuffle_status == Shuffle.UNSUPPORTED) { + critical(@"shuffle is unsupported by $bus_name"); + return; + } + shuffle_status = shuffle_status == Shuffle.ON ? Shuffle.OFF : Shuffle.ON; } - public signal void seeked (int64 position); - - public double _get_position() { + private double _get_position() { try { var reply = proxy.call_sync( "org.freedesktop.DBus.Properties.Get", @@ -130,63 +240,175 @@ public class Player : Object { private Shuffle _shuffle_status = Shuffle.UNSUPPORTED; private double _volume = -1; + /** + * The current loop/repeat status. + */ public Loop loop_status { get { return _loop_status; } set { proxy.loop_status = value.to_string(); } } + /** + * The current playback rate. + */ public double rate { get { return _rate; } set { proxy.rate = value; } } + /** + * The current shuffle status. + */ public Shuffle shuffle_status { get { return _shuffle_status; } set { proxy.shuffle = value == Shuffle.ON; } } + /** + * The current volume level between 0 and 1. + */ public double volume { get { return _volume; } set { proxy.volume = value; } } + /** + * The current position of the track in seconds. + * To get a progress percentage simply divide this with [[email protected]:length]. + */ public double position { get { return _get_position(); } set { _set_position(value); } } + /** + * The current playback status. + */ public PlaybackStatus playback_status { get; private set; } + + /** + * The minimum value which the [[email protected]:rate] can take. + */ public double minimum_rate { get; private set; } + + /** + * The maximum value which the [[email protected]:rate] can take. + */ public double maximum_rate { get; private set; } + + /** + * Indicates if invoking [[email protected]] has effect. + */ public bool can_go_next { get; private set; } + + /** + * Indicates if invoking [[email protected]] has effect. + */ public bool can_go_previous { get; private set; } + + /** + * Indicates if invoking [[email protected]] has effect. + */ public bool can_play { get; private set; } + + /** + * Indicates if invoking [[email protected]] has effect. + */ public bool can_pause { get; private set; } + + /** + * Indicates if setting [[email protected]:position] has effect. + */ public bool can_seek { get; private set; } - public bool can_control { get; private set; } - // metadata - [CCode (notify = false)] - public HashTable<string,Variant> metadata { owned get; private set; } + /** + * Indicates if the player can be controlled with + * methods such as [[email protected]_pause]. + */ + public bool can_control { get; private set; } + /** + * Metadata hashtable of this player. + * In languages that cannot introspect this + * use [[email protected]_meta]. + */ + [CCode (notify = false)] // notified manually in sync + public HashTable<string, Variant> metadata { owned get; private set; } + + /** + * Currently playing track's id. + */ public string trackid { owned get; private set; } + + /** + * Length of the currently playing track in seconds. + */ public double length { get; private set; } + + /** + * The location of an image representing the track or album. + * You should always prefer to use [[email protected]:cover_art]. + */ public string art_url { owned get; private set; } + /** + * Title of the currently playing album. + */ public string album { owned get; private set; } + + /** + * Artists of the currently playing album. + */ public string album_artist { owned get; private set; } + + /** + * Artists of the currently playing track. + */ public string artist { owned get; private set; } + + /** + * Lyrics of the currently playing track. + */ public string lyrics { owned get; private set; } + + /** + * Title of the currently playing track. + */ public string title { owned get; private set; } + + /** + * Composers of the currently playing track. + */ public string composer { owned get; private set; } + + /** + * Comments of the currently playing track. + */ public string comments { owned get; private set; } - // cached cover art + /** + * Path of the cached [[email protected]:art_url]. + */ public string cover_art { owned get; private set; } + /** + * Lookup a key from [[email protected]:metadata]. + * This method is useful for languages that fail to introspect hashtables. + */ + public Variant? get_meta(string key) { + return metadata.lookup(key); + } + + /** + * Construct a Player that tracks a dbus name. For example "org.mpris.MediaPlayer2.spotify". + * The "org.mpris.MediaPlayer2." prefix can be leftout so simply "spotify" would mean the same. + * [[email protected]:available] indicates whether the player is actually running or not. + * + * @param name dbus name of the player. + */ public Player(string name) { - Object(bus_name: name.has_prefix("org.mpris.MediaPlayer2.") - ? name : "org.mpris.MediaPlayer2." + name); + bus_name = name.has_prefix("org.mpris.MediaPlayer2.") + ? name : @"org.mpris.MediaPlayer2.$name"; } private void sync() { @@ -195,7 +417,7 @@ public class Player : Object { fullscreen = proxy.fullscreen; can_set_fullscreen = proxy.can_set_fullscreen; can_raise = proxy.can_raise; - has_track_list = proxy.has_track_list; + // has_track_list = proxy.has_track_list; identity = proxy.identity; entry = proxy.desktop_entry; supported_uri_schemas = proxy.supported_uri_schemas; @@ -310,10 +532,6 @@ public class Player : Object { } } - public Variant? get_meta(string key) { - return metadata.lookup(key); - } - private string get_str(string key) { if (metadata.get(key) == null) return ""; @@ -349,7 +567,7 @@ public class Player : Object { } } - public void try_proxy() throws Error { + private void try_proxy() throws Error { if (proxy != null) return; @@ -360,13 +578,14 @@ public class Player : Object { ); if (proxy.g_name_owner != null) - appeared(); + available = false; proxy.notify["g-name-owner"].connect(() => { - if (proxy.g_name_owner != null) - appeared(); - else - closed(); + if (proxy.g_name_owner != null) { + available = true; + } else { + available = false; + } }); proxy.g_properties_changed.connect(sync); @@ -387,12 +606,12 @@ public class Player : Object { } } -public enum PlaybackStatus { +public enum AstalMpris.PlaybackStatus { PLAYING, PAUSED, STOPPED; - public static PlaybackStatus from_string(string? str) { + internal static PlaybackStatus from_string(string? str) { switch (str) { case "Playing": return PLAYING; @@ -404,7 +623,7 @@ public enum PlaybackStatus { } } - public string to_string() { + internal string to_string() { switch (this) { case PLAYING: return "Playing"; @@ -417,13 +636,16 @@ public enum PlaybackStatus { } } -public enum Loop { +public enum AstalMpris.Loop { UNSUPPORTED, + /** The playback will stop when there are no more tracks to play. */ NONE, + /** The current track will start again from the begining once it has finished playing. */ TRACK, + /** The playback loops through a list of tracks. */ PLAYLIST; - public static Loop from_string(string? str) { + internal static Loop from_string(string? str) { switch (str) { case "None": return NONE; @@ -436,7 +658,7 @@ public enum Loop { } } - public string? to_string() { + internal string? to_string() { switch (this) { case NONE: return "None"; @@ -450,16 +672,18 @@ public enum Loop { } } -public enum Shuffle { +public enum AstalMpris.Shuffle { UNSUPPORTED, + /** Playback is progressing through a playlist in some other order. */ ON, + /** Playback is progressing linearly through a playlist. */ OFF; - public static Shuffle from_bool(bool b) { + internal static Shuffle from_bool(bool b) { return b ? Shuffle.ON : Shuffle.OFF; } - public string? to_string() { + internal string? to_string() { switch (this) { case OFF: return "Off"; @@ -470,4 +694,3 @@ public enum Shuffle { } } } -} diff --git a/lib/notifd/gir.py b/lib/notifd/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/notifd/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/notifd/meson.build b/lib/notifd/meson.build index b6ef59a..3d4de95 100644 --- a/lib/notifd/meson.build +++ b/lib/notifd/meson.build @@ -3,7 +3,7 @@ project( 'vala', 'c', version: run_command('cat', join_paths(meson.project_source_root(), 'version')).stdout().strip(), - meson_version: '>= 0.62.0', + meson_version: '>= 0.63.0', default_options: [ 'warning_level=2', 'werror=false', @@ -53,84 +53,30 @@ if get_option('lib') meson.project_name(), sources, dependencies: deps, - vala_args: ['--vapi-comments', '--ccode'], + vala_args: ['--vapi-comments'], vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, version: meson.project_version(), install: true, - install_dir: [true, true, true, true], + install_dir: [true, true, true], ) - # import('gnome').generate_gir( - # lib, - # sources: [], - # nsversion: api_version, - # namespace: namespace, - # symbol_prefix: meson.project_name().replace('-', '_'), - # identifier_prefix: namespace, - # includes: ['GObject-2.0'], - # header: meson.project_name() + '.h', - # export_packages: meson.project_name() + '-' + api_version, - # install: true, - # ) + pkgs = [] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach - # custom_target( - # gir, - # command: [ - # find_program('g-ir-scanner'), - # '--namespace=' + namespace, - # '--nsversion=' + api_version, - # '--library=' + meson.project_name(), - # '--include=GObject-2.0', - # '--output=' + gir, - # '--symbol-prefix=' + meson.project_name().replace('-', '_'), - # '--identifier-prefix=' + namespace, - # ] - # + pkgs - # + ['@INPUT@'], - # output: gir, - # depends: lib, - # input: meson.current_build_dir() / meson.project_name() + '.h', - # install: true, - # install_dir: get_option('datadir') / 'gir-1.0', - # ) - - # custom_target( - # gir, - # command: [ - # find_program('g-ir-scanner'), - # '--namespace=' + namespace, - # '--nsversion=' + api_version, - # '--library=' + meson.project_name(), - # '--include=GObject-2.0', - # '--output=' + gir, - # '--symbol-prefix=' + meson.project_name().replace('-', '_'), - # '--identifier-prefix=' + namespace, - # ] - # + pkgs - # + ['@INPUT@'], - # input: lib.extract_all_objects(), - # output: gir, - # depends: lib, - # install: true, - # install_dir: get_option('datadir') / 'gir-1.0', - # ) - - # pkgs = [] - # foreach dep : deps - # pkgs += ['--pkg=' + dep.name()] - # endforeach - # - # gir_tgt = custom_target( - # gir, - # command: [find_program('valadoc'), '-o', 'docs', '--gir', gir] + pkgs + sources, - # input: sources, - # depends: lib, - # output: gir, - # install: true, - # install_dir: get_option('datadir') / 'gir-1.0', - # ) + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', + ) custom_target( typelib, @@ -142,7 +88,7 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) diff --git a/lib/notifd/notifd.vala b/lib/notifd/notifd.vala index 6ca25fa..807e40a 100644 --- a/lib/notifd/notifd.vala +++ b/lib/notifd/notifd.vala @@ -1,5 +1,5 @@ /** - * Get the singleton instance of {@link Notifd} + * Get the singleton instance of [[email protected]] */ namespace AstalNotifd { public Notifd get_default() { @@ -74,7 +74,7 @@ public class AstalNotifd.Notifd : Object { } /** - * Gets the {@link Notification} with id or null if there is no such Notification. + * Gets the [[email protected]] with id or null if there is no such Notification. */ public Notification get_notification(uint id) { return proxy != null ? proxy.get_notification(id) : daemon.get_notification(id); @@ -85,7 +85,7 @@ public class AstalNotifd.Notifd : Object { } /** - * Emitted when the daemon receives a {@link Notification}. + * Emitted when the daemon receives a [[email protected]]. * * @param id The ID of the Notification. * @param replaced Indicates if an existing Notification was replaced. @@ -93,7 +93,7 @@ public class AstalNotifd.Notifd : Object { public signal void notified(uint id, bool replaced); /** - * Emitted when a {@link Notification} is resolved. + * Emitted when a [[email protected]] is resolved. * * @param id The ID of the Notification. * @param reason The reason how the Notification was resolved. diff --git a/lib/notifd/notification.vala b/lib/notifd/notification.vala index 5db3fe2..527a352 100644 --- a/lib/notifd/notification.vala +++ b/lib/notifd/notification.vala @@ -38,19 +38,24 @@ public class AstalNotifd.Notification : Object { public int expire_timeout { internal set; get; } /** - * List of {@link Action} of the notification. + * List of [[email protected]] of the notification. * - * Can be invoked by calling {@link Notification.invoke} with the action's id. + * Can be invoked by calling [[email protected]] with the action's id. */ public List<Action?> actions { get { return _actions; } } /** Path of an image */ public string image { get { return get_str_hint("image-path"); } } - /** Indicates whether {@link Action} identifier should be interpreted as a named icon. */ + /** + * Indicates whether [[email protected]] + * identifier should be interpreted as a named icon. + */ public bool action_icons { get { return get_bool_hint("action-icons"); } } - /** [[https://specifications.freedesktop.org/notification-spec/latest/categories.html|Category of the notification.]] */ + /** + * [[https://specifications.freedesktop.org/notification-spec/latest/categories.html]] + */ public string category { get { return get_str_hint("category"); } } /** Specifies the name of the desktop filename representing the calling program. */ @@ -71,13 +76,19 @@ public class AstalNotifd.Notification : Object { /** Indicates that the notification should be excluded from persistency. */ public bool transient { get { return get_bool_hint("transient"); } } - /** Specifies the X location on the screen that the notification should point to. The "y" hint must also be specified. */ + /** + * Specifies the X location on the screen that the notification should point to. + * The "y" hint must also be specified. + */ public int x { get { return get_int_hint("x"); } } - /** Specifies the Y location on the screen that the notification should point to. The "x" hint must also be specified. */ + /** + * Specifies the Y location on the screen that the notification should point to. + * The "x" hint must also be specified. + */ public int y { get { return get_int_hint("y"); } } - /** {@link Urgency} level of the notification. */ + /** [[email protected]] level of the notification. */ public Urgency urgency { get { return get_byte_hint("urgency"); } } internal Notification( @@ -141,24 +152,23 @@ public class AstalNotifd.Notification : Object { } /** - * Emitted when this {@link Notification} is resolved. + * Emitted when this this notification is resolved. * * @param reason The reason how the Notification was resolved. */ public signal void resolved(ClosedReason reason); /** - * Emitted when the user dismisses this {@link Notification} + * Emitted when the user dismisses this notification. * * @see dismiss */ public signal void dismissed(); /** - * Emitted when an {@link Action} of this {@link Notification} is invoked. + * Emitted when an [[email protected]] of this notification is invoked. * * @param action_id id of the invoked action - * @see invoke */ public signal void invoked(string action_id); @@ -171,7 +181,7 @@ public class AstalNotifd.Notification : Object { public void dismiss() { dismissed(); } /** - * Invoke an {@link Action} of this {@link Notification} + * Invoke an [[email protected]] of this notification. * * Note that this method just notifies the client that this action was invoked * by the user. If for example this notification persists through the lifetime diff --git a/lib/tray/gir.py b/lib/tray/gir.py new file mode 120000 index 0000000..b5b4f1d --- /dev/null +++ b/lib/tray/gir.py @@ -0,0 +1 @@ +../gir.py
\ No newline at end of file diff --git a/lib/tray/meson.build b/lib/tray/meson.build index 421f33d..fbb6672 100644 --- a/lib/tray/meson.build +++ b/lib/tray/meson.build @@ -3,7 +3,7 @@ project( 'vala', 'c', version: run_command('cat', join_paths(meson.project_source_root(), 'version')).stdout().strip(), - meson_version: '>= 0.62.0', + meson_version: '>= 0.63.0', default_options: [ 'warning_level=2', 'werror=false', @@ -18,8 +18,9 @@ assert( version_split = meson.project_version().split('.') api_version = version_split[0] + '.' + version_split[1] -gir = 'AstalTray-' + api_version + '.gir' -typelib = 'AstalTray-' + api_version + '.typelib' +namespace = 'AstalTray' +gir = namespace + '-' + api_version + '.gir' +typelib = namespace + '-' + api_version + '.typelib' config = configure_file( input: 'config.vala.in', @@ -61,7 +62,7 @@ dbusmenu_libs = run_command( check: true, ).stdout().strip() -sources = [config, 'tray.vala', 'watcher.vala', 'trayItem.vala'] +sources = [config] + files('tray.vala', 'watcher.vala', 'trayItem.vala') if get_option('lib') lib = library( @@ -70,23 +71,29 @@ if get_option('lib') dependencies: deps, vala_header: meson.project_name() + '.h', vala_vapi: meson.project_name() + '-' + api_version + '.vapi', - vala_gir: gir, - vala_args: ['--pkg', 'DbusmenuGtk3-0.4', '--pkg', 'Dbusmenu-0.4'], + vala_args: ['--vapi-comments', '--pkg', 'DbusmenuGtk3-0.4', '--pkg', 'Dbusmenu-0.4'], version: meson.project_version(), c_args: dbusmenu_cflags.split(' '), link_args: dbusmenu_libs.split(' '), install: true, - install_dir: [true, true, true, true], + install_dir: true, ) - import('pkgconfig').generate( - lib, - name: meson.project_name(), - filebase: meson.project_name() + '-' + api_version, - version: meson.project_version(), - subdirs: meson.project_name(), - requires: deps, - install_dir: get_option('libdir') / 'pkgconfig', + pkgs = ['--pkg', 'DbusmenuGtk3-0.4', '--pkg', 'Dbusmenu-0.4'] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', ) custom_target( @@ -99,10 +106,35 @@ if get_option('lib') ], input: lib, output: typelib, - depends: lib, + depends: [lib, gir_tgt], install: true, install_dir: get_option('libdir') / 'girepository-1.0', ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) + # + # custom_target( + # typelib, + # command: [ + # find_program('g-ir-compiler'), + # '--output', '@OUTPUT@', + # '--shared-library', get_option('prefix') / get_option('libdir') / '@PLAINNAME@', + # meson.current_build_dir() / gir, + # ], + # input: lib, + # output: typelib, + # depends: lib, + # install: true, + # install_dir: get_option('libdir') / 'girepository-1.0', + # ) endif if get_option('cli') diff --git a/lib/tray/tray.vala b/lib/tray/tray.vala index 09b0643..4ea6765 100644 --- a/lib/tray/tray.vala +++ b/lib/tray/tray.vala @@ -2,7 +2,7 @@ namespace AstalTray { [DBus (name="org.kde.StatusNotifierWatcher")] internal interface IWatcher : Object { public abstract string[] RegisteredStatusNotifierItems { owned get; } - public abstract int ProtocolVersion { owned get; } + public abstract int ProtocolVersion { get; } public abstract void RegisterStatusNotifierItem(string service, BusName sender) throws DBusError, IOError; public abstract void RegisterStatusNotifierHost(string service) throws DBusError, IOError; @@ -12,13 +12,19 @@ internal interface IWatcher : Object { public signal void StatusNotifierHostRegistered(); public signal void StatusNotifierHostUnregistered(); } - +/** + * Get the singleton instance of [[email protected]] + */ public Tray get_default() { return Tray.get_default(); } public class Tray : Object { private static Tray? instance; + + /** + * Get the singleton instance of [[email protected]] + */ public static unowned Tray get_default() { if (instance == null) instance = new Tray(); @@ -32,13 +38,22 @@ public class Tray : Object { private HashTable<string, TrayItem> _items = new HashTable<string, TrayItem>(str_hash, str_equal); + /** + * List of currently registered tray items + */ public List<weak TrayItem> items { owned get { return _items.get_values(); }} - public signal void item_added(string service) { + /** + * emitted when a new tray item was added. + */ + public signal void item_added(string item_id) { notify_property("items"); } - public signal void item_removed(string service) { + /** + * emitted when a tray item was removed. + */ + public signal void item_removed(string item_id) { notify_property("items"); } @@ -128,8 +143,11 @@ public class Tray : Object { item_removed(service); } - public TrayItem get_item(string service) { - return _items.get(service); + /** + * gets the TrayItem with the given item-id. + */ + public TrayItem get_item(string item_id) { + return _items.get(item_id); } } } diff --git a/lib/tray/trayItem.vala b/lib/tray/trayItem.vala index d5e8603..db0e6d4 100644 --- a/lib/tray/trayItem.vala +++ b/lib/tray/trayItem.vala @@ -57,12 +57,12 @@ public enum Status { [DBus (name="org.kde.StatusNotifierItem")] internal interface IItem : DBusProxy { public abstract string Title { owned get; } - public abstract Category Category { owned get; } - public abstract Status Status { owned get; } + public abstract Category Category { get; } + public abstract Status Status { get; } public abstract Tooltip? ToolTip { owned get; } public abstract string Id { owned get; } public abstract string? IconThemePath { owned get; } - public abstract bool ItemIsMenu { owned get; } + public abstract bool ItemIsMenu { get; } public abstract ObjectPath? Menu { owned get; } public abstract string IconName { owned get; } public abstract Pixmap[] IconPixmap { owned get; } @@ -88,11 +88,23 @@ public class TrayItem : Object { private IItem proxy; private List<ulong> connection_ids; + + /** The Title of the TrayItem */ public string title { owned get { return proxy.Title; } } + + /** The category this item belongs to */ public Category category { get { return proxy.Category; } } + + /** the current status of this item */ public Status status { get { return proxy.Status; } } + + /** the tooltip of this item */ public Tooltip? tooltip { owned get { return proxy.ToolTip; } } - + + /** + * a markup representation of the tooltip. This is basically equvivalent + * to `tooltip.title \n tooltip.description` + */ public string tooltip_markup { owned get { if (proxy.ToolTip == null) @@ -106,10 +118,30 @@ public class TrayItem : Object { } } + /** the id of the item. This id is specified by the tray app.*/ public string id { owned get { return proxy.Id ;} } - public string icon_theme_path { owned get { return proxy.IconThemePath ;} } + + /** + * If set, this only supports the menu, so showing the menu should be prefered + * over calling [[email protected]]. + */ public bool is_menu { get { return proxy.ItemIsMenu ;} } - + + /** + * the icon theme path, where to look for the [[email protected]:icon-name]. + * + * It is recommended to use the [[email protected]:gicon] property, + * which does the icon lookups for you. + */ + public string icon_theme_path { owned get { return proxy.IconThemePath ;} } + + /** + * the name of the icon. This should be looked up in the [[email protected]:icon-theme-path] + * if set or in the currently used icon theme otherwise. + * + * It is recommended to use the [[email protected]:gicon] property, + * which does the icon lookups for you. + */ public string icon_name { owned get { return proxy.Status == Status.NEEDS_ATTENTION @@ -117,17 +149,30 @@ public class TrayItem : Object { : proxy.IconName; } } - + + /** + * a pixbuf containing the icon. + * + * It is recommended to use the [[email protected]:gicon] property, + * which does the icon lookups for you. + */ public Gdk.Pixbuf icon_pixbuf { owned get { return _get_icon_pixbuf(); } } + /** + * contains the items icon. This property is intended to be used with the gicon property + * of the Icon widget and the recommended way to display the icon. + * This property unifies the [[email protected]:icon-name], + * [[email protected]:icon-theme-path] and [[email protected]:icon-pixbuf] properties. + */ public GLib.Icon gicon { get; private set; } + /** the id of the item used to uniquely identify the TrayItems by this lib.*/ public string item_id { get; private set; } public signal void changed(); public signal void ready(); - public TrayItem(string service, string path) { + internal TrayItem(string service, string path) { connection_ids = new List<ulong>(); item_id = service + path; setup_proxy.begin(service, path, (_, res) => setup_proxy.end(res)); @@ -229,7 +274,10 @@ public class TrayItem : Object { } ); } - + + /** + * send an activate request to the tray app. + */ public void activate(int x, int y) { try { proxy.Activate(x, y); @@ -239,6 +287,9 @@ public class TrayItem : Object { } } + /** + * send a secondary activate request to the tray app. + */ public void secondary_activate(int x, int y) { try { proxy.SecondaryActivate(x, y); @@ -248,6 +299,10 @@ public class TrayItem : Object { } } + /** + * send a scroll request to the tray app. + * valid values for the orientation are "horizontal" and "vertical". + */ public void scroll(int delta, string orientation) { try { proxy.Scroll(delta, orientation); @@ -257,7 +312,9 @@ public class TrayItem : Object { } } - + /** + * creates a new Gtk Menu for this item. + */ public Gtk.Menu? create_menu() { if (proxy.Menu == null) return null; @@ -267,7 +324,7 @@ public class TrayItem : Object { proxy.Menu); } - public Gdk.Pixbuf? _get_icon_pixbuf() { + private Gdk.Pixbuf? _get_icon_pixbuf() { Pixmap[] pixmaps = proxy.Status == Status.NEEDS_ATTENTION ? proxy.AttentionIconPixmap : proxy.IconPixmap; |