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/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/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/DaemonManager.vala b/src/DaemonManager.vala index 7303efca3..472bc0b67 100644 --- a/src/DaemonManager.vala +++ b/src/DaemonManager.vala @@ -32,7 +32,12 @@ public class Gala.DaemonManager : GLib.Object { client = new ManagedClient (display, args); client.window_created.connect ((window) => { - window.shown.connect (handle_daemon_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; } } 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/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 a7c6a1193..ca9d60975 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,7 +135,10 @@ namespace Gala { } public override void start () { - ShellClientsManager.init (this); + input_method = new InputMethod (get_display ()); + Clutter.get_default_backend ().set_input_method (input_method); + + 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 aa42ee15e..470aa1e81 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', @@ -45,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',