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:
212
FIXES.md
Normal file
212
FIXES.md
Normal 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)
|
||||||
5
inspiration/quickcenter/__init__.py
Normal file
5
inspiration/quickcenter/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .quickcenter import QuickCenter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QuickCenter",
|
||||||
|
]
|
||||||
150
inspiration/quickcenter/quickcenter.py
Normal file
150
inspiration/quickcenter/quickcenter.py
Normal 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()
|
||||||
7
inspiration/quickcenter/widgets/__init__.py
Normal file
7
inspiration/quickcenter/widgets/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .notificationcenter import NotificationCenter
|
||||||
|
from .sliders import QuickSliders
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NotificationCenter",
|
||||||
|
"QuickSliders"
|
||||||
|
]
|
||||||
98
inspiration/quickcenter/widgets/notificationcenter.py
Normal file
98
inspiration/quickcenter/widgets/notificationcenter.py
Normal 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,
|
||||||
|
],
|
||||||
|
)
|
||||||
94
inspiration/quickcenter/widgets/sliders.py
Normal file
94
inspiration/quickcenter/widgets/sliders.py
Normal 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
11
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
19
run.sh
Executable 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" "$@"
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user