From 67a297a38792201bd43fa5b81b5ccd5ede7f43f2 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Thu, 26 Mar 2026 22:20:04 +0100 Subject: [PATCH 1/5] DaemonManager: Handle windows on title notify We will need to handle windows before they are shown for the ibus popup and osk to make sure they get added to the shell group when they are shown. --- src/DaemonManager.vala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DaemonManager.vala b/src/DaemonManager.vala index 7303efca3..fb11f3cd1 100644 --- a/src/DaemonManager.vala +++ b/src/DaemonManager.vala @@ -32,7 +32,7 @@ public class Gala.DaemonManager : GLib.Object { client = new ManagedClient (display, args); client.window_created.connect ((window) => { - window.shown.connect (handle_daemon_window); + window.notify["title"].connect ((obj, pspec) => handle_daemon_window ((Meta.Window) obj)); }); } From 140f5ff70f13aa022a26c97274a903c8bb298080 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:05:30 +0100 Subject: [PATCH 2/5] Implement the InputMethod --- .github/workflows/main.yml | 6 +- docs/meson.build | 1 + meson.build | 3 +- src/InputMethod.vala | 316 +++++++++++++++++++++++++++++++++++++ src/WindowManager.vala | 5 + src/meson.build | 1 + 6 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 src/InputMethod.vala diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 201ea5393..6104f3a23 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,7 @@ jobs: - name: Install Dependencies run: | apt update - apt install -y gettext gsettings-desktop-schemas-dev libatk-bridge2.0-dev libclutter-1.0-dev libgee-0.8-dev libglib2.0-dev libgnome-desktop-4-dev libgnome-bg-4-dev libgranite-dev libgtk-3-dev ${{ matrix.mutter_pkg }} libsoup-3.0-dev libsqlite3-dev meson systemd-dev valac valadoc + apt install -y gettext gsettings-desktop-schemas-dev libatk-bridge2.0-dev libclutter-1.0-dev libgee-0.8-dev libglib2.0-dev libgnome-desktop-4-dev libgnome-bg-4-dev libgranite-dev libgtk-3-dev libibus-1.0-dev ${{ matrix.mutter_pkg }} libsoup-3.0-dev libsqlite3-dev meson systemd-dev valac valadoc - name: Build env: DESTDIR: out @@ -59,7 +59,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Dependencies run: | - dnf install -y desktop-file-utils gettext gsettings-desktop-schemas-devel atk-devel clutter-devel libgee-devel glib2-devel gnome-desktop3-devel granite-devel granite-7-devel gtk3-devel gtk4-devel libhandy-devel mutter-devel sqlite-devel meson valac valadoc + dnf install -y desktop-file-utils gettext gsettings-desktop-schemas-devel atk-devel clutter-devel libgee-devel glib2-devel gnome-desktop3-devel granite-devel granite-7-devel gtk3-devel gtk4-devel ibus-devel libhandy-devel mutter-devel sqlite-devel meson valac valadoc - name: Build env: DESTDIR: out @@ -78,7 +78,7 @@ jobs: run: | zypper addrepo https://download.opensuse.org/repositories/X11:Pantheon/16.0/X11:Pantheon.repo zypper --gpg-auto-import-keys refresh - zypper --non-interactive install tar git desktop-file-utils gsettings-desktop-schemas-devel libatk-1_0-0 clutter-devel libgee-devel glib2-devel libgnome-desktop-4-devel granite6-devel granite-devel gtk3-devel gtk4-devel libhandy-devel mutter-devel sqlite3-devel meson vala valadoc gcc + zypper --non-interactive install tar git desktop-file-utils gsettings-desktop-schemas-devel libatk-1_0-0 clutter-devel libgee-devel glib2-devel libgnome-desktop-4-devel granite6-devel granite-devel gtk3-devel gtk4-devel ibus-devel libhandy-devel mutter-devel sqlite3-devel meson vala valadoc gcc - uses: actions/checkout@v6 - name: Build env: diff --git a/docs/meson.build b/docs/meson.build index 130ccc20d..4e0e537ce 100644 --- a/docs/meson.build +++ b/docs/meson.build @@ -43,6 +43,7 @@ all_doc_target = custom_target( '--pkg', 'atk-bridge-2.0', '--pkg', 'gnome-bg-4', '--pkg', 'gnome-desktop-4', + '--pkg', 'ibus-1.0', '--pkg', 'libsystemd', '--pkg', 'wayland-server', '--pkg', 'pantheon-desktop-shell', diff --git a/meson.build b/meson.build index 4663fa1da..9e99604a9 100644 --- a/meson.build +++ b/meson.build @@ -70,6 +70,7 @@ gio_unix_dep = dependency('gio-unix-2.0', version: '>= @0@'.format(glib_version_ gmodule_dep = dependency('gmodule-2.0') gee_dep = dependency('gee-0.8') gnome_desktop_dep = dependency('gnome-desktop-4') +ibus_dep = dependency('ibus-1.0') gnome_bg_dep = dependency('gnome-bg-4') m_dep = cc.find_library('m', required: false) posix_dep = vala.find_library('posix', required: false) @@ -171,7 +172,7 @@ endif add_project_arguments(vala_flags, language: 'vala') add_project_link_arguments(['-Wl,-rpath,@0@'.format(mutter_typelib_dir)], language: 'c') -gala_base_dep = [atk_bridge_dep, gdk_pixbuf_def, gtk4_dep, glib_dep, gobject_dep, gio_dep, gio_unix_dep, gmodule_dep, gee_dep, mutter_dep, gnome_desktop_dep, gnome_bg_dep, m_dep, posix_dep, sqlite3_dep, xext_dep] +gala_base_dep = [atk_bridge_dep, gdk_pixbuf_def, gtk4_dep, glib_dep, gobject_dep, gio_dep, gio_unix_dep, gmodule_dep, gee_dep, mutter_dep, gnome_desktop_dep, gnome_bg_dep, ibus_dep, m_dep, posix_dep, sqlite3_dep, xext_dep] if get_option('systemd') gala_base_dep += systemd_dep diff --git a/src/InputMethod.vala b/src/InputMethod.vala new file mode 100644 index 000000000..5b4aae258 --- /dev/null +++ b/src/InputMethod.vala @@ -0,0 +1,316 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.InputMethod : Clutter.InputMethod { + public Meta.Display display { private get; construct; } + public Graphene.Rect cursor_location { get; private set; } + + private IBus.Bus bus; + private IBus.InputContext? context; + + private bool preedit_visible; + private string? preedit_text; + private uint preedit_cursor; + private uint preedit_anchor; + private Clutter.PreeditResetMode preedit_mode; + + private string? surrounding_text; + private uint surrounding_cursor; + private uint surrounding_anchor; + + private IBus.InputPurpose input_purpose; + private IBus.InputHints input_hints; + + public InputMethod (Meta.Display display) { + Object (display: display); + } + + construct { + IBus.init (); + + bus = new IBus.Bus.async (); + bus.connected.connect (on_connected); + + if (bus.is_connected ()) { + on_connected (); + } + } + + private void on_connected () { + bus.create_input_context_async.begin ("gala", -1, null, on_input_context_created); + } + + private void on_input_context_created (Object? obj, AsyncResult res) { + try { + context = bus.create_input_context_async_finish (res); + } catch (Error e) { + warning ("Failed to create IBus input context: %s", e.message); + return; + } + + context.commit_text.connect (on_commit_text); + context.require_surrounding_text.connect (on_require_surrounding_text); + context.delete_surrounding_text.connect (on_delete_surrounding_text); + context.update_preedit_text.connect (on_update_preedit_text); + context.update_preedit_text_with_mode.connect (on_update_preedit_text_with_mode); + context.show_preedit_text.connect (on_show_preedit_text); + context.hide_preedit_text.connect (on_hide_preedit_text); + context.forward_key_event.connect (on_forward_key_event); + context.destroy.connect (on_destroy); + + update_capabilities (); + } + + private void update_capabilities () { + IBus.Capabilite caps = PREEDIT_TEXT | FOCUS; + + if (surrounding_text != null) { + caps |= SURROUNDING_TEXT; + } + + context?.set_capabilities (caps); + } + + private void on_commit_text (IBus.Text text) { + commit (text.text); + } + + private void on_require_surrounding_text () { + request_surrounding (); + } + + private void on_delete_surrounding_text (int offset, uint length) { + delete_surrounding (offset, length); + } + + private void on_update_preedit_text (IBus.Text text, uint cursor_pos, bool visible) { + on_update_preedit_text_with_mode (text, cursor_pos, visible, preedit_mode); + } + + private void on_update_preedit_text_with_mode (IBus.Text text, uint cursor_pos, bool visible, uint mode) { + var preedit = text.text; + + if (preedit == "") { + preedit = null; + } + + var anchor = cursor_pos; + + if (visible) { + set_preedit_text (preedit, cursor_pos, anchor, mode); + } else if (preedit_visible) { + set_preedit_text (null, cursor_pos, anchor, mode); + } + + preedit_visible = visible; + preedit_text = preedit; + preedit_cursor = cursor_pos; + preedit_anchor = anchor; + preedit_mode = (Clutter.PreeditResetMode) mode; + } + + private void on_show_preedit_text () { + preedit_visible = true; + set_preedit_text (preedit_text, preedit_cursor, preedit_anchor, preedit_mode); + } + + private void on_hide_preedit_text () { + set_preedit_text (null, preedit_cursor, preedit_anchor, preedit_mode); + preedit_visible = false; + } + + private void on_forward_key_event (uint keyval, uint keycode, uint _modifiers) { + var modifiers = (IBus.ModifierType) _modifiers; + var press = !(IBus.ModifierType.RELEASE_MASK in modifiers); + modifiers &= ~IBus.ModifierType.RELEASE_MASK; + + var time = display.get_current_time (); + + forward_key (keyval, keycode + 8, modifiers & Clutter.ModifierType.MODIFIER_MASK, time, press); + } + + private void on_destroy () { + debug ("IBus input context was destroyed"); + context = null; + } + + private void maybe_request_surrounding () { + if (context != null && context.needs_surrounding_text ()) { + request_surrounding (); + } + } + + public override void focus_in (Clutter.InputFocus actor) { + update_capabilities (); + context?.set_content_type (input_purpose, input_hints); + maybe_request_surrounding (); + + context?.focus_in (); + } + + public override void focus_out () { + context?.set_content_type (0, 0); + context?.reset (); + + context?.focus_out (); + + if (preedit_visible) { + set_preedit_text (null, preedit_cursor, preedit_anchor, preedit_mode); + preedit_text = null; + } + } + + public override void reset () { + context?.reset (); + maybe_request_surrounding (); + + surrounding_text = null; + surrounding_cursor = 0; + surrounding_anchor = 0; + preedit_text = null; + } + + public override void set_cursor_location (Graphene.Rect rect) { + context?.set_cursor_location ((int) rect.origin.x, (int) rect.origin.y, (int) rect.size.width, (int) rect.size.height); + cursor_location = rect; + } + + public override void set_surrounding (string text, uint cursor_index, uint anchor_index) { + var update_caps = (surrounding_text == null) != (text == null); + + surrounding_text = text; + surrounding_cursor = cursor_index; + surrounding_anchor = anchor_index; + + if (update_caps) { + update_capabilities (); + } + + if (text == null) { + return; + } + + var ibus_text = new IBus.Text.from_string (text); + context?.set_surrounding_text (ibus_text, cursor_index, anchor_index); + } + + public override bool filter_key_event (Clutter.Event event) { + if (context == null) { + return false; + } + + var state = (IBus.ModifierType) event.get_state (); + + if (IBus.ModifierType.IGNORED_MASK in state) { + return false; + } + + if (event.get_type () == Clutter.EventType.KEY_RELEASE) { + state |= IBus.ModifierType.RELEASE_MASK; + } + + context.process_key_event_async.begin ( + event.get_key_symbol (), event.get_key_code () - 8, state, -1, null, + (obj, res) => { + try { + var handled = context.process_key_event_async_finish (res); + notify_key_event (event, handled); + } catch (Error e) { + warning ("Failed to process key event on IM: %s", e.message); + } + } + ); + + return true; + } + + public override void update_content_hints (Clutter.InputContentHintFlags hints) { + IBus.InputHints ibus_hints = 0; + + if (COMPLETION in hints) { + ibus_hints |= IBus.InputHints.WORD_COMPLETION; + } + + if (SPELLCHECK in hints) { + ibus_hints |= IBus.InputHints.SPELLCHECK; + } + + if (AUTO_CAPITALIZATION in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_SENTENCES; + } + + if (LOWERCASE in hints) { + ibus_hints |= IBus.InputHints.LOWERCASE; + } + + if (UPPERCASE in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_CHARS; + } + + if (TITLECASE in hints) { + ibus_hints |= IBus.InputHints.UPPERCASE_WORDS; + } + + if (SENSITIVE_DATA in hints) { + ibus_hints |= IBus.InputHints.PRIVATE; + } + + if (HIDDEN_TEXT in hints) { + // TODO: Probably needs a newer version + // ibus_hints |= IBus.InputHints.HIDDEN_TEXT; + } + + input_hints = ibus_hints; + + context?.set_content_type (input_purpose, input_hints); + } + + public override void update_content_purpose (Clutter.InputContentPurpose purpose) { + IBus.InputPurpose ibus_purpose; + + switch (purpose) { + case NORMAL: + ibus_purpose = FREE_FORM; + break; + case ALPHA: + ibus_purpose = ALPHA; + break; + case DIGITS: + ibus_purpose = DIGITS; + break; + case NUMBER: + ibus_purpose = NUMBER; + break; + case PHONE: + ibus_purpose = PHONE; + break; + case URL: + ibus_purpose = URL; + break; + case EMAIL: + ibus_purpose = EMAIL; + break; + case NAME: + ibus_purpose = NAME; + break; + case PASSWORD: + ibus_purpose = PASSWORD; + break; + case TERMINAL: + ibus_purpose = TERMINAL; + break; + default: + warning ("Unknown input purpose: %d", purpose); + ibus_purpose = FREE_FORM; + break; + } + + input_purpose = ibus_purpose; + + context?.set_content_type (input_purpose, input_hints); + } +} diff --git a/src/WindowManager.vala b/src/WindowManager.vala index 23bc37c70..520dafa54 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -90,6 +90,8 @@ namespace Gala { private KeyboardManager keyboard_manager; + private InputMethod input_method; + public WindowTracker? window_tracker { get; private set; } private WindowMover window_mover; @@ -140,6 +142,9 @@ namespace Gala { } public override void start () { + input_method = new InputMethod (get_display ()); + Clutter.get_default_backend ().set_input_method (input_method); + ShellClientsManager.init (this); BlurManager.init (this); daemon_manager = new DaemonManager (get_display ()); diff --git a/src/meson.build b/src/meson.build index aa42ee15e..a19bbccef 100644 --- a/src/meson.build +++ b/src/meson.build @@ -4,6 +4,7 @@ gala_bin_sources = files( 'DBusAccelerator.vala', 'DaemonManager.vala', 'DesktopIntegration.vala', + 'InputMethod.vala', 'InternalUtils.vala', 'KeyboardManager.vala', 'Main.vala', From 3d3f791ae742c20e114b9b3fd24a8f5a39cff030 Mon Sep 17 00:00:00 2001 From: Leonhard Date: Tue, 12 May 2026 20:12:00 +0200 Subject: [PATCH 3/5] WindowManager: Introduce an overlay window group --- src/WindowManager.vala | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/WindowManager.vala b/src/WindowManager.vala index 520dafa54..159828a18 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -21,6 +21,7 @@ namespace Gala { public enum WindowGroup { DESKTOP_SHELL, MODAL, + OVERLAY, } private const string OPEN_MULTITASKING_VIEW = "dbus-send --session --dest=org.pantheon.gala --print-reply /org/pantheon/gala org.pantheon.gala.PerformAction int32:1"; @@ -62,6 +63,8 @@ namespace Gala { */ public ModalGroup modal_group { get; private set; } + private Clutter.Actor overlay_group; + /** * {@inheritDoc} */ @@ -245,6 +248,7 @@ namespace Gala { * +-- shell group * +-- menu group * +-- modal group + * +-- overlay group (e.g. ibus popup, osk, etc.) * +-- feedback group (e.g. DND icons) * +-- pointer locator * +-- dwell click timer @@ -316,6 +320,9 @@ namespace Gala { modal_group.add_constraint (new Clutter.BindConstraint (stage, SIZE, 0)); ui_group.add_child (modal_group); + overlay_group = new Clutter.Actor (); + ui_group.add_child (overlay_group); + var feedback_group = display.get_compositor ().get_feedback_group (); stage.remove_child (feedback_group); ui_group.add_child (feedback_group); @@ -1067,6 +1074,7 @@ namespace Gala { switch (group) { case DESKTOP_SHELL: return shell_group; case MODAL: return modal_group.window_group; + case OVERLAY: return overlay_group; default: assert_not_reached (); } } From 8d88b67b928a7b267bc831242f7f265d5addabe0 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:07:59 +0100 Subject: [PATCH 4/5] ShellClients: Introduce an IBusCandidateWindow --- src/ShellClients/IBusCandidateWindow.vala | 23 +++++++++++++++++++++++ src/ShellClients/ShellClientsManager.vala | 21 ++++++++++++++++----- src/WindowManager.vala | 2 +- src/meson.build | 1 + 4 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 src/ShellClients/IBusCandidateWindow.vala diff --git a/src/ShellClients/IBusCandidateWindow.vala b/src/ShellClients/IBusCandidateWindow.vala new file mode 100644 index 000000000..2e8c73d3a --- /dev/null +++ b/src/ShellClients/IBusCandidateWindow.vala @@ -0,0 +1,23 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.IBusCandidateWindow : PositionedWindow { + public InputMethod im { get; construct; } + + public IBusCandidateWindow (InputMethod im, Meta.Window window) { + Object (im: im, window: window); + } + + construct { + im.notify["cursor-location"].connect (position_window); + } + + protected override void get_window_position (Mtk.Rectangle window_rect, out int x, out int y) { + x = (int) (im.cursor_location.origin.x + im.cursor_location.size.width); + y = (int) (im.cursor_location.origin.y + im.cursor_location.size.height); + } +} diff --git a/src/ShellClients/ShellClientsManager.vala b/src/ShellClients/ShellClientsManager.vala index 7ba9d573f..849b7ef82 100644 --- a/src/ShellClients/ShellClientsManager.vala +++ b/src/ShellClients/ShellClientsManager.vala @@ -8,12 +8,12 @@ public class Gala.ShellClientsManager : Object, GestureTarget { private static ShellClientsManager instance; - public static void init (WindowManagerGala wm) { + public static void init (WindowManagerGala wm, InputMethod im) { if (instance != null) { return; } - instance = new ShellClientsManager (wm); + instance = new ShellClientsManager (wm, im); } public static unowned ShellClientsManager? get_instance () { @@ -21,6 +21,7 @@ public class Gala.ShellClientsManager : Object, GestureTarget { } public WindowManagerGala wm { get; construct; } + public InputMethod im { get; construct; } private NotificationsClient notifications_client; private ManagedClient[] protocol_clients = {}; @@ -30,9 +31,10 @@ public class Gala.ShellClientsManager : Object, GestureTarget { private GLib.HashTable panel_windows = new GLib.HashTable (null, null); private GLib.HashTable positioned_windows = new GLib.HashTable (null, null); private GLib.HashTable monitor_label_windows = new GLib.HashTable (null, null); + private IBusCandidateWindow? ibus_candidate_window = null; - private ShellClientsManager (WindowManagerGala wm) { - Object (wm: wm); + private ShellClientsManager (WindowManagerGala wm, InputMethod im) { + Object (wm: wm, im: im); } construct { @@ -263,6 +265,14 @@ public class Gala.ShellClientsManager : Object, GestureTarget { window.unmanaging.connect_after ((_window) => monitor_label_windows.remove (_window)); } + public void make_ibus_candidate_window (Meta.Window window) requires (ibus_candidate_window == null) { + ibus_candidate_window = new IBusCandidateWindow (im, window); + + wm.override_window_group (window, OVERLAY); + + window.unmanaged.connect_after (() => ibus_candidate_window = null); + } + public void propagate (UpdateType update_type, GestureAction action, double progress) { foreach (var window in positioned_windows.get_values ()) { window.propagate (update_type, action, progress); @@ -278,7 +288,8 @@ public class Gala.ShellClientsManager : Object, GestureTarget { (window in positioned_windows && positioned_windows[window].modal) || (window in panel_windows) || (window in monitor_label_windows) || - NotificationStack.is_notification (window) + NotificationStack.is_notification (window) || + window == ibus_candidate_window?.window ); } diff --git a/src/WindowManager.vala b/src/WindowManager.vala index 159828a18..ce9b1dd3b 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -148,7 +148,7 @@ namespace Gala { input_method = new InputMethod (get_display ()); Clutter.get_default_backend ().set_input_method (input_method); - ShellClientsManager.init (this); + ShellClientsManager.init (this, input_method); BlurManager.init (this); daemon_manager = new DaemonManager (get_display ()); diff --git a/src/meson.build b/src/meson.build index a19bbccef..470aa1e81 100644 --- a/src/meson.build +++ b/src/meson.build @@ -46,6 +46,7 @@ gala_bin_sources = files( 'HotCorners/HotCornerManager.vala', 'ShellClients/ExtendedBehaviorWindow.vala', 'ShellClients/HideTracker.vala', + 'ShellClients/IBusCandidateWindow.vala', 'ShellClients/ManagedClient.vala', 'ShellClients/MonitorLabelWindow.vala', 'ShellClients/NotificationsClient.vala', From 8ec6ad606b87a73a86b64138c4991b7299df8aa2 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:09:09 +0100 Subject: [PATCH 5/5] Daemon: Implement the Candidate Popup for IBus --- daemon/IBus/Candidate.vala | 15 +++ daemon/IBus/CandidateArea.vala | 131 +++++++++++++++++++++++++++ daemon/IBus/CandidateBox.vala | 44 +++++++++ daemon/IBus/IBusCandidateWindow.vala | 123 +++++++++++++++++++++++++ daemon/IBus/IBusService.vala | 37 ++++++++ daemon/Main.vala | 6 ++ daemon/meson.build | 7 +- data/gala-daemon.css | 10 ++ src/DaemonManager.vala | 9 ++ 9 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 daemon/IBus/Candidate.vala create mode 100644 daemon/IBus/CandidateArea.vala create mode 100644 daemon/IBus/CandidateBox.vala create mode 100644 daemon/IBus/IBusCandidateWindow.vala create mode 100644 daemon/IBus/IBusService.vala diff --git a/daemon/IBus/Candidate.vala b/daemon/IBus/Candidate.vala new file mode 100644 index 000000000..0a7217753 --- /dev/null +++ b/daemon/IBus/Candidate.vala @@ -0,0 +1,15 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.Candidate : Object { + public string? label { get; construct; } + public string? candidate { get; construct; } + + public Candidate (string? label, string? candidate) { + Object (label: label, candidate: candidate); + } +} diff --git a/daemon/IBus/CandidateArea.vala b/daemon/IBus/CandidateArea.vala new file mode 100644 index 000000000..3f41e7257 --- /dev/null +++ b/daemon/IBus/CandidateArea.vala @@ -0,0 +1,131 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.CandidateArea : Granite.Bin { + private const string[] DEFAULT_LABELS = { + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "a", "b", "c", "d", "e", "f" + }; + + public IBus.PanelService service { get; construct; } + + private ListStore model; + private Gtk.SingleSelection selection_model; + private Gtk.ListView list_view; + + private Gtk.Button prev_page_button; + private Gtk.Button next_page_button; + private Granite.Box button_box; + + private Granite.Box content_box; + + public CandidateArea (IBus.PanelService service) { + Object (service: service); + } + + construct { + model = new ListStore (typeof (Candidate)); + + selection_model = new Gtk.SingleSelection (model); + + var factory = new Gtk.SignalListItemFactory (); + factory.setup.connect (on_setup); + factory.bind.connect (on_bind); + + list_view = new Gtk.ListView (selection_model, factory); + + prev_page_button = new Gtk.Button (); + prev_page_button.clicked.connect (service.page_up); + + next_page_button = new Gtk.Button (); + next_page_button.clicked.connect (service.page_down); + + button_box = new Granite.Box (HORIZONTAL, LINKED) { + hexpand = true + }; + button_box.append (prev_page_button); + button_box.append (next_page_button); + + content_box = new Granite.Box (VERTICAL); + content_box.append (list_view); + content_box.append (button_box); + + child = content_box; + } + + private void on_setup (Object obj) { + var item = (Gtk.ListItem) obj; + item.child = new CandidateBox (service, item); + } + + private void on_bind (Object obj) { + var item = (Gtk.ListItem) obj; + var candidate = (Candidate) item.item; + + var box = (CandidateBox) item.child; + box.set_candidate (candidate); + } + + public void update (IBus.LookupTable table) { + model.remove_all (); + + if (table.get_orientation () == IBus.Orientation.HORIZONTAL) { + update_orientation (HORIZONTAL); + } else { /* VERTICAL or SYSTEM */ + update_orientation (VERTICAL); + } + + var n_candidates = table.get_number_of_candidates (); + var page_size = table.get_page_size (); + + if (page_size == 0) { + /* I don't think 0 is intended to happen so print a warning */ + warning ("LookupTable page size is 0, using 5"); + page_size = 5; + } + + var cursor_pos = table.get_cursor_pos (); + var page = (uint) (cursor_pos / page_size); + + var start_index = page * page_size; + var end_index = uint.min (start_index + page_size, n_candidates); + + for (uint i = start_index; i < end_index; i++) { + var ibus_label = table.get_label (i)?.text; + var label = ibus_label != null && ibus_label.strip () != "" ? ibus_label : ( + i - start_index < DEFAULT_LABELS.length ? DEFAULT_LABELS[i - start_index] : null + ); + + var candidate = table.get_candidate (i)?.text; + + model.append (new Candidate (label, candidate)); + } + + selection_model.selected = table.get_cursor_in_page (); + + update_buttons (table.is_round (), page, (uint) ((n_candidates + page_size - 1) / page_size)); + } + + private void update_orientation (Gtk.Orientation orientation) { + content_box.orientation = orientation; + list_view.orientation = orientation; + + if (orientation == HORIZONTAL) { + prev_page_button.icon_name = "go-previous"; + next_page_button.icon_name = "go-next"; + } else { + prev_page_button.icon_name = "go-up"; + next_page_button.icon_name = "go-down"; + } + } + + private void update_buttons (bool wraps_around, uint page, uint n_pages) { + button_box.visible = n_pages > 1; + + prev_page_button.sensitive = wraps_around || page > 0; + next_page_button.sensitive = wraps_around || page < n_pages - 1; + } +} diff --git a/daemon/IBus/CandidateBox.vala b/daemon/IBus/CandidateBox.vala new file mode 100644 index 000000000..506225ebd --- /dev/null +++ b/daemon/IBus/CandidateBox.vala @@ -0,0 +1,44 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.CandidateBox : Granite.Bin { + public IBus.PanelService service { get; construct; } + public unowned Gtk.ListItem list_item { get; construct; } + + private Gtk.Label label_label; + private Gtk.Label candidate_label; + + public CandidateBox (IBus.PanelService service, Gtk.ListItem list_item) { + Object (service: service, list_item: list_item); + } + + construct { + label_label = new Gtk.Label (null); + label_label.add_css_class (Granite.CssClass.DIM); + + candidate_label = new Gtk.Label (null); + + var content_box = new Granite.Box (HORIZONTAL, HALF); + content_box.append (label_label); + content_box.append (candidate_label); + + child = content_box; + + var gesture_click = new Gtk.GestureClick (); + gesture_click.released.connect (on_clicked); + add_controller (gesture_click); + } + + private void on_clicked (Gtk.GestureClick gesture, int n_press, double x, double y) { + service.candidate_clicked (list_item.position, gesture.get_current_button (), gesture.get_current_event_state ()); + } + + public void set_candidate (Candidate candidate) { + label_label.label = candidate.label; + candidate_label.label = candidate.candidate; + } +} diff --git a/daemon/IBus/IBusCandidateWindow.vala b/daemon/IBus/IBusCandidateWindow.vala new file mode 100644 index 000000000..1d6dd9dc5 --- /dev/null +++ b/daemon/IBus/IBusCandidateWindow.vala @@ -0,0 +1,123 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.IBusCandidateWindow : Gtk.Window { + public IBus.PanelService service { get; construct; } + + private Gtk.Label preedit_text; + private Gtk.Label auxiliary_text; + private CandidateArea candidate_area; + + public IBusCandidateWindow (IBus.PanelService service) { + Object (service: service); + } + + construct { + preedit_text = new Gtk.Label (null) { + halign = START, + visible = false, + }; + + auxiliary_text = new Gtk.Label (null) { + halign = START, + visible = false, + }; + + candidate_area = new CandidateArea (service) { + hexpand = true, + visible = false, + }; + + var content_box = new Granite.Box (VERTICAL) { + margin_start = 6, + margin_end = 6, + margin_top = 6, + margin_bottom = 6, + }; + content_box.append (preedit_text); + content_box.append (auxiliary_text); + content_box.append (candidate_area); + + titlebar = new Gtk.Grid () { visible = false }; + child = content_box; + /* Used to identify the window for correct positioning in the wm */ + title = "IBUS_CANDIDATE"; + resizable = false; + + service.show_preedit_text.connect (on_show_preedit_text); + service.hide_preedit_text.connect (on_hide_preedit_text); + service.update_preedit_text.connect (on_update_preedit_text); + service.show_auxiliary_text.connect (on_show_auxiliary_text); + service.hide_auxiliary_text.connect (on_hide_auxiliary_text); + service.update_auxiliary_text.connect (on_update_auxiliary_text); + service.show_lookup_table.connect (on_show_lookup_table); + service.hide_lookup_table.connect (on_hide_lookup_table); + service.update_lookup_table.connect (on_update_lookup_table); + service.focus_out.connect (hide); + } + + private void update_visibility () { + var is_visible = preedit_text.visible || auxiliary_text.visible || candidate_area.visible; + + if (is_visible) { + present (); + } else { + hide (); + } + } + + private void on_show_preedit_text () { + preedit_text.visible = true; + update_visibility (); + } + + private void on_hide_preedit_text () { + preedit_text.visible = false; + update_visibility (); + } + + private void on_update_preedit_text (IBus.Text text, uint cursor_pos, bool visible) { + preedit_text.visible = visible; + preedit_text.label = text.text; + + update_visibility (); + } + + private void on_show_auxiliary_text () { + auxiliary_text.visible = true; + update_visibility (); + } + + private void on_hide_auxiliary_text () { + auxiliary_text.visible = false; + update_visibility (); + } + + private void on_update_auxiliary_text (IBus.Text text, bool visible) { + auxiliary_text.visible = visible; + auxiliary_text.label = text.text; + + update_visibility (); + } + + private void on_show_lookup_table () { + candidate_area.visible = true; + update_visibility (); + } + + private void on_hide_lookup_table () { + candidate_area.visible = false; + update_visibility (); + } + + private void on_update_lookup_table (IBus.LookupTable table, bool visible) { + candidate_area.visible = visible; + update_visibility (); + + candidate_area.update (table); + } +} diff --git a/daemon/IBus/IBusService.vala b/daemon/IBus/IBusService.vala new file mode 100644 index 000000000..8d7fbd4c5 --- /dev/null +++ b/daemon/IBus/IBusService.vala @@ -0,0 +1,37 @@ +/* + * Copyright 2026 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Authored by: Leonhard Kargl + */ + +public class Gala.Daemon.IBusService : Object { + private IBus.Bus bus; + private IBus.PanelService service; + private IBusCandidateWindow candidate_window; + + construct { + bus = new IBus.Bus.async (); + bus.connected.connect (on_connected); + } + + private void on_connected () { + bus.request_name_async.begin ( + IBus.SERVICE_PANEL, IBus.BusNameFlag.REPLACE_EXISTING, -1, null, + on_name_acquired + ); + } + + private void on_name_acquired (Object? obj, AsyncResult res) { + try { + bus.request_name_async_finish (res); + } catch (Error e) { + warning ("Failed to acquire bus name: %s", e.message); + return; + } + + /* We need to go via Object.new because we need to pass construct properties */ + service = (IBus.PanelService) Object.@new (typeof (IBus.PanelService), "connection", bus.get_connection (), "object-path", IBus.PATH_PANEL); + candidate_window = new IBusCandidateWindow (service); + } +} diff --git a/daemon/Main.vala b/daemon/Main.vala index bab809098..07a6c976d 100644 --- a/daemon/Main.vala +++ b/daemon/Main.vala @@ -4,10 +4,16 @@ */ public class Gala.Daemon.Application : Gtk.Application { + private IBusService ibus_service; + public Application () { Object (application_id: "org.pantheon.gala.daemon"); } + construct { + ibus_service = new IBusService (); + } + public override void startup () { base.startup (); diff --git a/daemon/meson.build b/daemon/meson.build index 671161ebb..cdee9dda7 100644 --- a/daemon/meson.build +++ b/daemon/meson.build @@ -5,6 +5,11 @@ gala_daemon_sources = files( 'MonitorLabel.vala', 'Window.vala', 'WindowMenu.vala', + 'IBus' / 'Candidate.vala', + 'IBus' / 'CandidateArea.vala', + 'IBus' / 'CandidateBox.vala', + 'IBus' / 'IBusService.vala', + 'IBus' / 'IBusCandidateWindow.vala' ) gtk4_dep = dependency('gtk4') @@ -16,6 +21,6 @@ executable( gala_common_enums, config_header, gala_resources, - dependencies: [gtk4_dep, granite7_dep], + dependencies: [gtk4_dep, granite7_dep, ibus_dep], install: true ) diff --git a/data/gala-daemon.css b/data/gala-daemon.css index 43a0f9eb1..bdb0fb1eb 100644 --- a/data/gala-daemon.css +++ b/data/gala-daemon.css @@ -16,3 +16,13 @@ daemon-window { margin: 1em; text-shadow: 0 1px 1px alpha(white, 0.1); } + +listview { + background: none; + border-spacing: 3px; +} + +listview > row { + padding: 3px; + border-radius: 4px; +} diff --git a/src/DaemonManager.vala b/src/DaemonManager.vala index fb11f3cd1..472bc0b67 100644 --- a/src/DaemonManager.vala +++ b/src/DaemonManager.vala @@ -32,6 +32,11 @@ public class Gala.DaemonManager : GLib.Object { client = new ManagedClient (display, args); client.window_created.connect ((window) => { +#if HAS_MUTTER49 + window.set_type (DOCK); +#elif HAS_MUTTER46 + client.wayland_client.make_dock (window); +#endif window.notify["title"].connect ((obj, pspec) => handle_daemon_window ((Meta.Window) obj)); }); } @@ -71,6 +76,10 @@ public class Gala.DaemonManager : GLib.Object { window.make_above (); window.stick (); break; + + case "IBUS_CANDIDATE": + ShellClientsManager.get_instance ().make_ibus_candidate_window (window); + break; } }