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.
This commit is contained in:
Melvin Ragusa
2025-10-25 21:23:32 +02:00
parent da57c43e69
commit 1a80358ffc
10 changed files with 638 additions and 8 deletions

212
FIXES.md Normal file
View File

@@ -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)

View File

@@ -0,0 +1,5 @@
from .quickcenter import QuickCenter
__all__ = [
"QuickCenter",
]

View File

@@ -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()

View File

@@ -0,0 +1,7 @@
from .notificationcenter import NotificationCenter
from .sliders import QuickSliders
__all__ = [
"NotificationCenter",
"QuickSliders"
]

View File

@@ -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,
],
)

View File

@@ -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

11
main.py
View File

@@ -38,6 +38,17 @@ def main(argv: list[str] | None = None) -> int:
) )
return 1 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 = Gtk.Application(application_id="ai.sidebar")
app.connect("activate", _on_activate) app.connect("activate", _on_activate)
status = app.run(args) status = app.run(args)

View File

@@ -56,8 +56,19 @@ class OllamaClient:
return [] return []
models: list[str] = [] models: list[str] = []
for item in response.get("models", []): # type: ignore[assignment] # Handle both dict responses (old SDK) and Pydantic objects (new SDK)
name = item.get("name") or item.get("model") 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: if name:
models.append(name) models.append(name)
@@ -91,12 +102,21 @@ class OllamaClient:
"content": f"Unable to reach Ollama: {exc}", "content": f"Unable to reach Ollama: {exc}",
} }
message = result.get("message") if isinstance(result, dict) else None # Handle both dict responses (old SDK) and Pydantic objects (new SDK)
if not message: if isinstance(result, dict):
return {"role": "assistant", "content": ""} 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} return {"role": role, "content": content}
def stream_chat( def stream_chat(

19
run.sh Executable file
View File

@@ -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" "$@"

View File

@@ -25,6 +25,10 @@ class SidebarWindow(Gtk.ApplicationWindow):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) 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_default_size(360, 720)
self.set_title("Niri AI Sidebar") self.set_title("Niri AI Sidebar")
self.set_hide_on_close(False) self.set_hide_on_close(False)
@@ -33,7 +37,6 @@ class SidebarWindow(Gtk.ApplicationWindow):
self._ollama_client = OllamaClient() self._ollama_client = OllamaClient()
self._current_model = self._ollama_client.default_model self._current_model = self._ollama_client.default_model
self._setup_layer_shell()
self._build_ui() self._build_ui()
self._populate_initial_messages() self._populate_initial_messages()
@@ -44,6 +47,11 @@ class SidebarWindow(Gtk.ApplicationWindow):
return return
Gtk4LayerShell.init_for_window(self) 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_namespace(self, "niri-ai-sidebar")
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP) Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 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.set_placeholder_text("Ask a question…")
self._entry.connect("activate", self._on_submit) 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 = Gtk.Button(label="Send")
self._send_button.connect("clicked", self._on_submit) self._send_button.connect("clicked", self._on_submit)