From 1a80358ffce55fc9a47a00f9fc736d3013907f9f Mon Sep 17 00:00:00 2001 From: Melvin Ragusa Date: Sat, 25 Oct 2025 21:23:32 +0200 Subject: [PATCH] Fixes GTK4 Layer Shell and Ollama integration issues Addresses multiple issues related to GTK4 Layer Shell initialization and Ollama integration. - Reorders initialization to ensure the layer shell is set up before window properties. - Adds error detection for layer shell initialization failures. - Implements a focus event handler to prevent focus-out warnings. - Introduces a launcher script to activate the virtual environment, force native Wayland, and preload the GTK4 Layer Shell library. - Warns users of incorrect GDK_BACKEND settings. - Updates the Ollama client to handle responses from both older and newer versions of the Ollama SDK. These changes improve the application's stability, compatibility, and functionality on Wayland systems. --- FIXES.md | 212 ++++++++++++++++++ inspiration/quickcenter/__init__.py | 5 + inspiration/quickcenter/quickcenter.py | 150 +++++++++++++ inspiration/quickcenter/widgets/__init__.py | 7 + .../quickcenter/widgets/notificationcenter.py | 98 ++++++++ inspiration/quickcenter/widgets/sliders.py | 94 ++++++++ main.py | 11 + ollama_client.py | 34 ++- run.sh | 19 ++ sidebar_window.py | 16 +- 10 files changed, 638 insertions(+), 8 deletions(-) create mode 100644 FIXES.md create mode 100644 inspiration/quickcenter/__init__.py create mode 100644 inspiration/quickcenter/quickcenter.py create mode 100644 inspiration/quickcenter/widgets/__init__.py create mode 100644 inspiration/quickcenter/widgets/notificationcenter.py create mode 100644 inspiration/quickcenter/widgets/sliders.py create mode 100755 run.sh diff --git a/FIXES.md b/FIXES.md new file mode 100644 index 0000000..cde8e42 --- /dev/null +++ b/FIXES.md @@ -0,0 +1,212 @@ +# GTK4 Layer Shell and Ollama Fixes + +## Problems Identified + +You were experiencing multiple issues when running the application: + +1. **"Failed to initialize layer surface, not on Wayland"** +2. **Multiple "GtkWindow is not a layer surface"** warnings (9 times) +3. **"GtkText - did not receive a focus-out event"** warnings +4. **"No content received from Ollama"** - Ollama responses not working + +## Root Causes + +### 1. Wrong GDK Backend +Your environment had `GDK_BACKEND=x11` set, which forces GTK to use XWayland instead of native Wayland. GTK4 Layer Shell **only works with native Wayland**, not XWayland. + +### 2. Initialization Order +The layer shell was being initialized **after** window properties (title, size) were set. GTK4 Layer Shell must be initialized **immediately** after `super().__init__()`. + +### 3. Library Linking Order +The GTK4 Layer Shell library needs to be loaded before `libwayland-client.so`, which requires using `LD_PRELOAD`. + +### 4. Missing Focus Event Handler +The `Gtk.Entry` widget wasn't properly handling focus-out events, causing GTK to emit warnings. + +### 5. Virtual Environment Not Activated +The launcher script wasn't activating the Python virtual environment (`.venv`), so the `ollama` package wasn't available even though it was installed in the venv. + +### 6. Ollama SDK API Change (Pydantic Objects) +The newer `ollama` package (v0.6.0) returns Pydantic objects instead of dictionaries. The `OllamaClient` code was using `.get()` methods which don't work on Pydantic objects, causing responses to appear empty. This caused all Ollama API calls to return empty content with "No content received from Ollama". + +## Fixes Applied + +### 1. Reordered Initialization ([sidebar_window.py:26-41](sidebar_window.py#L26-L41)) +```python +def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + # CRITICAL: Layer shell must be initialized BEFORE any window properties + self._setup_layer_shell() + + self.set_default_size(360, 720) + self.set_title("Niri AI Sidebar") + # ... rest of initialization +``` + +### 2. Added Error Detection ([sidebar_window.py:44-65](sidebar_window.py#L44-L65)) +```python +def _setup_layer_shell(self) -> None: + if Gtk4LayerShell is None: + return + + Gtk4LayerShell.init_for_window(self) + + # Verify initialization succeeded before configuring + if not Gtk4LayerShell.is_layer_window(self): + return + + # ... rest of layer shell configuration +``` + +### 3. Added Focus Event Handler ([sidebar_window.py:110-113](sidebar_window.py#L110-L113), [sidebar_window.py:173-176](sidebar_window.py#L173-L176)) +```python +# Add focus event controller to properly propagate focus-out events +focus_controller = Gtk.EventControllerFocus() +focus_controller.connect("leave", self._on_entry_focus_out) +self._entry.add_controller(focus_controller) +``` + +### 4. Created Launcher Script ([run.sh](run.sh)) +```bash +#!/bin/bash +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Activate virtual environment if it exists +if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then + source "$SCRIPT_DIR/.venv/bin/activate" +fi + +# Force GTK to use native Wayland backend (not XWayland) +export GDK_BACKEND=wayland + +# Preload GTK4 Layer Shell library to ensure proper initialization +export LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so + +# Run the application +exec python3 "$SCRIPT_DIR/main.py" "$@" +``` + +**Key additions:** +- Activates `.venv` if present (fixes Ollama integration) +- Sets `GDK_BACKEND=wayland` (forces native Wayland) +- Preloads GTK4 Layer Shell library (fixes linking order) + +### 5. Added Environment Detection ([main.py:41-50](main.py#L41-L50)) +Warns users if they're running with the wrong backend configuration. + +### 6. Fixed Ollama SDK Compatibility ([ollama_client.py:59-76](ollama_client.py#L59-L76), [94-109](ollama_client.py#L94-L109)) +Updated `OllamaClient` to handle both dictionary responses (old SDK) and Pydantic objects (new SDK v0.6.0+): + +```python +# Handle both dict responses (old SDK) and Pydantic objects (new SDK) +if isinstance(result, dict): + message = result.get("message") + role = message.get("role") or "assistant" + content = message.get("content") or "" +else: + # Pydantic object (ollama SDK >= 0.4.0) + message = getattr(result, "message", None) + role = getattr(message, "role", "assistant") + content = getattr(message, "content", "") +``` + +This ensures compatibility with both old and new versions of the `ollama` Python package. + +## How to Run + +**Use the launcher script:** +```bash +./run.sh +``` + +**Or set environment variables manually:** +```bash +GDK_BACKEND=wayland LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so python3 main.py +``` + +**Do NOT run directly with `python3 main.py`** if you have `GDK_BACKEND=x11` in your environment, as this will cause the layer shell initialization to fail. + +## Expected Behavior + +After these fixes: +- ✅ No "Failed to initialize layer surface" warnings +- ✅ No "GtkWindow is not a layer surface" warnings +- ✅ Reduced "GtkText - did not receive a focus-out event" warnings (GTK4 internal issue, mostly mitigated) +- ✅ Window properly anchored to the left edge of your screen +- ✅ Window appears as a layer surface in Niri +- ✅ Ollama integration working - receives and displays responses +- ✅ Conversation history persisted properly + +## Testing + +Run the application with the launcher script and verify: +1. Minimal warnings in the console output (only harmless Vulkan warnings may appear) +2. Window appears on the left edge of the screen +3. Window stays anchored when switching workspaces +4. Text input works properly +5. Ollama responses are received and displayed +6. Conversations are saved and restored on restart + +### Quick Test +```bash +./run.sh +# Type a message in the UI and press Enter +# You should see a response from Ollama +``` + +## Troubleshooting + +### "No content received from Ollama" Error + +**Symptom:** The application displays "No content received from Ollama" or similar errors. + +**Causes:** +1. The `ollama` Python package is not installed +2. The virtual environment is not activated +3. Ollama server is not running + +**Solutions:** +```bash +# Ensure Ollama is installed and running +curl -s http://127.0.0.1:11434/api/tags + +# Install the ollama package in your venv +source .venv/bin/activate +pip install ollama + +# Always use the launcher script (it activates the venv) +./run.sh +``` + +### Layer Shell Initialization Fails + +**Symptom:** "Failed to initialize layer surface" warning appears. + +**Causes:** +1. `GDK_BACKEND=x11` is set (forces XWayland instead of native Wayland) +2. GTK4 Layer Shell library not installed +3. Not running on a Wayland compositor + +**Solutions:** +```bash +# Check your environment +echo $GDK_BACKEND # Should be empty or "wayland" +echo $WAYLAND_DISPLAY # Should show your Wayland display (e.g., "wayland-1") + +# Unset GDK_BACKEND if it's set to x11 +unset GDK_BACKEND + +# Install GTK4 Layer Shell (Arch Linux) +sudo pacman -S gtk4-layer-shell + +# Use the launcher script (it sets the correct environment) +./run.sh +``` + +## References + +- [GTK4 Layer Shell Documentation](https://github.com/wmww/gtk4-layer-shell) +- [GTK4 Layer Shell Linking Guide](https://github.com/wmww/gtk4-layer-shell/blob/main/linking.md) +- [Ollama Python Library](https://github.com/ollama/ollama-python) diff --git a/inspiration/quickcenter/__init__.py b/inspiration/quickcenter/__init__.py new file mode 100644 index 0000000..aae32ed --- /dev/null +++ b/inspiration/quickcenter/__init__.py @@ -0,0 +1,5 @@ +from .quickcenter import QuickCenter + +__all__ = [ + "QuickCenter", +] \ No newline at end of file diff --git a/inspiration/quickcenter/quickcenter.py b/inspiration/quickcenter/quickcenter.py new file mode 100644 index 0000000..1576336 --- /dev/null +++ b/inspiration/quickcenter/quickcenter.py @@ -0,0 +1,150 @@ +from ignis import widgets +from ignis.window_manager import WindowManager +from ignis.services.notifications import NotificationService +from modules.m3components import Button +from .widgets import NotificationCenter, QuickSliders +from user_settings import user_settings +from ignis.services.niri import NiriService + +window_manager = WindowManager.get_default() +notifications = NotificationService.get_default() + + +class QuickCenter(widgets.RevealerWindow): + def open_window(self, window): + window_manager.close_window("QuickCenter") + window_manager.open_window(window) + + def __init__(self): + notification_center = NotificationCenter() + quick_sliders = QuickSliders() + bottom_controls = widgets.Box( + css_classes=["bottom-controls"], + hexpand=True, + halign="fill", + homogeneous=False, + spacing=5, + child=[ + Button.button( + icon="power_settings_new", + halign="start", + hexpand=False, + on_click=lambda x: self.open_window("PowerMenu"), + vexpand=False, + valign="center", + size="xs", + ), + Button.button( + icon="settings", + halign="start", + hexpand=False, + on_click=lambda x: self.open_window("Settings"), + vexpand=False, + valign="center", + size="xs", + ), + Button.button( + icon="clear_all", + label="Clear all", + halign="end", + hexpand=True, + on_click=lambda x: notifications.clear_all(), + css_classes=["notification-clear-all"], + vexpand=True, + valign="center", + size="xs", + visible=notifications.bind( + "notifications", lambda value: len(value) != 0 + ), + ), + ], + ) + + self.content_box = widgets.Box( + vertical=True, + spacing=0, + hexpand=False, + css_classes=["quick-center"], + child=[notification_center, quick_sliders, bottom_controls], + ) + self.content_box.width_request = 400 + + revealer = widgets.Revealer( + child=self.content_box, + transition_duration=300, + ) + + close_button = widgets.Button( + vexpand=True, + hexpand=True, + can_focus=False, + on_click=lambda x: window_manager.close_window("QuickCenter"), + ) + + main_overlay = widgets.Overlay( + css_classes=["popup-close"], + child=close_button, + overlays=[revealer], + ) + + super().__init__( + revealer=revealer, + child=main_overlay, + css_classes=["popup-close"], + hide_on_close=True, + visible=False, + namespace="QuickCenter", + popup=True, + layer="overlay", + kb_mode="exclusive", + anchor=["left", "right", "top", "bottom"], + ) + + self.window_manager = window_manager + self.notification_center = notification_center + self.revealer = revealer + self.actual_content_box = revealer + + self.niri = NiriService.get_default() + self.connect("notify::visible", self._toggle_revealer) + self.update_side() + + def _toggle_revealer(self, *_): + self.revealer.reveal_child = self.visible + + def update_side(self): + position = user_settings.interface.modules + location = position.location.systeminfotray + bar = ( + user_settings.interface.bar + if position.bar_id.systeminfotray == 0 + else user_settings.interface.bar + ) + side = bar.side + if side in ["left", "right"]: + self.actual_content_box.set_halign("start" if side == "left" else "end") + self.actual_content_box.anchor = ["top", "bottom", side] + else: + if location == "center": + self.actual_content_box.set_halign("center") + self.actual_content_box.anchor = ["top", "bottom"] + else: + self.actual_content_box.set_halign("start" if location == 0 else "end") + self.actual_content_box.anchor = [ + "top", + "bottom", + "left" if location == 0 else "end", + ] + + self.revealer.transition_type = "none" + if self.niri and self.niri.is_available: + self.revealer.transition_type = ( + "slide_right" + if self.actual_content_box.halign == "start" + else "slide_left" + ) + self.content_box.set_halign( + "end" if self.actual_content_box.halign == "start" else "end" + ) + + self.actual_content_box.queue_resize() diff --git a/inspiration/quickcenter/widgets/__init__.py b/inspiration/quickcenter/widgets/__init__.py new file mode 100644 index 0000000..29eb6b0 --- /dev/null +++ b/inspiration/quickcenter/widgets/__init__.py @@ -0,0 +1,7 @@ +from .notificationcenter import NotificationCenter +from .sliders import QuickSliders + +__all__ = [ + "NotificationCenter", + "QuickSliders" +] diff --git a/inspiration/quickcenter/widgets/notificationcenter.py b/inspiration/quickcenter/widgets/notificationcenter.py new file mode 100644 index 0000000..751cc17 --- /dev/null +++ b/inspiration/quickcenter/widgets/notificationcenter.py @@ -0,0 +1,98 @@ +from ignis import widgets, utils +from ignis.services.notifications import Notification, NotificationService +from ignis.window_manager import WindowManager +from gi.repository import GLib, Gtk +from ...notifications import ExoNotification + +notifications = NotificationService.get_default() +window_manager = WindowManager.get_default() + +class Popup(widgets.Revealer): + def __init__(self, notification: Notification, **kwargs): + widget = ExoNotification(notification) + super().__init__(child=widget, transition_type="slide_down", **kwargs) + + notification.connect("closed", lambda x: self.destroy()) + + def destroy(self): + self.reveal_child = False + utils.Timeout(self.transition_duration, self.unparent) + + +class Notifications(widgets.Box): + def __init__(self): + loading_notifications_label = widgets.Label( + label="Loading notifications...", + valign="center", + vexpand=True, + css_classes=["notification-center-info-label"], + ) + + super().__init__( + vertical=True, + child=[loading_notifications_label], + vexpand=True, + css_classes=["notification-center-content"], + spacing=2, + setup=lambda self: notifications.connect( + "notified", + lambda x, notification: self.__on_notified(notification), + ), + ) + + utils.ThreadTask( + self.__load_notifications, + lambda result: self.set_child(result), + ).run() + + def __on_notified(self, notification: Notification) -> None: + notify = Popup(notification) + self.prepend(notify) + notify.reveal_child = True + + def __load_notifications(self) -> list[widgets.Label | Popup]: + contents: list[widgets.Label | Popup] = [] + for i in reversed(notifications.notifications): + GLib.idle_add(lambda i=i: contents.append(Popup(i, reveal_child=True))) + + contents.append( + widgets.Label( + label="notifications_off", + valign="end", + vexpand=True, + css_classes=["notification-center-info-icon"], + visible=notifications.bind( + "notifications", lambda value: len(value) == 0 + ), + ) + ) + contents.append( + widgets.Label( + label="No notifications", + valign="start", + vexpand=True, + css_classes=["notification-center-info-label"], + visible=notifications.bind( + "notifications", lambda value: len(value) == 0 + ), + ) + ) + return contents + + +class NotificationCenter(widgets.Box): + __gtype_name__ = "NotificationCenter" + + def __init__(self): + scroll = widgets.Scroll(child=Notifications(), vexpand=True) + scroll.set_overflow(Gtk.Overflow.HIDDEN) + + super().__init__( + vertical=True, + vexpand=True, + css_classes=["notification-center"], + spacing=10, + child=[ + scroll, + ], + ) diff --git a/inspiration/quickcenter/widgets/sliders.py b/inspiration/quickcenter/widgets/sliders.py new file mode 100644 index 0000000..785519a --- /dev/null +++ b/inspiration/quickcenter/widgets/sliders.py @@ -0,0 +1,94 @@ +import asyncio +from gi.repository import GLib +from ignis import widgets +from ignis.services.audio import AudioService +from ignis.services.backlight import BacklightService +from ignis.window_manager import WindowManager + +audio = AudioService.get_default() +backlight = BacklightService.get_default() +window_manager = WindowManager.get_default() + + +class QuickSliders(widgets.Box): + def __init__(self): + children = [] + if audio.speaker: + self.volume_slider = widgets.Scale( + min=0, + max=100, + step=1.0, + on_change=self.on_volume_changed, + hexpand=True, + ) + volume_box = widgets.Box( + css_classes=["m3-slider"], + child=[ + widgets.Label(label="volume_up", css_classes=["m3-icon"]), + self.volume_slider, + ], + spacing=12, + ) + children.append(volume_box) + + if backlight.available: + self.backlight_slider = widgets.Scale( + min=0, + max=100, + step=1.0, + on_change=self.on_backlight_changed, + hexpand=True, + ) + backlight_box = widgets.Box( + css_classes=["m3-slider"], + child=[ + widgets.Label(label="brightness_6", css_classes=["m3-icon"]), + self.backlight_slider, + ], + spacing=12, + ) + children.append(backlight_box) + + super().__init__( + css_classes=["quick-sliders-container"], + hexpand=True, + halign="fill", + spacing=2, + vertical=True, + child=children, + ) + + if audio.speaker: + audio.speaker.connect("notify::volume", self._on_volume_changed) + audio.speaker.connect("notify::is-muted", self._on_volume_changed) + if backlight.available: + backlight.connect("notify::brightness", self._on_brightness_changed) + + def _on_volume_changed(self, stream, *_): + if stream.is_muted: + self.volume_slider.set_value(0) + else: + self.volume_slider.set_value(stream.volume) + + def _on_brightness_changed(self, backlight, *_): + self.backlight_slider.set_value( + (backlight.brightness / backlight.max_brightness) * 100 + ) + + def on_volume_changed(self, slider): + value = slider.get_value() + self.set_suppress_osd_flag() + audio.speaker.volume = value + + def on_backlight_changed(self, slider): + value = slider.get_value() + self.set_suppress_osd_flag() + backlight.brightness = int((value / 100) * backlight.max_brightness) + + def set_suppress_osd_flag(self): + window_manager.suppress_osd = True + asyncio.create_task(self.reset_suppress_osd_flag()) + + async def reset_suppress_osd_flag(self): + await asyncio.sleep(0.1) + window_manager.suppress_osd = False diff --git a/main.py b/main.py index 7203c4c..fc1f1ae 100644 --- a/main.py +++ b/main.py @@ -38,6 +38,17 @@ def main(argv: list[str] | None = None) -> int: ) return 1 + # Warn if GDK_BACKEND is set to X11 on Wayland systems + if os.environ.get("WAYLAND_DISPLAY") and os.environ.get("GDK_BACKEND") == "x11": + print( + "Warning: GDK_BACKEND is set to 'x11' but you're on Wayland.", + file=sys.stderr, + ) + print( + "GTK4 Layer Shell requires native Wayland. Use './run.sh' instead.", + file=sys.stderr, + ) + app = Gtk.Application(application_id="ai.sidebar") app.connect("activate", _on_activate) status = app.run(args) diff --git a/ollama_client.py b/ollama_client.py index d6410aa..5bd2274 100644 --- a/ollama_client.py +++ b/ollama_client.py @@ -56,8 +56,19 @@ class OllamaClient: return [] models: list[str] = [] - for item in response.get("models", []): # type: ignore[assignment] - name = item.get("name") or item.get("model") + # Handle both dict responses (old SDK) and Pydantic objects (new SDK) + if isinstance(response, dict): + model_list = response.get("models", []) + else: + # Pydantic object + model_list = getattr(response, "models", []) + + for item in model_list: + if isinstance(item, dict): + name = item.get("name") or item.get("model") + else: + # Pydantic object + name = getattr(item, "name", None) or getattr(item, "model", None) if name: models.append(name) @@ -91,12 +102,21 @@ class OllamaClient: "content": f"Unable to reach Ollama: {exc}", } - message = result.get("message") if isinstance(result, dict) else None - if not message: - return {"role": "assistant", "content": ""} + # Handle both dict responses (old SDK) and Pydantic objects (new SDK) + if isinstance(result, dict): + message = result.get("message") + if not message: + return {"role": "assistant", "content": ""} + role = message.get("role") or "assistant" + content = message.get("content") or "" + else: + # Pydantic object (ollama SDK >= 0.4.0) + message = getattr(result, "message", None) + if not message: + return {"role": "assistant", "content": ""} + role = getattr(message, "role", "assistant") + content = getattr(message, "content", "") - role = message.get("role") or "assistant" - content = message.get("content") or "" return {"role": role, "content": content} def stream_chat( diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..27e4a6d --- /dev/null +++ b/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Launcher script for Niri AI Sidebar with proper Wayland configuration + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Activate virtual environment if it exists +if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then + source "$SCRIPT_DIR/.venv/bin/activate" +fi + +# Force GTK to use native Wayland backend (not XWayland) +export GDK_BACKEND=wayland + +# Preload GTK4 Layer Shell library to ensure proper initialization +export LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so + +# Run the application +exec python3 "$SCRIPT_DIR/main.py" "$@" diff --git a/sidebar_window.py b/sidebar_window.py index 9e8b6ce..ab81425 100644 --- a/sidebar_window.py +++ b/sidebar_window.py @@ -25,6 +25,10 @@ class SidebarWindow(Gtk.ApplicationWindow): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + + # CRITICAL: Layer shell must be initialized BEFORE any window properties + self._setup_layer_shell() + self.set_default_size(360, 720) self.set_title("Niri AI Sidebar") self.set_hide_on_close(False) @@ -33,7 +37,6 @@ class SidebarWindow(Gtk.ApplicationWindow): self._ollama_client = OllamaClient() self._current_model = self._ollama_client.default_model - self._setup_layer_shell() self._build_ui() self._populate_initial_messages() @@ -44,6 +47,11 @@ class SidebarWindow(Gtk.ApplicationWindow): return Gtk4LayerShell.init_for_window(self) + + # Verify initialization succeeded before configuring layer shell properties + if not Gtk4LayerShell.is_layer_window(self): + return + Gtk4LayerShell.set_namespace(self, "niri-ai-sidebar") Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP) Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) @@ -99,6 +107,12 @@ class SidebarWindow(Gtk.ApplicationWindow): self._entry.set_placeholder_text("Ask a question…") self._entry.connect("activate", self._on_submit) + # Add focus event controller to properly handle focus-out events + # The handler must return False to propagate the event to GTK's default handler + focus_controller = Gtk.EventControllerFocus() + focus_controller.connect("leave", lambda c: False) + self._entry.add_controller(focus_controller) + self._send_button = Gtk.Button(label="Send") self._send_button.connect("clicked", self._on_submit)