Files
noctalia-shell/Modules/Bar/Bar.qml
T

709 lines
26 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import Quickshell.Wayland
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Modules.Notification
import qs.Modules.Panels.Settings
import qs.Services.Compositor
import qs.Services.UI
import qs.Widgets
// Bar Component
Item {
id: root
// This property will be set by MainScreen
property ShellScreen screen: null
// Filter widgets to only include those that exist in the registry
// This prevents errors when plugins are missing or widgets are being cleaned up
function filterValidWidgets(widgets: list<var>): list<var> {
if (!widgets)
return [];
return widgets.filter(function (w) {
return w && w.id && BarWidgetRegistry.hasWidget(w.id);
});
}
// Hot corner: trigger click on first widget in a section
function triggerFirstWidgetInSection(sectionName: string) {
var widgets = BarService.getWidgetsBySection(sectionName, screen?.name);
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
if (widget && widget.visible && widget.widgetId !== "Spacer") {
if (typeof widget.clicked === "function") {
widget.clicked();
}
return;
}
}
}
// Hot corner: trigger click on last widget in a section
function triggerLastWidgetInSection(sectionName: string) {
var widgets = BarService.getWidgetsBySection(sectionName, screen?.name);
for (var i = widgets.length - 1; i >= 0; i--) {
var widget = widgets[i];
if (widget && widget.visible && widget.widgetId !== "Spacer") {
if (typeof widget.clicked === "function") {
widget.clicked();
}
return;
}
}
}
// Expose bar region for click-through mask
readonly property var barRegion: barContentLoader.item?.children[0] || null
// Expose the actual bar Item for unified background system
readonly property var barItem: barRegion
// Bar positioning properties (per-screen)
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
readonly property bool barFloating: Settings.data.bar.barType === "floating"
// Bar density (per-screen)
readonly property string barDensity: Settings.getBarDensityForScreen(screen?.name)
// Bar sizing based on per-screen density
readonly property real barHeight: Style.getBarHeightForDensity(barDensity, barIsVertical)
readonly property real capsuleHeight: Style.getCapsuleHeightForDensity(barDensity, barHeight)
readonly property real barFontSize: Style.getBarFontSizeForDensity(barHeight, capsuleHeight, barIsVertical)
// Bar widgets (per-screen) - initial configuration
// Note: Updates are handled via Connections to BarService.widgetsRevisionChanged
readonly property var barWidgets: Settings.getBarWidgetsForScreen(screen?.name)
// Stable ListModels for each section - prevents Repeater recreation on settings changes
property ListModel leftWidgetsModel: ListModel {}
property ListModel centerWidgetsModel: ListModel {}
property ListModel rightWidgetsModel: ListModel {}
// Guard: set when Bar is destroyed; prevents Qt.callLater callbacks from running
// during/after teardown (avoids SIGSEGV in QV4::Object::insertMember when rapid
// workspace switch causes load/unload overlap with async widget incubation)
property bool _destroyed: false
Component.onDestruction: root._destroyed = true
// Sync a ListModel with widget data, preserving delegates when only settings change
function syncWidgetModel(model, newWidgets) {
var validWidgets = filterValidWidgets(newWidgets);
// Build list of current IDs in model
var currentIds = [];
for (var i = 0; i < model.count; i++) {
currentIds.push(model.get(i).id);
}
// Build list of new IDs
var newIds = validWidgets.map(w => w.id);
// Check if structure changed (different IDs or order)
var structureChanged = currentIds.length !== newIds.length;
if (!structureChanged) {
for (var i = 0; i < currentIds.length; i++) {
if (currentIds[i] !== newIds[i]) {
structureChanged = true;
break;
}
}
}
Logger.d("Bar", "syncWidgetModel:", currentIds.join("|"), "→", newIds.join("|"), "changed:", structureChanged);
if (structureChanged) {
// Rebuild model - IDs changed
model.clear();
for (var i = 0; i < validWidgets.length; i++) {
model.append(validWidgets[i]);
}
}
// If structure didn't change, delegates are preserved and will read fresh settings
}
// Sync models when widget revision changes
// Note: We use Connections instead of onBarWidgetsChanged because getBarWidgetsForScreen
// returns the same object reference (Settings.data.bar.widgets) even when content changes,
// so QML won't detect the change via property binding.
Connections {
target: BarService
function onWidgetsRevisionChanged() {
Logger.d("Bar", "onWidgetsRevisionChanged, revision:", BarService.widgetsRevision, "screen:", root.screen?.name);
Qt.callLater(root._syncFromRevision);
}
}
function _syncFromRevision() {
if (root._destroyed)
return;
var widgets = Settings.getBarWidgetsForScreen(screen?.name);
if (widgets) {
syncWidgetModel(leftWidgetsModel, widgets.left);
syncWidgetModel(centerWidgetsModel, widgets.center);
syncWidgetModel(rightWidgetsModel, widgets.right);
}
}
// Initialize models — deferred to next event-loop tick via Qt.callLater to avoid
// re-entrant incubation: Component.onCompleted fires during QQmlObjectCreator::finalize,
// and ListModel.append synchronously creates Repeater delegates whose own finalization
// can corrupt the V4 heap (SIGSEGV in QV4::Object::insertMember).
Component.onCompleted: {
Logger.d("Bar", "Bar Component.onCompleted for screen:", screen?.name);
Qt.callLater(root._initModels);
}
function _initModels() {
if (root._destroyed)
return;
var widgets = Settings.getBarWidgetsForScreen(screen?.name);
if (widgets) {
syncWidgetModel(leftWidgetsModel, widgets.left);
syncWidgetModel(centerWidgetsModel, widgets.center);
syncWidgetModel(rightWidgetsModel, widgets.right);
}
}
// Fill the parent (the Loader)
anchors.fill: parent
// Register bar when screen becomes available
onScreenChanged: {
if (screen && screen.name) {
Logger.d("Bar", "Bar screen set to:", screen.name);
Logger.d("Bar", " Position:", barPosition, "Floating:", barFloating);
BarService.registerBar(screen.name);
}
}
// Wait for screen to be set before loading bar content
Loader {
id: barContentLoader
anchors.fill: parent
active: {
if (root.screen === null || root.screen === undefined) {
return false;
}
var monitors = Settings.data.bar.monitors || [];
var result = monitors.length === 0 || monitors.includes(root.screen.name);
return result;
}
sourceComponent: Item {
anchors.fill: parent
// Bar container - Content
Item {
id: bar
// Wheel scroll handling (empty bar area)
property int barWheelAccumulatedDelta: 0
property bool barWheelCooldown: false
readonly property string barWheelAction: {
return Settings.data.bar.mouseWheelAction || "none";
}
readonly property string barRightClickAction: Settings.data.bar.rightClickAction || "controlCenter"
// Position and size the bar content based on orientation
x: (root.barPosition === "right") ? (parent.width - root.barHeight) : 0
y: (root.barPosition === "bottom") ? (parent.height - root.barHeight) : 0
width: root.barIsVertical ? root.barHeight : parent.width
height: root.barIsVertical ? parent.height : root.barHeight
// Corner states for new unified background system
// State -1: No radius (flat/square corner)
// State 0: Normal (inner curve)
// State 1: Horizontal inversion (outer curve on X-axis)
// State 2: Vertical inversion (outer curve on Y-axis)
readonly property int topLeftCornerState: {
// Floating bar: always simple rounded corners
if (barFloating)
return 0;
// Top bar: top corners against screen edge = no radius
if (barPosition === "top")
return -1;
// Left bar: top-left against screen edge = no radius
if (barPosition === "left")
return -1;
// Bottom/Right bar with outerCorners: inverted corner
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "right")) {
return barIsVertical ? 1 : 2; // horizontal invert for vertical bars, vertical invert for horizontal
}
// No outerCorners = square
return -1;
}
readonly property int topRightCornerState: {
// Floating bar: always simple rounded corners
if (barFloating)
return 0;
// Top bar: top corners against screen edge = no radius
if (barPosition === "top")
return -1;
// Right bar: top-right against screen edge = no radius
if (barPosition === "right")
return -1;
// Bottom/Left bar with outerCorners: inverted corner
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "left")) {
return barIsVertical ? 1 : 2;
}
// No outerCorners = square
return -1;
}
readonly property int bottomLeftCornerState: {
// Floating bar: always simple rounded corners
if (barFloating)
return 0;
// Bottom bar: bottom corners against screen edge = no radius
if (barPosition === "bottom")
return -1;
// Left bar: bottom-left against screen edge = no radius
if (barPosition === "left")
return -1;
// Top/Right bar with outerCorners: inverted corner
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "right")) {
return barIsVertical ? 1 : 2;
}
// No outerCorners = square
return -1;
}
readonly property int bottomRightCornerState: {
// Floating bar: always simple rounded corners
if (barFloating)
return 0;
// Bottom bar: bottom corners against screen edge = no radius
if (barPosition === "bottom")
return -1;
// Right bar: bottom-right against screen edge = no radius
if (barPosition === "right")
return -1;
// Top/Left bar with outerCorners: inverted corner
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "left")) {
return barIsVertical ? 1 : 2;
}
// No outerCorners = square
return -1;
}
function isPointOverWidget(xPos, yPos) {
var widgets = BarService.getAllWidgetInstances(null, screen.name);
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
if (!widget || !widget.visible || widget.widgetId === "Spacer") {
continue;
}
var localPos = mapToItem(widget, xPos, yPos);
if (root.barIsVertical) {
if (localPos.y >= -Style.marginS && localPos.y <= widget.height + Style.marginS) {
return true;
}
} else {
if (localPos.x >= -Style.marginS && localPos.x <= widget.width + Style.marginS) {
return true;
}
}
}
return false;
}
function switchWorkspaceByOffset(offset) {
if (!root.screen || CompositorService.workspaces.count === 0)
return;
var screenName = root.screen.name.toLowerCase();
var candidates = [];
for (var i = 0; i < CompositorService.workspaces.count; i++) {
var ws = CompositorService.workspaces.get(i);
var matchesScreen = CompositorService.globalWorkspaces || (ws.output && ws.output.toLowerCase() === screenName);
if (matchesScreen)
candidates.push(ws);
}
if (candidates.length <= 1)
return;
var current = -1;
for (var j = 0; j < candidates.length; j++) {
if (candidates[j].isFocused) {
current = j;
break;
}
}
if (current < 0)
current = 0;
var next = current + offset;
if (Settings.data.bar.mouseWheelWrap) {
next = next % candidates.length;
if (next < 0)
next = candidates.length - 1;
} else {
if (next < 0 || next >= candidates.length)
return;
}
if (next === current)
return;
CompositorService.switchToWorkspace(candidates[next]);
}
function handleEmptyBarClick(action, followMouse, command, mouse) {
if (action === "none")
return;
if (action === "controlCenter") {
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", screen);
controlCenterPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : "ControlCenter");
mouse.accepted = true;
} else if (action === "settings") {
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
settingsPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : null);
mouse.accepted = true;
} else if (action === "launcherPanel") {
var launcherPanel = PanelService.getPanel("launcherPanel", screen);
launcherPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : null);
mouse.accepted = true;
} else if (action === "command") {
runCustomCommand(command);
mouse.accepted = true;
}
}
function runCustomCommand(command) {
if (!command || command.trim() === "")
return;
const processString = "import QtQuick; import Quickshell.Io; Process { command: [\"sh\", \"-lc\", \"\"] }";
try {
const processObj = Qt.createQmlObject(processString, root, "BarCommandProcess_" + Date.now());
processObj.command = ["sh", "-lc", command];
processObj.exited.connect(function (exitCode) {
if (exitCode !== 0) {
ToastService.showError(I18n.tr("toast.custom-command-failed.title"), I18n.tr("toast.custom-command-failed.description", {
command: command,
code: exitCode
}));
}
processObj.destroy();
});
processObj.running = true;
} catch (e) {
Logger.e("Bar", "Failed to start custom command:", e);
ToastService.showError(I18n.tr("toast.custom-command-failed.title"), I18n.tr("toast.custom-command-failed.description", {
command: command,
code: "start_error"
}));
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton | Qt.MiddleButton
enabled: bar.barRightClickAction !== "none" || Settings.data.bar.middleClickAction !== "none"
hoverEnabled: false
preventStealing: true
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
if (bar.isPointOverWidget(mouse.x, mouse.y))
return;
bar.handleEmptyBarClick(bar.barRightClickAction, Settings.data.bar.rightClickFollowMouse, Settings.data.bar.rightClickCommand, mouse);
return;
}
if (mouse.button === Qt.MiddleButton) {
if (bar.isPointOverWidget(mouse.x, mouse.y))
return;
bar.handleEmptyBarClick(Settings.data.bar.middleClickAction || "none", Settings.data.bar.middleClickFollowMouse, Settings.data.bar.middleClickCommand, mouse);
return;
}
}
}
// Debounce timer for wheel interactions
Timer {
id: barWheelDebounce
interval: 150
repeat: false
onTriggered: {
bar.barWheelCooldown = false;
bar.barWheelAccumulatedDelta = 0;
}
}
// Scroll on empty bar area to switch workspaces
WheelHandler {
id: barWheelHandler
target: bar
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
enabled: bar.barWheelAction !== "none"
onWheel: function (event) {
if (bar.barWheelCooldown)
return;
if (bar.isPointOverWidget(event.x, event.y))
return;
var dy = event.angleDelta.y;
var dx = event.angleDelta.x;
var useDy = Math.abs(dy) >= Math.abs(dx);
var delta = useDy ? dy : dx;
bar.barWheelAccumulatedDelta += delta;
var step = 120;
if (Math.abs(bar.barWheelAccumulatedDelta) >= step) {
var direction = bar.barWheelAccumulatedDelta > 0 ? -1 : 1;
if (Settings.data.bar.reverseScroll)
direction *= -1;
if (bar.barWheelAction === "workspace") {
bar.switchWorkspaceByOffset(direction);
} else if (bar.barWheelAction === "content") {
CompositorService.scrollWorkspaceContent(direction);
}
bar.barWheelCooldown = true;
barWheelDebounce.restart();
bar.barWheelAccumulatedDelta = 0;
event.accepted = true;
}
}
}
Loader {
anchors.fill: parent
sourceComponent: root.barIsVertical ? verticalBarComponent : horizontalBarComponent
}
}
}
}
// For vertical bars
Component {
id: verticalBarComponent
Item {
anchors.fill: parent
clip: true
// Top edge hot corner - triggers first widget in left (top) section
MouseArea {
width: parent.width
height: Style.marginS
x: 0
y: 0
onClicked: root.triggerFirstWidgetInSection("left")
}
// Bottom edge hot corner - triggers last widget in right (bottom) section
MouseArea {
width: parent.width
height: Style.marginS
x: 0
anchors.bottom: parent.bottom
onClicked: root.triggerLastWidgetInSection("right")
}
// Calculate margin to center widgets vertically within the bar height
readonly property real verticalBarMargin: Math.round((root.barHeight - root.capsuleHeight) / 2)
// Top section (left widgets)
ColumnLayout {
x: Style.pixelAlignCenter(parent.width, width)
anchors.top: parent.top
anchors.topMargin: verticalBarMargin + Settings.data.bar.contentPadding
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.leftWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "left",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.leftWidgetsModel.count
})
Layout.alignment: Qt.AlignHCenter
}
}
}
// Center section (center widgets)
ColumnLayout {
x: Style.pixelAlignCenter(parent.width, width)
anchors.verticalCenter: parent.verticalCenter
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.centerWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "center",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.centerWidgetsModel.count
})
Layout.alignment: Qt.AlignHCenter
}
}
}
// Bottom section (right widgets)
ColumnLayout {
x: Style.pixelAlignCenter(parent.width, width)
anchors.bottom: parent.bottom
anchors.bottomMargin: verticalBarMargin + Settings.data.bar.contentPadding
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.rightWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "right",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.rightWidgetsModel.count
})
Layout.alignment: Qt.AlignHCenter
}
}
}
}
}
// For horizontal bars
Component {
id: horizontalBarComponent
Item {
anchors.fill: parent
clip: true
// Left edge hot corner - triggers first widget in left section
MouseArea {
width: Style.marginS
height: parent.height
x: 0
y: 0
onClicked: root.triggerFirstWidgetInSection("left")
}
// Right edge hot corner - triggers last widget in right section
MouseArea {
width: Style.marginS
height: parent.height
anchors.right: parent.right
y: 0
onClicked: root.triggerLastWidgetInSection("right")
}
// Calculate margin to center widgets horizontally within the bar height
readonly property real horizontalBarMargin: Math.round((root.barHeight - root.capsuleHeight) / 2)
// Left Section
RowLayout {
id: leftSection
objectName: "leftSection"
anchors.left: parent.left
anchors.leftMargin: horizontalBarMargin + Settings.data.bar.contentPadding
y: Style.pixelAlignCenter(parent.height, height)
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.leftWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "left",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.leftWidgetsModel.count
})
Layout.alignment: Qt.AlignVCenter
}
}
}
// Center Section
RowLayout {
id: centerSection
objectName: "centerSection"
anchors.horizontalCenter: parent.horizontalCenter
y: Style.pixelAlignCenter(parent.height, height)
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.centerWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "center",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.centerWidgetsModel.count
})
Layout.alignment: Qt.AlignVCenter
}
}
}
// Right Section
RowLayout {
id: rightSection
objectName: "rightSection"
anchors.right: parent.right
anchors.rightMargin: horizontalBarMargin + Settings.data.bar.contentPadding
y: Style.pixelAlignCenter(parent.height, height)
spacing: Settings.data.bar.widgetSpacing
Repeater {
model: root.rightWidgetsModel
delegate: BarWidgetLoader {
required property var model
required property int index
widgetId: model.id || ""
widgetScreen: root.screen
widgetProps: ({
"widgetId": model.id,
"section": "right",
"sectionWidgetIndex": index,
"sectionWidgetsCount": root.rightWidgetsModel.count
})
Layout.alignment: Qt.AlignVCenter
}
}
}
}
}
}