From 511ac8a1093cb52316bd54bcabc1f145c6e14c96 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Thu, 26 Mar 2026 22:20:04 +0100 Subject: [PATCH 1/4] 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 c167023dbd30f823c1d2ca889ff163bb975e01dd Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:05:30 +0100 Subject: [PATCH 2/4] 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 0e78f370d..bfd8e366a 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 a7c6a1193..ca49923c3 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -85,6 +85,8 @@ namespace Gala { private KeyboardManager keyboard_manager; + private InputMethod input_method; + public WindowTracker? window_tracker { get; private set; } private WindowMover window_mover; @@ -133,6 +135,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 a46020991232b03580d3c002d8195dd996e7663a Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:07:59 +0100 Subject: [PATCH 3/4] ShellClients: Introduce an IBusCandidateWindow --- src/ShellClients/IBusCandidateWindow.vala | 23 +++++++++++++++++++++++ src/ShellClients/ShellClientsManager.vala | 19 ++++++++++++++----- src/WindowManager.vala | 2 +- src/meson.build | 1 + 4 files changed, 39 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 84594cbad..c6f7b2e27 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 (WindowManager wm) { + public static void init (WindowManager 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 WindowManager 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 (WindowManager wm) { - Object (wm: wm); + private ShellClientsManager (WindowManager wm, InputMethod im) { + Object (wm: wm, im: im); } construct { @@ -257,6 +259,12 @@ 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); + + 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); @@ -272,7 +280,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 ca49923c3..ca9d60975 100644 --- a/src/WindowManager.vala +++ b/src/WindowManager.vala @@ -138,7 +138,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 7277bc08554045705cb11a250d32ca82fdbb56f3 Mon Sep 17 00:00:00 2001 From: Leonhard Kargl Date: Wed, 25 Mar 2026 17:09:09 +0100 Subject: [PATCH 4/4] 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; } }