Merge pull request #807 from lonerOrz/feat/custombutton

Enhance custom button
This commit is contained in:
Lemmy
2025-11-21 12:02:57 -05:00
committed by GitHub
14 changed files with 614 additions and 67 deletions
+5 -9
View File
@@ -51,10 +51,9 @@ Item {
// Sizing logic for vertical bars
readonly property int buttonSize: Style.capsuleHeight
readonly property int pillHeight: buttonSize
readonly property int pillPaddingVertical: 3 * 2 // Very precise adjustment don't replace by Style.margin
readonly property int pillOverlap: Math.round(buttonSize * 0.5)
readonly property int maxPillWidth: rotateText ? Math.max(buttonSize, Math.round(textItem.implicitHeight + pillPaddingVertical * 2)) : buttonSize
readonly property int maxPillHeight: rotateText ? Math.max(1, Math.round(textItem.implicitWidth + pillPaddingVertical * 2 + Math.round(iconCircle.height / 4))) : Math.max(1, Math.round(textItem.implicitHeight + pillPaddingVertical * 4))
readonly property int maxPillWidth: rotateText ? Math.max(buttonSize, Math.round(textItem.implicitHeight + Style.marginM * 2)) : buttonSize
readonly property int maxPillHeight: rotateText ? Math.max(1, Math.round(textItem.implicitWidth + Style.marginM * 2 + Math.round(iconCircle.height / 4))) : Math.max(1, Math.round(textItem.implicitHeight + Style.marginM * 2))
readonly property real iconSize: {
switch (root.density) {
@@ -132,7 +131,7 @@ Item {
id: textItem
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: rotateText ? Math.round(iconCircle.height / 4) : getVerticalCenterOffset()
anchors.verticalCenterOffset: openDownward ? Style.marginXXS : -Style.marginXXS
rotation: rotateText ? -90 : 0
text: root.text + root.suffix
family: Settings.data.ui.fontFixed
@@ -145,11 +144,8 @@ Item {
visible: revealed
function getVerticalCenterOffset() {
var offset = openDownward ? Math.round(pillPaddingVertical * 0.75) : -Math.round(pillPaddingVertical * 0.75);
if (forceOpen) {
offset += oppositeDirection ? -Style.marginXXS : Style.marginXXS;
}
return offset;
// A small, symmetrical offset to push the text slightly away from the icon's edge.
return openDownward ? Style.marginXS : -Style.marginXS;
}
}
Behavior on width {
+231 -20
View File
@@ -39,15 +39,21 @@ Item {
readonly property bool rightClickUpdateText: widgetSettings.rightClickUpdateText ?? widgetMetadata.rightClickUpdateText
readonly property string middleClickExec: widgetSettings.middleClickExec || widgetMetadata.middleClickExec
readonly property bool middleClickUpdateText: widgetSettings.middleClickUpdateText ?? widgetMetadata.middleClickUpdateText
readonly property string wheelExec: widgetSettings.wheelExec || widgetMetadata.wheelExec
readonly property string wheelUpExec: widgetSettings.wheelUpExec || widgetMetadata.wheelUpExec
readonly property string wheelDownExec: widgetSettings.wheelDownExec || widgetMetadata.wheelDownExec
readonly property string wheelMode: widgetSettings.wheelMode || widgetMetadata.wheelMode
readonly property bool wheelUpdateText: widgetSettings.wheelUpdateText ?? widgetMetadata.wheelUpdateText
readonly property bool wheelUpUpdateText: widgetSettings.wheelUpUpdateText ?? widgetMetadata.wheelUpUpdateText
readonly property bool wheelDownUpdateText: widgetSettings.wheelDownUpdateText ?? widgetMetadata.wheelDownUpdateText
readonly property string textCommand: widgetSettings.textCommand !== undefined ? widgetSettings.textCommand : (widgetMetadata.textCommand || "")
readonly property bool textStream: widgetSettings.textStream !== undefined ? widgetSettings.textStream : (widgetMetadata.textStream || false)
readonly property int textIntervalMs: widgetSettings.textIntervalMs !== undefined ? widgetSettings.textIntervalMs : (widgetMetadata.textIntervalMs || 3000)
readonly property string textCollapse: widgetSettings.textCollapse !== undefined ? widgetSettings.textCollapse : (widgetMetadata.textCollapse || "")
readonly property bool parseJson: widgetSettings.parseJson !== undefined ? widgetSettings.parseJson : (widgetMetadata.parseJson || false)
readonly property bool hideTextInVerticalBar: widgetSettings.hideTextInVerticalBar !== undefined ? widgetSettings.hideTextInVerticalBar : (widgetMetadata.hideTextInVerticalBar || false)
readonly property bool hasExec: (leftClickExec || rightClickExec || middleClickExec)
readonly property bool shouldShowText: !isVerticalBar || !hideTextInVerticalBar
readonly property bool hasExec: (leftClickExec || rightClickExec || middleClickExec ||
(wheelMode === "unified" && wheelExec) ||
(wheelMode === "separate" && (wheelUpExec || wheelDownExec)))
implicitWidth: pill.width
implicitHeight: pill.height
@@ -58,9 +64,9 @@ Item {
screen: root.screen
oppositeDirection: BarService.getPillDirection(root)
icon: _dynamicIcon !== "" ? _dynamicIcon : customIcon
text: shouldShowText ? _dynamicText : ""
text: (!isVerticalBar || currentMaxTextLength > 0) ? _dynamicText : ""
density: Settings.data.bar.density
rotateText: isVerticalBar && !hideTextInVerticalBar
rotateText: isVerticalBar && currentMaxTextLength > 0
autoHide: false
forceOpen: _dynamicText !== ""
tooltipText: {
@@ -76,6 +82,16 @@ Item {
if (middleClickExec !== "") {
tooltipLines.push(`Middle click: ${middleClickExec}.`);
}
if (wheelMode === "unified" && wheelExec !== "") {
tooltipLines.push(`Wheel: ${wheelExec}.`);
} else if (wheelMode === "separate") {
if (wheelUpExec !== "") {
tooltipLines.push(`Wheel up: ${wheelUpExec}.`);
}
if (wheelDownExec !== "") {
tooltipLines.push(`Wheel down: ${wheelDownExec}.`);
}
}
}
if (_dynamicTooltip !== "") {
@@ -95,6 +111,7 @@ Item {
onClicked: root.onClicked()
onRightClicked: root.onRightClicked()
onMiddleClicked: root.onMiddleClicked()
onWheel: delta => root.onWheel(delta)
}
// Internal state for dynamic text
@@ -102,12 +119,39 @@ Item {
property string _dynamicIcon: ""
property string _dynamicTooltip: ""
// Maximum length for text display before scrolling (different values for horizontal and vertical)
readonly property var maxTextLength: {
"horizontal": ((widgetSettings && widgetSettings.maxTextLength && widgetSettings.maxTextLength.horizontal !== undefined) ?
widgetSettings.maxTextLength.horizontal :
((widgetMetadata && widgetMetadata.maxTextLength && widgetMetadata.maxTextLength.horizontal !== undefined) ?
widgetMetadata.maxTextLength.horizontal :
10)),
"vertical": ((widgetSettings && widgetSettings.maxTextLength && widgetSettings.maxTextLength.vertical !== undefined) ?
widgetSettings.maxTextLength.vertical :
((widgetMetadata && widgetMetadata.maxTextLength && widgetMetadata.maxTextLength.vertical !== undefined) ?
widgetMetadata.maxTextLength.vertical :
10))
}
readonly property int _staticDuration: 6 // How many cycles to stay static at start/end
// Encapsulated state for scrolling text implementation
property var _scrollState: {
"originalText": "",
"needsScrolling": false,
"offset": 0,
"phase": 0, // 0=static start, 1=scrolling, 2=static end
"phaseCounter": 0
}
// Current max text length based on bar orientation
readonly property int currentMaxTextLength: isVerticalBar ? maxTextLength.vertical : maxTextLength.horizontal
// Periodically run the text command (if set)
Timer {
id: refreshTimer
interval: Math.max(250, textIntervalMs)
repeat: true
running: shouldShowText && !textStream && textCommand && textCommand.length > 0
running: (!isVerticalBar || currentMaxTextLength > 0) && !textStream && textCommand && textCommand.length > 0
triggeredOnStart: true
onTriggered: root.runTextCommand()
}
@@ -116,10 +160,58 @@ Item {
Timer {
id: restartTimer
interval: 1000
running: shouldShowText && textStream && !textProc.running
running: (!isVerticalBar || currentMaxTextLength > 0) && textStream && !textProc.running
onTriggered: root.runTextCommand()
}
// Timer for scrolling text display
Timer {
id: scrollTimer
interval: 300
repeat: true
running: false
onTriggered: {
if (_scrollState.needsScrolling && _scrollState.originalText.length > currentMaxTextLength) {
// Traditional marquee with pause at beginning and end
if (_scrollState.phase === 0) { // Static at beginning
_dynamicText = _scrollState.originalText.substring(0, Math.min(currentMaxTextLength, _scrollState.originalText.length));
_scrollState.phaseCounter++;
if (_scrollState.phaseCounter >= _staticDuration) {
_scrollState.phaseCounter = 0;
_scrollState.phase = 1; // Move to scrolling
}
} else if (_scrollState.phase === 1) { // Scrolling
_scrollState.offset++;
var start = _scrollState.offset;
var end = start + currentMaxTextLength;
if (start >= _scrollState.originalText.length - currentMaxTextLength) {
// Reached or passed the end, ensure we show the last part
var textEnd = _scrollState.originalText.length;
var textStart = Math.max(0, textEnd - currentMaxTextLength);
_dynamicText = _scrollState.originalText.substring(textStart, textEnd);
_scrollState.phase = 2; // Move to static end phase
_scrollState.phaseCounter = 0;
} else {
_dynamicText = _scrollState.originalText.substring(start, end);
}
} else if (_scrollState.phase === 2) { // Static at end
// Ensure end text is displayed correctly
var textEnd = _scrollState.originalText.length;
var textStart = Math.max(0, textEnd - currentMaxTextLength);
_dynamicText = _scrollState.originalText.substring(textStart, textEnd);
_scrollState.phaseCounter++;
if (_scrollState.phaseCounter >= _staticDuration) {
// Do NOT loop back to start, just stop scrolling
scrollTimer.stop();
}
}
} else {
scrollTimer.stop();
}
}
}
SplitParser {
id: textStdoutSplit
onRead: line => root.parseDynamicContent(line)
@@ -162,16 +254,33 @@ Item {
let tooltip = parsed.tooltip || "";
if (checkCollapse(text)) {
_scrollState.originalText = "";
_dynamicText = "";
_dynamicIcon = "";
_dynamicTooltip = "";
_scrollState.needsScrolling = false;
_scrollState.phase = 0;
_scrollState.phaseCounter = 0;
return;
}
_dynamicText = text;
_scrollState.originalText = text;
_scrollState.needsScrolling = text.length > currentMaxTextLength && currentMaxTextLength > 0;
if (_scrollState.needsScrolling) {
// Start with the beginning of the text
_dynamicText = text.substring(0, currentMaxTextLength);
_scrollState.phase = 0; // Start at phase 0 (static beginning)
_scrollState.phaseCounter = 0;
_scrollState.offset = 0;
scrollTimer.start(); // Start the scrolling timer
} else {
_dynamicText = text;
scrollTimer.stop();
}
_dynamicIcon = icon;
_dynamicTooltip = toHtml(tooltip);
_scrollState.offset = 0;
return;
} catch (e) {
Logger.w("CustomButton", `Failed to parse JSON. Content: "${lineToParse}"`);
@@ -179,15 +288,32 @@ Item {
}
if (checkCollapse(contentStr)) {
_scrollState.originalText = "";
_dynamicText = "";
_dynamicIcon = "";
_dynamicTooltip = "";
_scrollState.needsScrolling = false;
_scrollState.phase = 0;
_scrollState.phaseCounter = 0;
return;
}
_dynamicText = contentStr;
_scrollState.originalText = contentStr;
_scrollState.needsScrolling = contentStr.length > currentMaxTextLength && currentMaxTextLength > 0;
if (_scrollState.needsScrolling) {
// Start with the beginning of the text
_dynamicText = contentStr.substring(0, currentMaxTextLength);
_scrollState.phase = 0; // Start at phase 0 (static beginning)
_scrollState.phaseCounter = 0;
_scrollState.offset = 0;
scrollTimer.start(); // Start the scrolling timer
} else {
_dynamicText = contentStr;
scrollTimer.stop();
}
_dynamicIcon = "";
_dynamicTooltip = toHtml(contentStr);
_scrollState.offset = 0;
}
function checkCollapse(text) {
@@ -215,8 +341,8 @@ Item {
if (leftClickExec) {
Quickshell.execDetached(["sh", "-c", leftClickExec]);
Logger.i("CustomButton", `Executing command: ${leftClickExec}`);
} else if (!hasExec && !leftClickUpdateText) {
// No script was defined, open settings
} else if (!leftClickUpdateText) {
// No left click script was defined, open settings
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
settingsPanel.requestedTab = SettingsPanel.Tab.Bar;
settingsPanel.open();
@@ -247,17 +373,25 @@ Item {
}
function toHtml(str) {
const htmlRegex = /<\/?[a-zA-Z][\s\S]*>/;
const htmlTagRegex = /<\/?[a-zA-Z][^>]*>/g;
const placeholders = [];
let i = 0;
const protectedStr = str.replace(htmlTagRegex, tag => {
placeholders.push(tag);
return `___HTML_TAG_${i++}___`;
});
if (htmlRegex.test(str)) {
return str;
}
let escaped = protectedStr
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\r\n|\r|\n/g, "<br/>");
const escaped = str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
escaped = escaped.replace(/___HTML_TAG_(\d+)___/g, (_, index) => placeholders[Number(index)]);
const withBreaks = escaped.replace(/\r\n|\r|\n/g, "<br/>");
return withBreaks;
return escaped;
}
function runTextCommand() {
@@ -268,4 +402,81 @@ Item {
textProc.command = ["sh", "-lc", textCommand];
textProc.running = true;
}
function onWheel(delta) {
if (wheelMode === "unified" && wheelExec) {
let normalizedDelta = delta > 0 ? 1 : -1;
let command = wheelExec.replace(/\$delta([+\-*/]\d+)?/g, function(match, operation) {
if (operation) {
try {
let operator = operation.charAt(0);
let operand = parseInt(operation.substring(1));
let result;
switch(operator) {
case '+': result = normalizedDelta + operand; break;
case '-': result = normalizedDelta - operand; break;
case '*': result = normalizedDelta * operand; break;
case '/': result = Math.floor(normalizedDelta / operand); break;
default: result = normalizedDelta;
}
return result.toString();
} catch (e) {
Logger.w("CustomButton", `Error evaluating expression: ${match}, using normalized value ${normalizedDelta}`);
return normalizedDelta.toString();
}
} else {
return normalizedDelta.toString();
}
});
Quickshell.execDetached(["sh", "-c", command])
Logger.i("CustomButton", `Executing command: ${command}`)
} else if (wheelMode === "separate") {
if ((delta > 0 && wheelUpExec) || (delta < 0 && wheelDownExec)) {
let commandExec = delta > 0 ? wheelUpExec : wheelDownExec;
let normalizedDelta = delta > 0 ? 1 : -1;
let command = commandExec.replace(/\$delta([+\-*/]\d+)?/g, function(match, operation) {
if (operation) {
try {
let operator = operation.charAt(0);
let operand = parseInt(operation.substring(1));
let result;
switch(operator) {
case '+': result = normalizedDelta + operand; break;
case '-': result = normalizedDelta - operand; break;
case '*': result = normalizedDelta * operand; break;
case '/': result = Math.floor(normalizedDelta / operand); break;
default: result = normalizedDelta;
}
return result.toString();
} catch (e) {
Logger.w("CustomButton", `Error evaluating expression: ${match}, using normalized value ${normalizedDelta}`);
return normalizedDelta.toString();
}
} else {
return normalizedDelta.toString();
}
});
Quickshell.execDetached(["sh", "-c", command])
Logger.i("CustomButton", `Executing command: ${command}`)
}
}
if (!textStream) {
if (wheelMode === "unified" && wheelUpdateText) {
runTextCommand()
} else if (wheelMode === "separate") {
if ((delta > 0 && wheelUpUpdateText) || (delta < 0 && wheelDownUpdateText)) {
runTextCommand()
}
}
}
}
}
@@ -16,7 +16,8 @@ ColumnLayout {
property string valueIcon: widgetData.icon !== undefined ? widgetData.icon : widgetMetadata.icon
property bool valueTextStream: widgetData.textStream !== undefined ? widgetData.textStream : widgetMetadata.textStream
property bool valueParseJson: widgetData.parseJson !== undefined ? widgetData.parseJson : widgetMetadata.parseJson
property bool valueHideTextInVerticalBar: widgetData.hideTextInVerticalBar !== undefined ? widgetData.hideTextInVerticalBar : widgetMetadata.hideTextInVerticalBar
property int valueMaxTextLengthHorizontal: widgetData?.maxTextLength?.horizontal ?? widgetMetadata?.maxTextLength?.horizontal
property int valueMaxTextLengthVertical: widgetData?.maxTextLength?.vertical ?? widgetMetadata?.maxTextLength?.vertical
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
@@ -27,11 +28,21 @@ ColumnLayout {
settings.rightClickUpdateText = rightClickUpdateText.checked;
settings.middleClickExec = middleClickExecInput.text;
settings.middleClickUpdateText = middleClickUpdateText.checked;
settings.wheelMode = separateWheelToggle.internalChecked ? "separate" : "unified";
settings.wheelExec = wheelExecInput.text;
settings.wheelUpExec = wheelUpExecInput.text;
settings.wheelDownExec = wheelDownExecInput.text;
settings.wheelUpdateText = wheelUpdateText.checked;
settings.wheelUpUpdateText = wheelUpUpdateText.checked;
settings.wheelDownUpdateText = wheelDownUpdateText.checked;
settings.textCommand = textCommandInput.text;
settings.textCollapse = textCollapseInput.text;
settings.textStream = valueTextStream;
settings.parseJson = valueParseJson;
settings.hideTextInVerticalBar = valueHideTextInVerticalBar;
settings.maxTextLength = {
"horizontal": valueMaxTextLengthHorizontal,
"vertical": valueMaxTextLengthVertical
};
settings.textIntervalMs = parseInt(textIntervalInput.text || textIntervalInput.placeholderText, 10);
return settings;
}
@@ -137,6 +148,104 @@ ColumnLayout {
}
}
// Wheel command settings
NToggle {
id: separateWheelToggle
Layout.fillWidth: true
label: I18n.tr("bar.widget-settings.custom-button.wheel-mode-separate.label", "Separate wheel commands")
description: I18n.tr("bar.widget-settings.custom-button.wheel-mode-separate.description", "Enable separate commands for wheel up and down")
property bool internalChecked: (widgetData?.wheelMode || widgetMetadata?.wheelMode || "unified") === "separate"
checked: internalChecked
onToggled: checked => {
internalChecked = checked
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: parent.width
RowLayout {
id: unifiedWheelLayout
visible: !separateWheelToggle.checked
spacing: Style.marginM
NTextInput {
id: wheelExecInput
Layout.fillWidth: true
label: I18n.tr("bar.widget-settings.custom-button.wheel.label")
description: I18n.tr("bar.widget-settings.custom-button.wheel.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelExec || widgetMetadata?.wheelExec || ""
}
NToggle {
id: wheelUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelUpdateText, I18n.tr("bar.widget-settings.custom-button.wheel.update-text"), "auto")
onExited: TooltipService.hide()
checked: widgetData?.wheelUpdateText ?? widgetMetadata?.wheelUpdateText
onToggled: isChecked => checked = isChecked
}
}
ColumnLayout {
id: separatedWheelLayout
Layout.fillWidth: true
visible: separateWheelToggle.checked
RowLayout {
spacing: Style.marginM
NTextInput {
id: wheelUpExecInput
Layout.fillWidth: true
label: I18n.tr("bar.widget-settings.custom-button.wheel-up.label")
description: I18n.tr("bar.widget-settings.custom-button.wheel-up.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelUpExec || widgetMetadata?.wheelUpExec || ""
}
NToggle {
id: wheelUpUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelUpUpdateText, I18n.tr("bar.widget-settings.custom-button.wheel.update-text"), "auto")
onExited: TooltipService.hide()
checked: (widgetData?.wheelUpUpdateText !== undefined) ? widgetData.wheelUpUpdateText : (widgetMetadata?.wheelUpUpdateText ?? false)
onToggled: isChecked => checked = isChecked
}
}
RowLayout {
spacing: Style.marginM
NTextInput {
id: wheelDownExecInput
Layout.fillWidth: true
label: I18n.tr("bar.widget-settings.custom-button.wheel-down.label")
description: I18n.tr("bar.widget-settings.custom-button.wheel-down.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelDownExec || widgetMetadata?.wheelDownExec || ""
}
NToggle {
id: wheelDownUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelDownUpdateText, I18n.tr("bar.widget-settings.custom-button.wheel.update-text"), "auto")
onExited: TooltipService.hide()
checked: (widgetData?.wheelDownUpdateText !== undefined) ? widgetData.wheelDownUpdateText : (widgetMetadata?.wheelDownUpdateText ?? false)
onToggled: isChecked => checked = isChecked
}
}
}
}
NDivider {
Layout.fillWidth: true
}
@@ -145,11 +254,22 @@ ColumnLayout {
label: I18n.tr("bar.widget-settings.custom-button.dynamic-text")
}
NToggle {
label: I18n.tr("bar.widget-settings.custom-button.hide-vertical.label", "Hide text in vertical bar")
description: I18n.tr("bar.widget-settings.custom-button.hide-vertical.description", "If enabled, the text from the command output will not be shown when the bar is in a vertical layout (left or right).")
checked: valueHideTextInVerticalBar
onToggled: checked => valueHideTextInVerticalBar = checked
NSpinBox {
label: I18n.tr("bar.widget-settings.custom-button.max-text-length-horizontal.label", "Max text length (horizontal)")
description: I18n.tr("bar.widget-settings.custom-button.max-text-length-horizontal.description", "Maximum number of characters to show in horizontal bar (0 to hide text)")
from: 0
to: 100
value: valueMaxTextLengthHorizontal
onValueChanged: valueMaxTextLengthHorizontal = value
}
NSpinBox {
label: I18n.tr("bar.widget-settings.custom-button.max-text-length-vertical.label", "Max text length (vertical)")
description: I18n.tr("bar.widget-settings.custom-button.max-text-length-vertical.description", "Maximum number of characters to show in vertical bar (0 to hide text)")
from: 0
to: 100
value: valueMaxTextLengthVertical
onValueChanged: valueMaxTextLengthVertical = value
}
NToggle {