diff --git a/docs/development.md b/docs/development.md index 8d9de9f..d435739 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,7 +28,7 @@ Use the same literal configuration path for startup, logs, IPC, and process sele ## Run the regression suites -The six shell suites use isolated fixtures for lifecycle, installer, updater, theme, package, and AI usage behavior. +The seven shell suites use isolated fixtures for lifecycle, installer, updater, theme, package, AI usage, and localization behavior. Run them from the repository root: @@ -36,6 +36,7 @@ Run them from the repository root: ./tests/qs-arch-update-regression.sh ./tests/qs-barctl-regression.sh ./tests/qs-codex-usage-regression.sh +./tests/qs-i18n-regression.sh ./tests/qs-quattro-runtime-regression.sh ./tests/qs-shell-update-regression.sh ./tests/qs-theme-update-regression.sh @@ -112,7 +113,7 @@ The shell updater compares the installed commit with the repository’s `main` b Before updating `main`: 1. Group code and documentation into reviewable commits -2. Run all six regression suites +2. Run all seven regression suites 3. Run shell syntax, ShellCheck, QML, and whitespace checks 4. Test V1 and V2 through the installed controller 5. Confirm installer, updater, hooks, and controller come from the same commit diff --git a/docs/usage.md b/docs/usage.md index e245410..f7ce97b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -44,6 +44,12 @@ $HOME/.config/quickshell/bin/qs-barctl switch-wait v2 The shared host closes variant panels, destroys the old interface, loads the target, and waits for `ready=true`. It stores the new choice only after the target remains ready. +## Use localized date and weather text + +Clock tooltips, calendar month/day labels, and weather-panel controls follow the process locale. Rise checks `LC_ALL`, then `LC_MESSAGES`, then `LANG`. Italian locales use the included Italian strings and `it_IT` date formatting; other locales currently fall back to English and `en_US`. + +The shared helper lives at `versions/V1/i18n/Translation.qml`, so additional languages can extend one stable translation table without duplicating locale logic in V1 and V2. + ## Choose workspace and bar styles Workspace count and visual style are independent settings. Both variants support 10, 5, and active-only workspace modes. diff --git a/tests/qs-i18n-regression.sh b/tests/qs-i18n-regression.sh new file mode 100755 index 0000000..e68883b --- /dev/null +++ b/tests/qs-i18n-regression.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +fail() { + printf 'i18n regression: %s\n' "$*" >&2 + exit 1 +} + +require_contains() { + local file=$1 needle=$2 + grep -Fq -- "$needle" "$file" || fail "$file missing: $needle" +} + +require_absent() { + local file=$1 needle=$2 + if grep -Fq -- "$needle" "$file"; then + fail "$file still contains: $needle" + fi +} + +helper=versions/V1/i18n/Translation.qml +require_contains "$helper" 'Quickshell.env("LC_ALL")' +require_contains "$helper" 'Quickshell.env("LC_MESSAGES")' +require_contains "$helper" 'Quickshell.env("LANG")' +require_contains "$helper" 'readonly property string localeName: language === "it" ? "it_IT" : "en_US"' +require_contains "$helper" '"Weather": "Meteo"' +require_contains "$helper" '"Today": "Oggi"' + +for clock in \ + versions/V1/modules/ClockWidget.qml \ + versions/V1/variants/V2/modules/ClockWidget.qml; do + require_contains "$clock" 'Translation { id: i18n }' + require_contains "$clock" 'toLocaleString(Qt.locale(i18n.localeName), "ddd, d MMMM yyyy")' + require_absent "$clock" 'readonly property var months: ["January"' + require_absent "$clock" 'readonly property var days: ["Sun"' +done + +for calendar in \ + versions/V1/panels/CalendarPopup.qml \ + versions/V1/variants/V2/panels/CalendarPopup.qml; do + require_contains "$calendar" 'Translation { id: i18n }' + require_contains "$calendar" 'i18n.monthName(' + require_contains "$calendar" 'i18n.weekdayShort(1)' + require_absent "$calendar" 'model: ["MO","TU","WE","TH","FR","SA","SU"]' +done + +for weather in \ + versions/V1/panels/WeatherPanel.qml \ + versions/V1/variants/V2/panels/WeatherPanel.qml; do + require_contains "$weather" 'Translation { id: i18n }' + require_contains "$weather" 'return i18n.tr("Today")' + require_contains "$weather" 'return d.toLocaleString(Qt.locale(i18n.localeName), "ddd")' + require_contains "$weather" 'text: i18n.tr("Weather")' + require_contains "$weather" 'i18n.tr("Refreshing…")' +done + +printf 'i18n regression checks passed\n' diff --git a/versions/V1/i18n/Translation.qml b/versions/V1/i18n/Translation.qml new file mode 100644 index 0000000..c514560 --- /dev/null +++ b/versions/V1/i18n/Translation.qml @@ -0,0 +1,54 @@ +import QtQuick +import Quickshell + +QtObject { + id: translation + + readonly property string rawLocale: Quickshell.env("LC_ALL") !== "" + ? Quickshell.env("LC_ALL") + : Quickshell.env("LC_MESSAGES") !== "" + ? Quickshell.env("LC_MESSAGES") + : Quickshell.env("LANG") + readonly property string language: normalizeLanguage(rawLocale) + readonly property string localeName: language === "it" ? "it_IT" : "en_US" + + readonly property var italian: ({ + "Weather": "Meteo", + "Today": "Oggi", + "Tomorrow": "Domani", + "Location": "Località", + "Feels like": "Percepita", + "Humidity": "Umidità", + "Wind": "Vento", + "3-DAY FORECAST": "PREVISIONI 3 GIORNI", + "rain": "pioggia", + "Refreshing…": "Aggiornamento…", + "Refresh": "Aggiorna", + "metric": "metrico", + "imperial": "imperiale" + }) + + function normalizeLanguage(value) { + var candidate = String(value || "").trim().toLowerCase().replace("-", "_") + return candidate.indexOf("it") === 0 ? "it" : "en" + } + + function tr(source) { + var value = String(source || "") + if (language === "it" && italian[value] !== undefined) + return italian[value] + return value + } + + function weekdayShort(dayOfWeek) { + // 2023-01-01 was a Sunday; offset from it to get a stable weekday name. + var date = new Date(2023, 0, 1 + dayOfWeek) + return date.toLocaleString(Qt.locale(localeName), "ddd").slice(0, 2).toUpperCase() + } + + function monthName(year, month) { + return new Date(year, month, 1) + .toLocaleString(Qt.locale(localeName), "MMMM") + .toUpperCase() + } +} diff --git a/versions/V1/modules/ClockWidget.qml b/versions/V1/modules/ClockWidget.qml index f8825fe..36ead3a 100644 --- a/versions/V1/modules/ClockWidget.qml +++ b/versions/V1/modules/ClockWidget.qml @@ -1,11 +1,14 @@ import QtQuick import Quickshell import Quickshell.Io +import "../i18n" Item { id: rootMod required property var root + Translation { id: i18n } + property date now: new Date() function pad(n) { return n < 10 ? "0" + n : String(n) } @@ -18,11 +21,8 @@ Item { return pad(now.getHours()) + ":" + pad(now.getMinutes()) } - readonly property var months: ["January","February","March","April","May","June", - "July","August","September","October","November","December"] - readonly property var days: ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"] - - readonly property string tooltipText: days[now.getDay()] + ", " + now.getDate() + " " + months[now.getMonth()] + " " + now.getFullYear() + readonly property string tooltipText: + now.toLocaleString(Qt.locale(i18n.localeName), "ddd, d MMMM yyyy") implicitWidth: label.implicitWidth implicitHeight: 28 diff --git a/versions/V1/panels/CalendarPopup.qml b/versions/V1/panels/CalendarPopup.qml index f2d1f11..f4ec6d5 100644 --- a/versions/V1/panels/CalendarPopup.qml +++ b/versions/V1/panels/CalendarPopup.qml @@ -1,5 +1,6 @@ import QtQuick import "../modules" +import "../i18n" import Quickshell import Quickshell.Wayland @@ -7,6 +8,14 @@ PanelWindow { id: calPopup required property var root + Translation { id: i18n } + + function monthTitle() { + var now = new Date() + var month = now.getMonth() + root.calendarMonthOffset + return i18n.monthName(now.getFullYear(), month) + " " + root.calendarYear + } + screen: root.activePopupScreen color: "transparent" @@ -92,7 +101,7 @@ PanelWindow { // month + year — click to jump back to today UiText { anchors.centerIn: parent - text: root.calendarMonthName + " " + root.calendarYear + text: calPopup.monthTitle() color: monthMa.containsMouse && root.calendarMonthOffset !== 0 ? root.seal : root.ink font.family: root.mono font.pixelSize: 12 @@ -135,7 +144,11 @@ PanelWindow { Row { width: parent.width Repeater { - model: ["MO","TU","WE","TH","FR","SA","SU"] + model: [ + i18n.weekdayShort(1), i18n.weekdayShort(2), i18n.weekdayShort(3), + i18n.weekdayShort(4), i18n.weekdayShort(5), i18n.weekdayShort(6), + i18n.weekdayShort(0) + ] delegate: Item { required property string modelData required property int index diff --git a/versions/V1/panels/WeatherPanel.qml b/versions/V1/panels/WeatherPanel.qml index 59268f0..dcac006 100644 --- a/versions/V1/panels/WeatherPanel.qml +++ b/versions/V1/panels/WeatherPanel.qml @@ -1,5 +1,6 @@ import QtQuick import "../modules" +import "../i18n" import Quickshell import Quickshell.Io import Quickshell.Wayland @@ -8,6 +9,8 @@ PanelWindow { id: wxPanel required property var root + Translation { id: i18n } + screen: root.activePopupScreen color: "transparent" @@ -58,11 +61,11 @@ PanelWindow { return String.fromCodePoint(0xe33d) } function dayLabel(dateStr, index) { - if (index === 0) return "Today" - if (index === 1) return "Tomorrow" + if (index === 0) return i18n.tr("Today") + if (index === 1) return i18n.tr("Tomorrow") var d = new Date(dateStr + "T00:00:00") if (isNaN(d.getTime())) return dateStr - return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()] + return d.toLocaleString(Qt.locale(i18n.localeName), "ddd") } function dayRange(day) { var unit = root.weatherImperial ? "°F" : "°C" @@ -156,7 +159,7 @@ PanelWindow { height: 24 UiText { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: "Weather" + text: i18n.tr("Weather") color: root.ink; font.family: root.mono; font.pixelSize: 13 font.letterSpacing: 2; font.weight: Font.Medium } @@ -193,25 +196,25 @@ PanelWindow { Row { width: parent.width visible: wxPanel.location !== "" - UiText { text: "Location"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Location"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.location; color: root.ink; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.6; elide: Text.ElideRight } } Row { width: parent.width visible: wxPanel.feels !== "" - UiText { text: "Feels like"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Feels like"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.tConv(wxPanel.feels) + "°" + (root.weatherImperial ? "F" : "C"); color: root.ink; font.family: root.mono; font.pixelSize: 11 } } Row { width: parent.width visible: wxPanel.humidity !== "" - UiText { text: "Humidity"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Humidity"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.humidity + "%"; color: root.ink; font.family: root.mono; font.pixelSize: 11 } } Row { width: parent.width visible: wxPanel.wind !== "" - UiText { text: "Wind"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Wind"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.wConv(wxPanel.wind); color: root.ink; font.family: root.mono; font.pixelSize: 11 } } } @@ -224,7 +227,7 @@ PanelWindow { visible: wxPanel.forecastDays.length > 0 UiText { - text: "3-DAY FORECAST" + text: i18n.tr("3-DAY FORECAST") color: root.sumiHi font.family: root.mono font.pixelSize: 10 @@ -271,7 +274,7 @@ PanelWindow { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 76 - text: (day.rain !== undefined ? Math.round(day.rain) + "% rain" : "") + text: (day.rain !== undefined ? Math.round(day.rain) + "% " + i18n.tr("rain") : "") color: root.sumiHi font.family: root.mono font.pixelSize: 10 @@ -296,7 +299,7 @@ PanelWindow { Behavior on color { ColorAnimation { duration: 120 } } UiText { anchors.centerIn: parent - text: wxPanel.refreshing ? "Refreshing…" : "Refresh" + text: wxPanel.refreshing ? i18n.tr("Refreshing…") : i18n.tr("Refresh") color: root.paper; font.family: root.mono; font.pixelSize: 11 } MouseArea { @@ -317,7 +320,7 @@ PanelWindow { Behavior on border.color { ColorAnimation { duration: 120 } } UiText { anchors.centerIn: parent - text: root.weatherImperial ? "metric" : "imperial" + text: root.weatherImperial ? i18n.tr("metric") : i18n.tr("imperial") color: unitMa.containsMouse ? root.seal : root.ink font.family: root.mono; font.pixelSize: 11 Behavior on color { ColorAnimation { duration: 120 } } diff --git a/versions/V1/variants/V2/modules/ClockWidget.qml b/versions/V1/variants/V2/modules/ClockWidget.qml index c7302fb..89ef3df 100644 --- a/versions/V1/variants/V2/modules/ClockWidget.qml +++ b/versions/V1/variants/V2/modules/ClockWidget.qml @@ -1,11 +1,14 @@ import QtQuick import Quickshell import Quickshell.Io +import "../../../i18n" Item { id: rootMod required property var root + Translation { id: i18n } + property date now: new Date() readonly property color contentColor: root.widgetContentColor("G8", root.ink) @@ -19,11 +22,8 @@ Item { return pad(now.getHours()) + ":" + pad(now.getMinutes()) } - readonly property var months: ["January","February","March","April","May","June", - "July","August","September","October","November","December"] - readonly property var days: ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"] - - readonly property string tooltipText: days[now.getDay()] + ", " + now.getDate() + " " + months[now.getMonth()] + " " + now.getFullYear() + readonly property string tooltipText: + now.toLocaleString(Qt.locale(i18n.localeName), "ddd, d MMMM yyyy") implicitWidth: label.implicitWidth implicitHeight: 28 diff --git a/versions/V1/variants/V2/panels/CalendarPopup.qml b/versions/V1/variants/V2/panels/CalendarPopup.qml index ee05aca..b80b409 100644 --- a/versions/V1/variants/V2/panels/CalendarPopup.qml +++ b/versions/V1/variants/V2/panels/CalendarPopup.qml @@ -1,5 +1,6 @@ import QtQuick import "../modules" +import "../../../i18n" import Quickshell import Quickshell.Wayland @@ -7,6 +8,14 @@ PanelWindow { id: calPopup required property var root + Translation { id: i18n } + + function monthTitle() { + var now = new Date() + var month = now.getMonth() + root.calendarMonthOffset + return i18n.monthName(now.getFullYear(), month) + " " + root.calendarYear + } + screen: root.activePopupScreen color: "transparent" @@ -100,7 +109,7 @@ PanelWindow { // month + year — click to jump back to today UiText { anchors.centerIn: parent - text: root.calendarMonthName + " " + root.calendarYear + text: calPopup.monthTitle() color: monthMa.containsMouse && root.calendarMonthOffset !== 0 ? root.seal : root.ink font.family: root.mono font.pixelSize: 12 @@ -143,7 +152,11 @@ PanelWindow { Row { width: parent.width Repeater { - model: ["MO","TU","WE","TH","FR","SA","SU"] + model: [ + i18n.weekdayShort(1), i18n.weekdayShort(2), i18n.weekdayShort(3), + i18n.weekdayShort(4), i18n.weekdayShort(5), i18n.weekdayShort(6), + i18n.weekdayShort(0) + ] delegate: Item { required property string modelData required property int index diff --git a/versions/V1/variants/V2/panels/WeatherPanel.qml b/versions/V1/variants/V2/panels/WeatherPanel.qml index 8ce4502..c13eaa4 100644 --- a/versions/V1/variants/V2/panels/WeatherPanel.qml +++ b/versions/V1/variants/V2/panels/WeatherPanel.qml @@ -1,5 +1,6 @@ import QtQuick import "../modules" +import "../../../i18n" import Quickshell import Quickshell.Io import Quickshell.Wayland @@ -8,6 +9,8 @@ PanelWindow { id: wxPanel required property var root + Translation { id: i18n } + screen: root.activePopupScreen color: "transparent" @@ -58,11 +61,11 @@ PanelWindow { return String.fromCodePoint(0xe33d) } function dayLabel(dateStr, index) { - if (index === 0) return "Today" - if (index === 1) return "Tomorrow" + if (index === 0) return i18n.tr("Today") + if (index === 1) return i18n.tr("Tomorrow") var d = new Date(dateStr + "T00:00:00") if (isNaN(d.getTime())) return dateStr - return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()] + return d.toLocaleString(Qt.locale(i18n.localeName), "ddd") } function dayRange(day) { var unit = root.weatherImperial ? "°F" : "°C" @@ -164,7 +167,7 @@ PanelWindow { height: 24 UiText { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: "Weather" + text: i18n.tr("Weather") color: root.ink; font.family: root.mono; font.pixelSize: 13 font.letterSpacing: 2; font.weight: Font.Medium } @@ -201,25 +204,25 @@ PanelWindow { Row { width: parent.width visible: wxPanel.location !== "" - UiText { text: "Location"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Location"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.location; color: root.ink; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.6; elide: Text.ElideRight } } Row { width: parent.width visible: wxPanel.feels !== "" - UiText { text: "Feels like"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Feels like"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.tConv(wxPanel.feels) + "°" + (root.weatherImperial ? "F" : "C"); color: root.ink; font.family: root.mono; font.pixelSize: 11 } } Row { width: parent.width visible: wxPanel.humidity !== "" - UiText { text: "Humidity"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Humidity"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.humidity + "%"; color: root.ink; font.family: root.mono; font.pixelSize: 11 } } Row { width: parent.width visible: wxPanel.wind !== "" - UiText { text: "Wind"; color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } + UiText { text: i18n.tr("Wind"); color: root.sumiHi; font.family: root.mono; font.pixelSize: 11; width: parent.width * 0.4 } UiText { text: wxPanel.wConv(wxPanel.wind); color: root.ink; font.family: root.mono; font.pixelSize: 11 } } } @@ -232,7 +235,7 @@ PanelWindow { visible: wxPanel.forecastDays.length > 0 UiText { - text: "3-DAY FORECAST" + text: i18n.tr("3-DAY FORECAST") color: root.sumiHi font.family: root.mono font.pixelSize: 10 @@ -279,7 +282,7 @@ PanelWindow { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 76 - text: (day.rain !== undefined ? Math.round(day.rain) + "% rain" : "") + text: (day.rain !== undefined ? Math.round(day.rain) + "% " + i18n.tr("rain") : "") color: root.sumiHi font.family: root.mono font.pixelSize: 10 @@ -304,7 +307,7 @@ PanelWindow { Behavior on color { ColorAnimation { duration: 120 } } UiText { anchors.centerIn: parent - text: wxPanel.refreshing ? "Refreshing…" : "Refresh" + text: wxPanel.refreshing ? i18n.tr("Refreshing…") : i18n.tr("Refresh") color: root.paper; font.family: root.mono; font.pixelSize: 11 } MouseArea { @@ -325,7 +328,7 @@ PanelWindow { Behavior on border.color { ColorAnimation { duration: 120 } } UiText { anchors.centerIn: parent - text: root.weatherImperial ? "metric" : "imperial" + text: root.weatherImperial ? i18n.tr("metric") : i18n.tr("imperial") color: unitMa.containsMouse ? root.seal : root.ink font.family: root.mono; font.pixelSize: 11 Behavior on color { ColorAnimation { duration: 120 } }