feat(osd): add reusable osd overlay with audio support

This commit is contained in:
Lysec
2026-04-06 15:02:53 +02:00
parent 5472b78da3
commit 443d0a2c65
11 changed files with 568 additions and 2 deletions
+2
View File
@@ -241,6 +241,8 @@ add_executable(noctalia
src/render/scene/input_area.cpp
src/render/scene/input_dispatcher.cpp
src/shell/notification/notification_popup.cpp
src/shell/osd/audio_osd.cpp
src/shell/osd/osd_overlay.cpp
src/shell/tray/tray_menu.cpp
src/shell/panel/panel_manager.cpp
src/shell/panels/notification_history_panel.cpp
+15
View File
@@ -12,6 +12,7 @@ Changes are detected automatically via inotify — no restart required.
- [Per-monitor overrides](#per-monitor-overrides)
- [Widget definitions](#widget-definitions)
- [Built-in widgets](#built-in-widgets)
- [OSD](#osd)
- [Wallpaper](#wallpaper)
- [Full example](#full-example)
@@ -220,6 +221,17 @@ edge_smoothness = 0.5 # 0.0 1.0
---
## OSD
```toml
[osd]
position = "top_right" # top_right | top_left | top_center | bottom_right | bottom_left | bottom_center
```
The OSD currently powers the volume HUD and defaults to `top_right`.
---
## Full example
```toml
@@ -258,6 +270,9 @@ match = "DP-1" # main 4K display — taller bar, show seconds
height = 44
end = ["tray", "notifications", "volume", "battery", "clock-seconds"]
[osd]
position = "top_right"
[bar.main.monitor.hdmi]
match = "HDMI-A-1" # secondary 1080p — smaller, minimal widgets
height = 34
+1 -1
View File
@@ -210,7 +210,7 @@ gdbus call --session --dest dev.noctalia.Debug --object-path /dev/noctalia/Debug
### Controls (`src/ui/controls/`)
- [ ] Scroll view
- [x] Scroll view
- [ ] List view
- [ ] Checkbox
- [ ] Radio button
+19 -1
View File
@@ -5,6 +5,8 @@
#include "shell/panels/notification_history_panel.h"
#include "shell/panels/test_panel.h"
#include <chrono>
#include <cmath>
#include <csignal>
#include <stdexcept>
@@ -142,7 +144,6 @@ void Application::run() {
try {
m_pipewireService = std::make_unique<PipeWireService>();
m_pipewireService->setChangeCallback([this]() { m_bar.onWorkspaceChange(); });
const auto* sink = m_pipewireService->defaultSink();
if (sink != nullptr) {
logInfo("pipewire: default sink \"{}\" vol={:.0f}%", sink->description, sink->volume * 100.0f);
@@ -214,6 +215,13 @@ void Application::run() {
// Initialize notification popup (top layer, dynamic surface)
m_notificationPopup.initialize(m_wayland, &m_configService, &m_notificationManager, &m_renderContext);
// Initialize the shared OSD overlay (overlay layer, transient surface)
m_osdOverlay.initialize(m_wayland, &m_configService, &m_renderContext);
m_audioOsd.bindOverlay(m_osdOverlay);
if (m_pipewireService != nullptr) {
m_audioOsd.primeFromService(*m_pipewireService);
}
// Initialize tray menu (top layer, dynamic surface)
m_trayMenu.initialize(m_wayland, &m_configService, m_trayService.get(), &m_renderContext);
@@ -221,6 +229,16 @@ void Application::run() {
m_bar.initialize(m_wayland, &m_configService, &m_timeService, &m_notificationManager, m_trayService.get(),
m_pipewireService.get(), m_upowerService.get(), m_systemMonitor.get(), &m_renderContext);
if (m_pipewireService != nullptr) {
m_audioOsd.suppressFor(std::chrono::milliseconds(2000));
m_pipewireService->setChangeCallback([this]() {
m_bar.onWorkspaceChange();
if (m_pipewireService != nullptr) {
m_audioOsd.onAudioStateChanged(*m_pipewireService);
}
});
}
// Unified pointer event routing — both Bar and PanelManager check surface ownership
m_wayland.setPointerEventCallback([this](const PointerEvent& event) {
if (m_trayMenu.onPointerEvent(event))
+4
View File
@@ -22,6 +22,8 @@
#include "render/render_context.h"
#include "shell/bar/bar.h"
#include "shell/notification/notification_popup.h"
#include "shell/osd/audio_osd.h"
#include "shell/osd/osd_overlay.h"
#include "shell/panel/panel_manager.h"
#include "shell/tray/tray_menu.h"
#include "shell/wallpaper/wallpaper.h"
@@ -65,6 +67,8 @@ private:
Bar m_bar;
PanelManager m_panelManager;
NotificationPopup m_notificationPopup;
AudioOsd m_audioOsd;
OsdOverlay m_osdOverlay;
TrayMenu m_trayMenu;
Wallpaper m_wallpaper;
+7
View File
@@ -379,6 +379,13 @@ void ConfigService::loadFromFile(const std::string& path) {
wp.edgeSmoothness = static_cast<float>(*v);
}
// Parse [osd]
if (auto* osdTbl = tbl["osd"].as_table()) {
auto& osd = m_config.osd;
if (auto v = (*osdTbl)["position"].value<std::string>())
osd.position = *v;
}
if (m_config.bars.empty()) {
logInfo("config: no [bar.*] defined, using defaults");
m_config.bars.push_back(BarConfig{});
+5
View File
@@ -82,10 +82,15 @@ struct WallpaperConfig {
float edgeSmoothness = 0.5f;
};
struct OsdConfig {
std::string position = "top_right";
};
struct Config {
std::vector<BarConfig> bars;
std::unordered_map<std::string, WidgetConfig> widgets;
WallpaperConfig wallpaper;
OsdConfig osd;
};
class ConfigService {
+102
View File
@@ -0,0 +1,102 @@
#include "shell/osd/audio_osd.h"
#include "pipewire/pipewire_service.h"
#include "shell/osd/osd_overlay.h"
#include <algorithm>
#include <cmath>
#include <string>
namespace {
const char* volumeIconName(float volume, bool muted) {
if (muted || volume <= 0.0f) {
return "volume-mute";
}
if (volume < 0.4f) {
return "volume-low";
}
return "volume-high";
}
OsdContent makeOutputContent(float volume, bool muted) {
const int percent = static_cast<int>(std::round(std::max(0.0f, volume) * 100.0f));
return OsdContent{
.icon = volumeIconName(volume, muted),
.value = std::to_string(percent) + "%",
.progress = std::clamp(volume, 0.0f, 1.0f),
};
}
OsdContent makeInputContent(float volume, bool muted) {
const int percent = static_cast<int>(std::round(std::max(0.0f, volume) * 100.0f));
return OsdContent{
.icon = muted ? "microphone-mute" : "microphone",
.value = std::to_string(percent) + "%",
.progress = std::clamp(volume, 0.0f, 1.0f),
};
}
} // namespace
void AudioOsd::bindOverlay(OsdOverlay& overlay) { m_overlay = &overlay; }
void AudioOsd::primeFromService(const PipeWireService& service) {
if (const auto* sink = service.defaultSink(); sink != nullptr) {
m_lastSinkId = sink->id;
m_lastSinkPercent = static_cast<int>(std::round(std::max(0.0f, sink->volume) * 100.0f));
m_lastSinkMuted = sink->muted;
}
if (const auto* source = service.defaultSource(); source != nullptr) {
m_lastSourceId = source->id;
m_lastSourcePercent = static_cast<int>(std::round(std::max(0.0f, source->volume) * 100.0f));
m_lastSourceMuted = source->muted;
}
}
void AudioOsd::suppressFor(std::chrono::milliseconds duration) {
m_suppressUntil = std::chrono::steady_clock::now() + duration;
}
void AudioOsd::onAudioStateChanged(const PipeWireService& service) {
const auto* sink = service.defaultSink();
const auto* source = service.defaultSource();
const std::uint32_t sinkId = sink != nullptr ? sink->id : 0;
const int sinkPercent =
sink != nullptr ? static_cast<int>(std::round(std::max(0.0f, sink->volume) * 100.0f)) : 0;
const bool sinkMuted = sink != nullptr ? sink->muted : false;
const std::uint32_t sourceId = source != nullptr ? source->id : 0;
const int sourcePercent =
source != nullptr ? static_cast<int>(std::round(std::max(0.0f, source->volume) * 100.0f)) : 0;
const bool sourceMuted = source != nullptr ? source->muted : false;
if (std::chrono::steady_clock::now() < m_suppressUntil) {
m_lastSinkId = sinkId;
m_lastSinkPercent = sinkPercent;
m_lastSinkMuted = sinkMuted;
m_lastSourceId = sourceId;
m_lastSourcePercent = sourcePercent;
m_lastSourceMuted = sourceMuted;
return;
}
if (m_overlay != nullptr) {
if (sink != nullptr &&
(sinkId != m_lastSinkId || sinkPercent != m_lastSinkPercent || sinkMuted != m_lastSinkMuted)) {
m_overlay->show(makeOutputContent(sink->volume, sinkMuted));
} else if (source != nullptr && (sourceId != m_lastSourceId || sourcePercent != m_lastSourcePercent ||
sourceMuted != m_lastSourceMuted)) {
m_overlay->show(makeInputContent(source->volume, sourceMuted));
}
}
m_lastSinkId = sinkId;
m_lastSinkPercent = sinkPercent;
m_lastSinkMuted = sinkMuted;
m_lastSourceId = sourceId;
m_lastSourcePercent = sourcePercent;
m_lastSourceMuted = sourceMuted;
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <chrono>
#include <cstdint>
class OsdOverlay;
class PipeWireService;
class AudioOsd {
public:
void bindOverlay(OsdOverlay& overlay);
void primeFromService(const PipeWireService& service);
void suppressFor(std::chrono::milliseconds duration);
void onAudioStateChanged(const PipeWireService& service);
private:
OsdOverlay* m_overlay = nullptr;
std::uint32_t m_lastSinkId = 0;
int m_lastSinkPercent = -1;
bool m_lastSinkMuted = false;
std::uint32_t m_lastSourceId = 0;
int m_lastSourcePercent = -1;
bool m_lastSourceMuted = false;
std::chrono::steady_clock::time_point m_suppressUntil{};
};
+319
View File
@@ -0,0 +1,319 @@
#include "shell/osd/osd_overlay.h"
#include "config/config_service.h"
#include "core/log.h"
#include "render/render_context.h"
#include "render/scene/node.h"
#include "ui/controls/box.h"
#include "ui/controls/icon.h"
#include "ui/controls/label.h"
#include "ui/palette.h"
#include "ui/style.h"
#include "wayland/wayland_connection.h"
#include <algorithm>
#include <cmath>
namespace {
constexpr int kSurfaceWidth = 360;
constexpr int kSurfaceHeight = 76;
constexpr int kCardWidth = 348;
constexpr int kCardHeight = 52;
constexpr int kHideDelayMs = 1400;
constexpr float kCardOpacity = 0.98f;
constexpr float kIconSize = 20.0f;
constexpr float kValueFontSize = static_cast<float>(Style::fontSizeTitle);
constexpr float kProgressHeight = 8.0f;
constexpr float kCardPadding = static_cast<float>(Style::spaceMd);
constexpr float kInnerGap = static_cast<float>(Style::spaceMd);
constexpr int kScreenMargin = Style::spaceLg;
constexpr int kBarGap = 4;
} // namespace
void OsdOverlay::initialize(WaylandConnection& wayland, ConfigService* config, RenderContext* renderContext) {
m_wayland = &wayland;
m_config = config;
m_renderContext = renderContext;
}
void OsdOverlay::show(const OsdContent& content) {
if (m_wayland == nullptr || m_renderContext == nullptr) {
return;
}
m_content = content;
ensureSurfaces();
for (auto& inst : m_instances) {
if (inst->sceneRoot == nullptr) {
continue;
}
updateInstanceContent(*inst);
animateInstance(*inst);
inst->surface->requestRedraw();
}
}
void OsdOverlay::ensureSurfaces() {
if (m_wayland == nullptr || m_renderContext == nullptr) {
return;
}
const std::string position =
(m_config != nullptr && !m_config->config().osd.position.empty()) ? m_config->config().osd.position : "top_right";
if (!m_instances.empty() && position != m_lastPosition) {
destroySurfaces();
}
if (!m_instances.empty() && m_instances.size() != m_wayland->outputs().size()) {
destroySurfaces();
}
if (!m_instances.empty()) {
return;
}
m_lastPosition = position;
const auto surfaceWidth = static_cast<std::uint32_t>(kSurfaceWidth);
const auto surfaceHeight = static_cast<std::uint32_t>(kSurfaceHeight);
std::int32_t barHeight = Style::barHeightDefault;
std::string barPosition = "top";
if (m_config != nullptr && !m_config->config().bars.empty()) {
barHeight = m_config->config().bars[0].height;
barPosition = m_config->config().bars[0].position;
}
for (const auto& output : m_wayland->outputs()) {
auto inst = std::make_unique<Instance>();
inst->output = output.output;
inst->scale = output.scale;
std::uint32_t anchor = LayerShellAnchor::Top | LayerShellAnchor::Right;
std::int32_t marginTop = kScreenMargin;
std::int32_t marginRight = kScreenMargin;
std::int32_t marginBottom = 0;
std::int32_t marginLeft = 0;
if (position == "top_left") {
anchor = LayerShellAnchor::Top | LayerShellAnchor::Left;
marginRight = 0;
marginLeft = kScreenMargin;
} else if (position == "top_center") {
anchor = LayerShellAnchor::Top;
marginRight = 0;
} else if (position == "bottom_left") {
anchor = LayerShellAnchor::Bottom | LayerShellAnchor::Left;
marginTop = 0;
marginRight = 0;
marginBottom = kScreenMargin;
marginLeft = kScreenMargin;
} else if (position == "bottom_center") {
anchor = LayerShellAnchor::Bottom;
marginTop = 0;
marginRight = 0;
marginBottom = kScreenMargin;
} else if (position == "bottom_right") {
anchor = LayerShellAnchor::Bottom | LayerShellAnchor::Right;
marginTop = 0;
marginBottom = kScreenMargin;
}
if ((position == "top_left" || position == "top_center" || position == "top_right") && barPosition == "top") {
marginTop += barHeight + kBarGap;
}
if ((position == "bottom_left" || position == "bottom_center" || position == "bottom_right") &&
barPosition == "bottom") {
marginBottom += barHeight + kBarGap;
}
auto surfaceConfig = LayerSurfaceConfig{
.nameSpace = "noctalia-osd",
.layer = LayerShellLayer::Overlay,
.anchor = anchor,
.width = surfaceWidth,
.height = surfaceHeight,
.exclusiveZone = -1,
.marginTop = marginTop,
.marginRight = marginRight,
.marginBottom = marginBottom,
.marginLeft = marginLeft,
.keyboard = LayerShellKeyboard::None,
.defaultWidth = surfaceWidth,
.defaultHeight = surfaceHeight,
};
inst->surface = std::make_unique<LayerSurface>(*m_wayland, std::move(surfaceConfig));
auto* instPtr = inst.get();
inst->surface->setConfigureCallback([this, instPtr](std::uint32_t width, std::uint32_t height) {
buildScene(*instPtr, width, height);
});
inst->surface->setAnimationManager(&inst->animations);
inst->surface->setRenderContext(m_renderContext);
if (!inst->surface->initialize(output.output, output.scale)) {
logWarn("osd overlay: failed to initialize surface on {}", output.connectorName);
continue;
}
inst->wlSurface = inst->surface->wlSurface();
m_instances.push_back(std::move(inst));
}
}
void OsdOverlay::destroySurfaces() {
for (auto& inst : m_instances) {
inst->animations.cancelAll();
}
m_instances.clear();
}
void OsdOverlay::buildScene(Instance& inst, std::uint32_t width, std::uint32_t height) {
if (m_renderContext == nullptr) {
return;
}
const float w = static_cast<float>(width);
const float h = static_cast<float>(height);
inst.sceneRoot = std::make_unique<Node>();
inst.sceneRoot->setSize(w, h);
inst.sceneRoot->setOpacity(0.0f);
inst.surface->setSceneRoot(inst.sceneRoot.get());
const float cardX = (w - static_cast<float>(kCardWidth)) * 0.5f;
const float cardY = (h - static_cast<float>(kCardHeight)) * 0.5f;
auto background = std::make_unique<Box>();
background->setCardStyle();
background->setFill(palette.surface);
background->setBorder(palette.outline, static_cast<float>(Style::borderWidth));
background->setRadius(static_cast<float>(kCardHeight) * 0.5f);
background->setSoftness(1.2f);
background->setSize(static_cast<float>(kCardWidth), static_cast<float>(kCardHeight));
background->setPosition(cardX, cardY);
background->setZIndex(0);
inst.background = background.get();
inst.sceneRoot->addChild(std::move(background));
auto card = std::make_unique<Node>();
card->setSize(static_cast<float>(kCardWidth), static_cast<float>(kCardHeight));
card->setPosition(cardX, cardY);
card->setZIndex(1);
inst.card = card.get();
auto icon = std::make_unique<Icon>();
icon->setIconSize(kIconSize);
icon->setColor(palette.primary);
inst.icon = icon.get();
inst.icon->setZIndex(1);
inst.card->addChild(std::move(icon));
auto value = std::make_unique<Label>();
value->setBold(true);
value->setFontSize(kValueFontSize);
value->setColor(palette.onSurface);
inst.value = value.get();
inst.value->setZIndex(1);
inst.card->addChild(std::move(value));
auto progress = std::make_unique<ProgressBar>();
progress->setRadius(static_cast<float>(Style::radiusFull));
progress->setTrackColor(palette.surface);
progress->setFillColor(palette.primary);
inst.progress = progress.get();
inst.progress->setZIndex(1);
inst.card->addChild(std::move(progress));
inst.sceneRoot->addChild(std::move(card));
updateInstanceContent(inst);
}
void OsdOverlay::updateInstanceContent(Instance& inst) {
if (m_renderContext == nullptr || inst.card == nullptr || inst.icon == nullptr || inst.value == nullptr ||
inst.progress == nullptr) {
return;
}
const float cardWidth = inst.card->width();
inst.icon->setIcon(m_content.icon);
inst.icon->measure(*m_renderContext);
inst.icon->setPosition(kCardPadding,
std::round((inst.card->height() - inst.icon->height()) * 0.5f) - 1.0f);
inst.value->setText(m_content.value);
inst.value->setMaxWidth(cardWidth);
inst.value->measure(*m_renderContext);
inst.value->setPosition(cardWidth - kCardPadding - inst.value->width(),
std::round((inst.card->height() - inst.value->height()) * 0.5f));
const float progressX = inst.icon->x() + inst.icon->width() + kInnerGap;
const float progressWidth = std::max(0.0f, inst.value->x() - progressX - kInnerGap);
inst.progress->setSize(progressWidth, kProgressHeight);
inst.progress->setRadius(kProgressHeight * 0.5f);
inst.progress->setPosition(progressX, std::round((inst.card->height() - kProgressHeight) * 0.5f));
inst.progress->setProgress(m_content.progress);
}
void OsdOverlay::animateInstance(Instance& inst) {
if (inst.sceneRoot == nullptr) {
return;
}
if (inst.showAnimId != 0) {
inst.animations.cancel(inst.showAnimId);
inst.showAnimId = 0;
}
if (inst.hideAnimId != 0) {
inst.animations.cancel(inst.hideAnimId);
inst.hideAnimId = 0;
}
const float baseY = (inst.sceneRoot->height() - static_cast<float>(kCardHeight)) * 0.5f;
if (!inst.visible) {
inst.sceneRoot->setOpacity(0.0f);
inst.card->setPosition(inst.card->x(), baseY + 8.0f);
if (inst.background != nullptr) {
inst.background->setPosition(inst.background->x(), baseY + 8.0f);
}
inst.showAnimId = inst.animations.animate(
0.0f, 1.0f, Style::animNormal, Easing::EaseOutCubic,
[&inst, baseY](float v) {
inst.sceneRoot->setOpacity(v);
inst.card->setPosition(inst.card->x(), baseY + (1.0f - v) * 8.0f);
if (inst.background != nullptr) {
inst.background->setPosition(inst.background->x(), baseY + (1.0f - v) * 8.0f);
}
},
[&inst]() {
inst.showAnimId = 0;
inst.visible = true;
});
} else {
inst.sceneRoot->setOpacity(1.0f);
inst.card->setPosition(inst.card->x(), baseY);
if (inst.background != nullptr) {
inst.background->setPosition(inst.background->x(), baseY);
}
}
inst.hideAnimId = inst.animations.animate(
1.0f, 0.0f, kHideDelayMs, Easing::Linear, [](float /*v*/) {},
[&inst, baseY]() {
inst.hideAnimId = inst.animations.animate(
1.0f, 0.0f, Style::animNormal, Easing::EaseInOutQuad,
[&inst, baseY](float v) {
inst.sceneRoot->setOpacity(v);
inst.card->setPosition(inst.card->x(), baseY + (1.0f - v) * 6.0f);
if (inst.background != nullptr) {
inst.background->setPosition(inst.background->x(), baseY + (1.0f - v) * 6.0f);
}
},
[&inst]() {
inst.hideAnimId = 0;
inst.visible = false;
});
});
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include "render/animation/animation_manager.h"
#include "ui/controls/progress_bar.h"
#include "wayland/layer_surface.h"
#include <memory>
#include <string>
#include <vector>
class ConfigService;
class Box;
class Icon;
class Label;
class Node;
class RenderContext;
class WaylandConnection;
struct wl_surface;
struct OsdContent {
std::string icon;
std::string value;
float progress = 0.0f;
};
class OsdOverlay {
public:
OsdOverlay() = default;
~OsdOverlay() = default;
OsdOverlay(const OsdOverlay&) = delete;
OsdOverlay& operator=(const OsdOverlay&) = delete;
void initialize(WaylandConnection& wayland, ConfigService* config, RenderContext* renderContext);
void show(const OsdContent& content);
private:
struct Instance {
wl_output* output = nullptr;
std::int32_t scale = 1;
std::unique_ptr<LayerSurface> surface;
std::unique_ptr<Node> sceneRoot;
AnimationManager animations;
wl_surface* wlSurface = nullptr;
Node* card = nullptr;
Box* background = nullptr;
Icon* icon = nullptr;
Label* value = nullptr;
ProgressBar* progress = nullptr;
AnimationManager::Id showAnimId = 0;
AnimationManager::Id hideAnimId = 0;
bool visible = false;
};
void ensureSurfaces();
void destroySurfaces();
void buildScene(Instance& inst, std::uint32_t width, std::uint32_t height);
void updateInstanceContent(Instance& inst);
void animateInstance(Instance& inst);
WaylandConnection* m_wayland = nullptr;
ConfigService* m_config = nullptr;
RenderContext* m_renderContext = nullptr;
OsdContent m_content;
std::string m_lastPosition;
std::vector<std::unique_ptr<Instance>> m_instances;
};