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.
- 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
.desktopentry. - Green times on the timetable are live estimations from HSL API, which are more accurate than the simple planned departure times in white
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.
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 beforelayer=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 totop, neveroverlay, 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 waytop'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.
- 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).
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-devDon'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.
sudo pacman -S --needed base-devel cmake ninja \
qt6-base qt6-wayland layer-shell-qtArch 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.
sudo dnf install gcc-c++ cmake ninja-build \
qt6-qtbase-devel qt6-qtwayland layer-shell-qt-develqt6-qtbase-devel and layer-shell-qt-devel pull in their respective
runtime packages (qt6-qtbase, layer-shell-qt) as dependencies.
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-waylandon Debian/Arch,qt6-qtwaylandon Fedora). Without it, Qt has nothing to open a window with underQT_QPA_PLATFORM=waylandat all. - The actual Qt-Wayland-client shell-integration plugin
(
liblayer-shell.so- thelayer-shell-qtpackage, as opposed toliblayershellqtinterface-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/-develone. Without it:qt.qpa.wayland: No shell integration named "layer-shell" foundand 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 the binary into build/ folder:
cmake -S . -B build -G Ninja
cmake --build buildThen install it, goes to /usr/local/bin/hosula by default:
sudo cmake --install build
# or, for a per-user install:
cmake --install build --prefix ~/.localIf you install somewhere other than /usr/local/bin, update Exec= in
data/hosula.desktop to match before copying it into place (see
Autostart below).
ctest --test-dir build --output-on-failureBuilds 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.
- 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.
- 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"
- 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.
- Click Subscribe. You'll be asked to answer a short questionnaire about your intended use.
- 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.
- Paste that key as
api_keyin~/.config/hosula/config.ini(see below) - Hosula sends it as thedigitransit-subscription-keyrequest 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.iniSee 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.
Locally from repo:
build/hosula # foreground; Ctrl+C stops it cleanly
build/hosula & disown # detached from the terminalFrom installed binary:
source ~/.zshrc # or source ~/.bashrc
hosulaEach 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.
pkill hosula # SIGTERM, clean shutdownHosula 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.
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.
src/Config.{h,cpp}— loads~/.config/hosula/config.iniviaQSettings.stop_idsis 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 ofstop_idsinto concrete GTFS IDs, run once frommain.cppbeforeMainWidgetis constructed. Entries already shaped like a GTFS ID pass through untouched; anything else is resolved via a single batchedstops(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 theQNetworkAccessManager, builds one GraphQL POST aliasing every tiled stop'sstop(id:...)field so a single HTTP request covers all of them, parses the JSON reply intoQList<StopDepartures>, and emitsdeparturesReady()/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 oneStopDepartures::errorinstead 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:LayerShellQtsetup (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 ifdeparture_window_hoursworth of departures doesn't fit, rebuilt wheneverdeparturesReady()fires.src/Logging.{h,cpp}— per-run log file + aqInstallMessageHandlerthat fans every log call out to both it and stderr. See Logs above.src/UnixSignalHandler.{h,cpp}— SIGINT/SIGTERM → cleanQCoreApplication::quit()via the standard self-pipe/QSocketNotifierpattern (Qt installs no signal handlers of its own by default). No backgroundQThreads 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—QApplicationsetup,LayerShellQt::Shell:: useLayerShell()(must run before theQApplicationis 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 installedhosulabinary.
MIT — see LICENSE.
