Skip to content

Latest commit

 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hosula

A borderless, always-visible KDE Plasma (Wayland) desktop widget showing real-time departures for one or more HSL/Digitransit public transport stops, anchored to a corner of the screen like a panel. The name is a portmanteau of HSL and the Finnish verb "hosua" ("hurry" in English).

It's a standalone Qt6 Widgets application pinned to the desktop via the Wayland layer-shell protocol (LayerShellQt) — not a Plasma applet/plasmoid, not QML.

hosula-widget

Features

  • One or more configured stops, each by Digitransit gtfsId (e.g. HSL:1173434) or by a stop name to fuzzy-search for (e.g. Ristikkokatu). Multiple stops are tiled to fit the widget at a readable minimum size; ones that don't fit are dropped with a warning.
  • Polls the Digitransit Routing API GraphQL endpoint every 20-30s, one batched request covering every configured (and tiled) stop.
  • Each stop's tile shows its name, public/platform code (e.g. "H3022" - what's physically printed on the stop pole) and HSL fare zone letter as a badge/circle, above a vertical departure list: departure time (favoring realtime data when present over the scheduled time), line short name, headsign, colored by transport mode (bus/tram/rail/subway/ferry).
  • Plain-INI config (stops, API key, refresh interval, anchor corner) — no in-app settings UI.
  • Starts automatically via an XDG autostart .desktop entry.
  • Green times on the timetable are live estimations from HSL API, which are more accurate than the simple planned departure times in white

Desktop / compositor compatibility

Hosula's code has no KDE Frameworks or Plasma-specific dependency - just plain Qt6 Widgets plus LayerShellQt. But LayerShellQt itself is a thin wrapper around one specific Wayland protocol extension, wlr-layer-shell (zwlr_layer_shell_v1) - the thing that actually lets Hosula anchor borderlessly to a screen corner like a panel instead of being a normal floating window. Whether it behaves as intended comes down entirely to whether your compositor implements that protocol, regardless of which desktop environment or window manager sits on top of it.

Works (compositor implements wlr-layer-shell):

  • KDE Plasma on Wayland, via KWin - the primary target this project is built and tested against. Support has been in KWin for years, not a recent addition.
  • wlroots-based compositors/window managers: Sway, Hyprland, river, labwc, and others built on wlroots.
  • niri (the scrollable-tiling compositor).
  • COSMIC (cosmic-comp, System76's compositor).
  • Mir-based compositors - some require the protocol enabled explicitly (e.g. --add-wayland-extension zwlr_layer_shell_v1).

Doesn't work: GNOME (Mutter), on Wayland or X11. Mutter has never implemented wlr-layer-shell - it's a long-standing, still-open GNOME Shell feature request (gitlab.gnome.org/GNOME/gnome-shell#1141), not something a GNOME Shell extension can add on its own (implementing a Wayland protocol means compositor-level C code, not JS-level Shell extension hooks). The only workaround is an unofficial, unpackaged Mutter fork - not something worth depending on for a widget that's meant to just work.

X11 (any DE/WM): no fallback worth relying on. There's no X11 equivalent of wlr-layer-shell, and Hosula doesn't implement the older X11-native mechanism for this kind of thing either (_NET_WM_STRUT/EWMH dock hints). LayerShellQt::Window::get() simply returns nullptr under X11, and Hosula treats that as non-fatal - see the comment in MainWidget::setupLayerShell() - so the app still launches, just as a plain frameless floating window with none of the anchoring/pinning/exclusive-zone behavior that's the actual point of the project. Fine for a quick local UI check, not for real use.

Where the widget sits relative to other windows

wlr-layer-shell defines four fixed stacking layers (background < bottom < top < overlay); which one Hosula renders in is the layer= config key (see Configure below), and it decides whether other windows can cover Hosula, not the reverse:

  • top (the default - this is the only behavior Hosula had before layer= existed, so existing configs are unaffected): above ordinary windows, but a fullscreen app (video player, browser, etc.) can still cover it. Fullscreen surfaces get promoted to this same layer by the compositor, and the protocol leaves it up to the compositor to decide which of two same-layer surfaces wins - on the compositor this was tested against (KWin), the fullscreen surface wins.
  • overlay: above everything, including fullscreen surfaces (those are only ever promoted to top, never overlay, per the protocol spec) - a true "always visible, no exceptions" mode. Not the default since plenty of people would find that behavior actively annoying on a general-purpose desktop; opt in if you specifically want it. (Not yet verified against a live fullscreen app the way top's behavior above was - if it doesn't actually beat fullscreen content on your compositor, that's worth filing as a bug.)
  • bottom / background: below ordinary windows too - useful if you'd rather Hosula behave more like a passive desktop-background element that anything can cover, the opposite of a kiosk display.

There's no protocol-level way to express "cover me with fullscreen content specifically, but nothing else" beyond what top already gives you by way of the compositor's own fullscreen-promotion behavior - the protocol only has these four static layers, not a conditional/relative one.

Dependencies

  • C++20, CMake ≥ 3.25, Ninja (or Make).
  • GCC ≥ 13 or Clang ≥ 17.
  • Qt6 (Widgets, Network, Test - Test is only needed for the regression test suite, see Tests below) development packages.
  • LayerShellQt (KDE's Qt binding for wlr-layer-shell) development package.
  • A Digitransit API subscription key from portal-api.digitransit.fi.

Package names differ across distros; commands to install everything needed to both build and run Hosula follow for a few major ones (tested on Debian trixie and Arch Linux; Fedora is untested but the package names below match what Fedora's own repos advertise as of writing).

Debian / Ubuntu

Tested on Debian trixie; a recent Ubuntu with Qt6/KDE 6 packages available should work the same way.

sudo apt install build-essential cmake ninja-build \
    qt6-base-dev qt6-wayland layer-shell-qt liblayershellqtinterface-dev

Don't let g++/gcc resolve to GCC 13 on Debian trixie. GCC 13's own bundled libstdc++.so predates the CXXABI_1.3.15 symbol this Qt6 build needs, so linking fails with undefined reference to __cxa_call_terminate@CXXABI_1.3.15. Use the distro default (GCC 14, pulled in by build-essential) or Clang 17.

Arch Linux

sudo pacman -S --needed base-devel cmake ninja \
    qt6-base qt6-wayland layer-shell-qt

Arch doesn't split runtime and -dev/-devel packages the way Debian and Fedora do - qt6-base and layer-shell-qt already include headers and CMake config files, so there's nothing extra to install for building versus just running.

Fedora

sudo dnf install gcc-c++ cmake ninja-build \
    qt6-qtbase-devel qt6-qtwayland layer-shell-qt-devel

qt6-qtbase-devel and layer-shell-qt-devel pull in their respective runtime packages (qt6-qtbase, layer-shell-qt) as dependencies.

Compiling successfully doesn't mean it'll render

This trips people up regardless of distro: three runtime pieces are needed beyond Qt6/LayerShellQt's own headers and link libraries (the -dev/-devel packages above, or the single qt6-base/layer-shell-qt packages on Arch) - all already covered by the install commands above, but worth knowing what each one is actually for when something doesn't work:

  • The Wayland QPA platform plugin (libqwayland-generic.so - qt6-wayland on Debian/Arch, qt6-qtwayland on Fedora). Without it, Qt has nothing to open a window with under QT_QPA_PLATFORM=wayland at all.
  • The actual Qt-Wayland-client shell-integration plugin (liblayer-shell.so - the layer-shell-qt package, as opposed to liblayershellqtinterface-dev/layer-shell-qt-devel, which is only the C++ API library app code links against). Easy to miss since it's a distinctly-named package from the -dev/-devel one. Without it: qt.qpa.wayland: No shell integration named "layer-shell" found and the app aborts on startup.
  • A live compositor socket ($XDG_RUNTIME_DIR/$WAYLAND_DISPLAY) to actually connect to — i.e. a real Wayland session, not a TTY/SSH shell with nothing attached.

Build

Build the binary into build/ folder:

cmake -S . -B build -G Ninja
cmake --build build

Then install it, goes to /usr/local/bin/hosula by default:

sudo cmake --install build
# or, for a per-user install:
cmake --install build --prefix ~/.local

If you install somewhere other than /usr/local/bin, update Exec= in data/hosula.desktop to match before copying it into place (see Autostart below).

Tests

ctest --test-dir build --output-on-failure

Builds and runs the (currently one) regression test alongside hosula itself - no extra dependency beyond Qt6::Test (already part of the base Qt6 dev package[s] listed under Dependencies on every distro covered there), and no Wayland compositor/session needed: it runs headless under QT_QPA_PLATFORM=offscreen, set automatically by the test's CTest properties. Set -DBUILD_TESTING=OFF at the cmake -S . -B build step to skip building it entirely.

tests/tst_translucent_repaint.cpp guards against a specific rendering bug class: a QWidget with only a QSS background-color: rgba(...) under Qt::WA_TranslucentBackground doesn't erase its own previous contents before a partial repaint, so old text can stay baked into the backing store underneath whatever's drawn next (this is what the TranslucentCard/MainWidget::paintEvent fix in MainWidget.cpp addresses). See the comment at the top of that test file for the full root-cause writeup and why it has to use a real shown window + QScreen::grabWindow() rather than QWidget::grab()/render(), which can't catch this class of bug at all.

Configure

Getting a Digitransit API key

  1. Go to the Digitransit API portal and click Sign up (top right). Use an email you actually have access to - it's used for initial authentication, not just contact info.
  2. Verify your email, then set up two-factor authentication (an emailed code or an authenticator app) - if it asks to setup 2FA after this or on later logins, you may just click "Later"
  3. Once signed in, open the Products tab. There's a single product, Digitransit developer API, covering every endpoint (including the Routing API GraphQL endpoint Hosula uses) - open it.
  4. Click Subscribe. You'll be asked to answer a short questionnaire about your intended use.
  5. After subscribing, go to the Profile tab and click Show next to your key to reveal it. There's a primary and a secondary key (so you can rotate one while the other stays live), plus a regenerate option if a key ever leaks.
  6. Paste that key as api_key in ~/.config/hosula/config.ini (see below) - Hosula sends it as the digitransit-subscription-key request header on every API call, exactly as Digitransit's docs describe.

Copy the example config and fill in your stop(s) and API key:

mkdir -p ~/.config/hosula
cp config.ini.example ~/.config/hosula/config.ini
$EDITOR ~/.config/hosula/config.ini

See config.ini.example for all keys (stop_ids, api_key, refresh_seconds, anchor, layer, timezone, width_scale, height_scale, departure_window_hours, monitor, x_offset, y_offset, widget_opacity, tile_opacity, tile_bg_color). There is no in-app settings UI — Hosula reads this file once at startup. Every fatal startup condition (missing file, missing stop_ids/api_key, an invalid anchor/ layer/timezone, a configured monitor that isn't currently connected, or none of the configured stop_ids resolving to an actual stop) shows an error dialog and exits, since Hosula is normally launched via XDG autostart with no terminal attached to see a stderr message on. Numeric knobs that always have a safe fallback (width_scale, height_scale, departure_window_hours, refresh_seconds) are instead silently clamped into range, with a note in the log file. Similarly, if some (but not all) of stop_ids can't be resolved to a stop, or more stops are configured than fit at a readable size, the affected entries are just dropped with a warning rather than failing startup outright.

Run

Locally from repo:

build/hosula           # foreground; Ctrl+C stops it cleanly
build/hosula & disown  # detached from the terminal

From installed binary:

source ~/.zshrc        # or source ~/.bashrc
hosula

Logs

Each run writes to its own file under $XDG_STATE_HOME/hosula/logs/ (falling back to ~/.local/state/hosula/logs/ if that's unset), named after its start time, e.g. 20260920-171257.log — the same content is also mirrored to stderr. Files older than 30 days are pruned automatically on startup. The log includes the list of detected monitor names on every run (useful for picking a monitor= value) — it never includes your api_key.

Stopping the widget

pkill hosula   # SIGTERM, clean shutdown

Hosula installs proper SIGINT/SIGTERM handlers, so this (or kill <pid>, a systemd --user stop, or a normal logout) triggers a clean shutdown (event loop exits normally, LayerShellQt/the Wayland connection tear down the ordinary way). Avoid kill -9/SIGKILL — it's fundamentally uncatchable by any process, so no app-level code can make that one graceful.

Autostart

mkdir -p ~/.config/autostart
cp data/hosula.desktop ~/.config/autostart/

Adjust Exec= in that file first if you installed the binary anywhere other than /usr/local/bin/hosula.

Architecture

  • src/Config.{h,cpp} — loads ~/.config/hosula/config.ini via QSettings. stop_ids is parsed into a plain comma-separated list; it does not distinguish a GTFS ID from a stop name itself (that's StopResolver's job, since it needs a network round-trip Config can't make on its own).
  • src/StopResolver.{h,cpp} — one-time, synchronous (blocking) resolution of stop_ids into concrete GTFS IDs, run once from main.cpp before MainWidget is constructed. Entries already shaped like a GTFS ID pass through untouched; anything else is resolved via a single batched stops(name:) GraphQL query. A name matching several stops that share that exact name (typically one per direction) resolves to all of them, since there's no way to tell which direction was meant; a name matching nothing is dropped (warning logged either way).
  • src/DigitransitClient.{h,cpp} — owns the QNetworkAccessManager, builds one GraphQL POST aliasing every tiled stop's stop(id:...) field so a single HTTP request covers all of them, parses the JSON reply into QList<StopDepartures>, and emits departuresReady()/fetchFailed(). A failed poll never clears any tile's list — the last-known-good departures stay visible with a stale indicator instead. A per-stop problem (e.g. a GTFS ID that no longer exists) surfaces as that one StopDepartures::error instead of failing the whole batch.
  • src/Departure.h — Departure: one row (time, line, headsign, mode, isRealtime). StopDepartures: one tile's worth (stop ID, name, code, its departures, and an optional per-stop error).
  • src/MainWidget.{h,cpp} — the visible window: LayerShellQt setup (layer, anchor, margins), sized as a fraction of the target screen (width_scale/height_scale). Within that fixed budget, tiles the resolved stops into a grid (uniform cell size, typography scaled to match each cell) at a readable minimum size, dropping stops from the end of the list (with a warning) if not all of them fit. Each tile has its own scrollable departure list that only shows a scrollbar if departure_window_hours worth of departures doesn't fit, rebuilt whenever departuresReady() fires.
  • src/Logging.{h,cpp} — per-run log file + a qInstallMessageHandler that fans every log call out to both it and stderr. See Logs above.
  • src/UnixSignalHandler.{h,cpp} — SIGINT/SIGTERM → clean QCoreApplication::quit() via the standard self-pipe/QSocketNotifier pattern (Qt installs no signal handlers of its own by default). No background QThreads exist anywhere in this codebase — Qt's own internal auxiliary threads (networking, etc.) are normal for any nontrivial Qt6 app and are cleaned up automatically on a normal exit.
  • src/main.cpp — QApplication setup, LayerShellQt::Shell:: useLayerShell() (must run before the QApplication is constructed), logging/signal-handler setup, config load, monitor resolution, and starting the poll timer.
  • tests/ — regression tests (see Tests above), built and run via CTest, not part of the installed hosula binary.

License

MIT — see LICENSE.

About

An always-visible KDE Plasma (Wayland) desktop widget showing real-time departures for one or more HSL/Digitransit public transport stops, anchored to a corner of the screen like a panel. Adjustable size, opacity and departure time window.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages