Merge branch 'main' into 135-issue-pr-templates
C++ / fmt (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 16s
JS/TS / lint (pull_request) Successful in 20s
Python / fmt (pull_request) Successful in 31s
Python / lint (pull_request) Successful in 29s
Python / test (pull_request) Successful in 1m0s
C++ / build (pull_request) Successful in 3m25s
Rust / fmt (pull_request) Successful in 1m12s
Python / buildcheck (pull_request) Successful in 2m51s
Rust / build (pull_request) Successful in 2m14s
Rust / clippy (pull_request) Successful in 1m52s
C++ / clang-tidy (pull_request) Successful in 6m7s

This commit is contained in:
2026-07-08 22:14:09 +02:00
38 changed files with 2466 additions and 1874 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ name: Rebuild CI Image
on:
schedule:
- cron: '0 6 * * 1'
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
@@ -11,7 +11,7 @@ jobs:
container:
image: node:26-alpine
env:
IMAGE: git.aramjonghu.nl/aramjonghu/zshell-ci:latest
IMAGE: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
@@ -21,7 +21,7 @@ jobs:
run: apk add --no-cache docker-cli
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.nl --username aramjonghu --password-stdin
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.dev --username aramjonghu --password-stdin
- name: Build image
run: docker build -t "$IMAGE" -f ci/Dockerfile .
+34 -1
View File
@@ -4,10 +4,43 @@ on:
pull_request:
jobs:
fmt:
runs-on: alpine
container:
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Format check
run: |
find . \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) \
-not -path './build/*' \
-exec clang-format -i --style=file {} +
git diff --exit-code && echo "clang-format: passed"
build:
runs-on: alpine
container:
image: git.aramjonghu.nl/aramjonghu/zshell-ci:latest
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure
run: cmake -B build -G Ninja -DENABLE_MODULES=plugin -DCMAKE_BUILD_TYPE=Release
- name: Build
run: ninja -C build
clang-tidy:
runs-on: alpine
container:
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest
steps:
- name: Checkout
@@ -1,10 +1,10 @@
name: Lint & Format (JS/TS)
name: JS/TS
on:
pull_request:
jobs:
lint-format:
fmt:
runs-on: alpine
container: node:26-alpine
@@ -18,7 +18,6 @@ jobs:
git
- name: Prettier
continue-on-error: true
run: |
if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then
npx --yes prettier --check "**/*.{js,jsx,ts,tsx,mjs,cjs}" --ignore-path .prettierignore
@@ -26,6 +25,19 @@ jobs:
echo "No JS/TS files found"
fi
lint:
runs-on: alpine
container: node:26-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
apk add --no-cache \
git
- name: ESLint
run: |
if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then
-85
View File
@@ -1,85 +0,0 @@
name: Lint & Format (Rust)
on:
pull_request:
jobs:
lint-format:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust \
rustfmt \
rust-clippy
- id: format-check
name: Format check
continue-on-error: true
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo fmt --manifest-path "$manifest" --check && \
echo "$manifest: formatting OK" || \
{ echo "$manifest: needs formatting"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
- id: clippy
name: Clippy
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo clippy --manifest-path "$manifest" --all-targets --all-features -- -D warnings && \
echo "$manifest: Clippy passed" || \
{ echo "$manifest: Clippy failed"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
- name: Check results
if: always()
run: |
if [ "${{ steps.format-check.outcome }}" = "failure" ] || [ "${{ steps.clippy.outcome }}" = "failure" ]; then
echo "One or more checks failed"
exit 1
fi
echo "All checks passed"
@@ -4,7 +4,7 @@ on:
pull_request:
jobs:
lint-format:
fmt:
runs-on: alpine
container: node:26-alpine
@@ -23,11 +23,28 @@ jobs:
pip install --no-cache-dir ruff
- name: Format check
continue-on-error: true
run: |
. .venv/bin/activate
ruff format --check .
lint:
runs-on: alpine
container: node:26-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
apk add --no-cache \
git \
python3 \
py3-pip
python3 -m venv .venv
. .venv/bin/activate
pip install --no-cache-dir ruff
- name: Lint
run: |
. .venv/bin/activate
@@ -63,3 +80,30 @@ jobs:
. .venv/bin/activate
cd cli
python -m pytest tests/ -v
buildcheck:
runs-on: alpine
container: node:26-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
apk add --no-cache \
git \
python3 \
py3-pip \
build-base \
python3-dev \
gcc \
g++
python3 -m venv .venv
. .venv/bin/activate
pip install --no-cache-dir nuitka
- name: Nuitka module check
run: |
. .venv/bin/activate
nuitka --module --include-package=zshell cli/src/zshell/
+152
View File
@@ -0,0 +1,152 @@
name: Rust
on:
pull_request:
jobs:
build:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-build-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust
- name: Cargo check
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
for manifest in $(find . -name "Cargo.toml"); do
cargo check --manifest-path "$manifest" && \
echo "$manifest: check passed" || \
{ echo "$manifest: check failed"; exit 1; }
done
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
fmt:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-fmt-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust \
rustfmt
- name: Format check
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo fmt --manifest-path "$manifest" --check && \
echo "$manifest: formatting OK" || \
{ echo "$manifest: needs formatting"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
clippy:
runs-on: alpine
container: node:26-alpine
env:
CARGO_HOME: ${{ github.workspace }}/.cargo
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo packages
uses: actions/cache@v4
env:
cache-name: cache-cargo-packages
with:
path: |
.cargo/registry
.cargo/git
target
key: rust-clippy-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-build-${{ env.cache-name }}-
rust-${{ runner.os }}-build-
rust-
- name: Install tools
run: |
apk add --no-cache \
git \
cargo \
rust \
rust-clippy
- name: Clippy
run: |
if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then
status=0
for manifest in $(find . -name "Cargo.toml"); do
cargo clippy --manifest-path "$manifest" --all-targets --all-features -- -D warnings && \
echo "$manifest: Clippy passed" || \
{ echo "$manifest: Clippy failed"; status=1; }
done
exit $status
elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then
echo "Rust files found but no Cargo.toml"
exit 1
else
echo "No Rust project found"
fi
+82
View File
@@ -51,7 +51,9 @@ add_compile_options(
-Wunreachable-code
)
if("shell" IN_LIST ENABLE_MODULES)
# Build settings index
find_package(Python3 COMPONENTS Interpreter REQUIRED)
set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
execute_process(
@@ -64,6 +66,86 @@ if("shell" IN_LIST ENABLE_MODULES)
if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
message(FATAL_ERROR "Failed to build settings search index")
endif()
# Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
set(ZSHELL_CLI_SRC "${CMAKE_SOURCE_DIR}/cli/src/zshell")
find_program(NUITKA_EXECUTABLE nuitka)
if(NUITKA_EXECUTABLE)
file(GLOB_RECURSE ZSHELL_CLI_SOURCES CONFIGURE_DEPENDS
"${ZSHELL_CLI_SRC}/*.py"
)
file(GLOB_RECURSE ZSHELL_CLI_ASSETS CONFIGURE_DEPENDS
"${ZSHELL_CLI_SRC}/assets/*"
)
add_custom_command(
OUTPUT "${ZSHELL_CLI_DIST}/zshell-cli"
COMMAND ${CMAKE_COMMAND} -E make_directory "${ZSHELL_CLI_BUILD_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -rf "${ZSHELL_CLI_DIST}"
COMMAND
${NUITKA_EXECUTABLE}
--standalone
--include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets
--output-dir=${ZSHELL_CLI_BUILD_DIR}
--output-filename=zshell-cli
${CMAKE_SOURCE_DIR}/cli/src/zshell/
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/cli
DEPENDS ${ZSHELL_CLI_SOURCES} ${ZSHELL_CLI_ASSETS}
)
add_custom_target(zshell-cli ALL DEPENDS "${ZSHELL_CLI_DIST}/zshell-cli")
install(PROGRAMS "${ZSHELL_CLI_DIST}/zshell-cli" DESTINATION "${INSTALL_LIBDIR}/zshell-cli")
install(DIRECTORY "${ZSHELL_CLI_DIST}/" DESTINATION "${INSTALL_LIBDIR}/zshell-cli" PATTERN "zshell-cli" EXCLUDE)
configure_file(
"${CMAKE_SOURCE_DIR}/Plugins/cmake/zshell-cli.cmake.in"
"${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake"
@ONLY
)
install(SCRIPT "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake")
else()
message(STATUS "Nuitka not found, building zshell-cli as a Python wheel instead")
find_package(Python3 COMPONENTS Interpreter REQUIRED)
set(ZSHELL_CLI_DIR "${CMAKE_SOURCE_DIR}")
set(ZSHELL_CLI_DIST_DIR "${ZSHELL_CLI_DIR}/dist")
set(ZSHELL_CLI_WHEEL_STAMP "${CMAKE_BINARY_DIR}/zshell-cli/wheel.stamp")
file(GLOB_RECURSE ZSHELL_CLI_SOURCES CONFIGURE_DEPENDS
"${ZSHELL_CLI_DIR}/src/zshell/*.py"
)
file(GLOB_RECURSE ZSHELL_CLI_ASSETS CONFIGURE_DEPENDS
"${ZSHELL_CLI_DIR}/src/zshell/assets/*"
)
add_custom_command(
OUTPUT "${ZSHELL_CLI_WHEEL_STAMP}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/zshell-cli"
COMMAND ${CMAKE_COMMAND} -E rm -rf "${ZSHELL_CLI_DIST_DIR}"
COMMAND ${Python3_EXECUTABLE} -m build --wheel --no-isolation
COMMAND ${CMAKE_COMMAND} -E touch "${ZSHELL_CLI_WHEEL_STAMP}"
WORKING_DIRECTORY "${ZSHELL_CLI_DIR}"
DEPENDS ${ZSHELL_CLI_SOURCES} ${ZSHELL_CLI_ASSETS}
COMMENT "Building zshell-cli python wheel"
)
add_custom_target(zshell-cli ALL DEPENDS "${ZSHELL_CLI_WHEEL_STAMP}")
configure_file(
"${CMAKE_SOURCE_DIR}/Plugins/cmake/zshell-cli-python.cmake.in"
"${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli-python.cmake"
@ONLY
)
install(SCRIPT "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli-python.cmake")
endif()
endif()
if("plugin" IN_LIST ENABLE_MODULES)
+1 -1
View File
@@ -6,7 +6,7 @@ ListView {
property bool doneFakeFlick
interactive: !Visibilities.getForActive().isDrawing
interactive: !Visibilities.getForActive()?.isDrawing
maximumFlickVelocity: 3000
rebound: Transition {
+1 -1
View File
@@ -52,7 +52,7 @@ MouseArea {
anchors.fill: parent
cursorShape: !enabled ? undefined : Qt.PointingHandCursor
enabled: parent.enabled && !Visibilities.getForActive().isDrawing
enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing
hoverEnabled: true
Behavior on stateOpacity {
+2 -13
View File
@@ -196,7 +196,7 @@ Item {
if (!root.visibilities.bar && Config.bar.autoHide && y < root.bar.implicitHeight)
root.bar.isHovered = true;
if (root.panels.sidebar.width === 0) {
if (root.panels.sidebar.offsetScale === 1) {
const showOsd = root.inRightPanel(root.panels.osdWrapper, x, y);
if (showOsd) {
@@ -204,7 +204,7 @@ Item {
root.panels.osd.hovered = true;
}
} else {
const outOfSidebar = x < root.width - root.panels.sidebar.width;
const outOfSidebar = x < root.width - root.panels.sidebar.width * (1 - root.panels.sidebar.offsetScale);
const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y);
if (!root.osdShortcutActive) {
@@ -311,17 +311,6 @@ Item {
}
}
function onUtilitiesChanged() {
if (root.visibilities.utilities) {
const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY);
if (!inUtilitiesArea) {
root.utilitiesShortcutActive = true;
}
} else {
root.utilitiesShortcutActive = false;
}
}
target: root.visibilities
}
}
+3 -7
View File
@@ -184,18 +184,14 @@ Item {
Item {
id: settingsWrapper
anchors.fill: parent
clip: true
implicitHeight: settings.implicitHeight
implicitWidth: settings.implicitWidth
x: (root.width - settings.implicitWidth) / 2
y: (settings.implicitHeight + (root.height - root.bar.implicitHeight - settings.implicitHeight) / 2) * (1 - settings.offsetScale) - settings.implicitHeight - 5
Settings.Wrapper {
id: settings
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
// anchors.centerIn: parent
anchors.centerIn: parent
anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale
panels: root
screen: root.screen
visibilities: root.visibilities
+1 -2
View File
@@ -38,7 +38,6 @@ Region {
R {
panel: root.panels.osdWrapper
width: panel.width * (1 - root.panels.osd.offsetScale) + root.borderThickness
x: root.win.width - width
}
R {
@@ -60,7 +59,7 @@ Region {
}
R {
panel: root.panels.settingsWrapper
panel: root.panels.settings
}
R {
+3 -17
View File
@@ -26,7 +26,7 @@ CustomWindow {
if (focusGrab.active)
return 0;
if (monitor?.lastIpcObject.specialWorkspace?.name || monitor?.activeWorkspace.lastIpcObject.windows > 0)
if (monitor?.lastIpcObject.specialWorkspace?.name || monitor?.activeWorkspace?.lastIpcObject.windows > 0)
return 0;
return 100;
@@ -162,14 +162,6 @@ CustomWindow {
Component.onCompleted: Visibilities.load(root.screen, this)
}
IpcHandler {
function toggleLauncher(fix: string): void {
visibilities.launcher = !visibilities.launcher;
}
target: "visibilities"
}
Binding {
property: "bar"
target: visibilities
@@ -306,15 +298,9 @@ CustomWindow {
PanelBg {
id: settingsBg
property real extraHeight: 0
deformAmount: 0.03
implicitHeight: panels.settings.height * (1 + extraHeight)
implicitWidth: panels.settings.width
panel: panels.settingsWrapper
panel: panels.settings
radius: Appearance.rounding.large + Appearance.padding.normal
x: panels.settingsWrapper.x + panels.settings.x + root.borderThickness
y: panels.settingsWrapper.y + panels.settings.y + bar.implicitHeight - panels.settings.height * extraHeight
}
PanelBg {
@@ -422,7 +408,7 @@ CustomWindow {
resources.transform: Matrix4x4 {
matrix: resourcesBg.deformMatrix
}
settingsWrapper.transform: Matrix4x4 {
settings.transform: Matrix4x4 {
matrix: settingsBg.deformMatrix
}
sidebar.transform: Matrix4x4 {
+15 -42
View File
@@ -11,8 +11,13 @@ Singleton {
id: root
property bool appleDisplayPresent: false
readonly property var ddcMonitorMap: {
const map = {};
for (const m of ddcMonitors)
map[m.connector] = m;
return map;
}
property list<var> ddcMonitors: []
property list<var> ddcServiceMon: []
readonly property list<Monitor> monitors: variants.instances
function decreaseBrightness(): void {
@@ -56,8 +61,6 @@ Singleton {
onMonitorsChanged: {
ddcMonitors = [];
ddcServiceMon = [];
ddcServiceProc.running = true;
ddcProc.running = true;
}
@@ -92,26 +95,6 @@ Singleton {
}
}
Process {
id: ddcServiceProc
command: ["ddcutil-client", "detect"]
// running: true
stdout: StdioCollector {
onStreamFinished: {
const t = text.replace(/\r\n/g, "\n").trim();
const output = ("\n" + t).split(/\n(?=display:\s*\d+\s*\n)/).filter(b => b.startsWith("display:")).map(b => ({
display: Number(b.match(/^display:\s*(\d+)/m)?.[1] ?? -1),
name: (b.match(/^\s*product_name:\s*(.*)$/m)?.[1] ?? "").trim()
})).filter(d => d.display > 0);
root.ddcServiceMon = output;
}
}
}
CustomShortcut {
description: "Increase brightness"
name: "brightnessUp"
@@ -183,16 +166,12 @@ Singleton {
id: monitor
property real brightness
readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? ""
readonly property string displayNum: root.ddcServiceMon.find(m => m.name === modelData.model)?.display ?? ""
readonly property string busNum: ddcInfo?.busNum ?? ""
readonly property var ddcInfo: root.ddcMonitorMap[modelData.name] ?? null
readonly property Process initProc: Process {
stdout: StdioCollector {
onStreamFinished: {
if (monitor.isDdcService) {
const output = text.split("\n").filter(o => o.startsWith("vcp_current_value:"))[0].split(":")[1];
const val = parseInt(output.trim());
monitor.brightness = val / 100;
} else if (monitor.isAppleDisplay) {
if (monitor.isAppleDisplay) {
const val = parseInt(text.trim());
monitor.brightness = val / 101;
} else {
@@ -203,12 +182,11 @@ Singleton {
}
}
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name)
readonly property bool isDdcService: Config.services.ddcutilService
readonly property bool isDdc: ddcInfo !== null
required property ShellScreen modelData
property real queuedBrightness: NaN
readonly property Timer timer: Timer {
interval: 500
interval: 400
onTriggered: {
if (!isNaN(monitor.queuedBrightness)) {
@@ -219,9 +197,7 @@ Singleton {
}
function initBrightness(): void {
if (isDdcService)
initProc.command = ["ddcutil-client", "-d", displayNum, "getvcp", "10"];
else if (isAppleDisplay)
if (isAppleDisplay)
initProc.command = ["asdbctl", "get"];
else if (isDdc)
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"];
@@ -237,28 +213,25 @@ Singleton {
if (Math.round(brightness * 100) === rounded)
return;
if ((isDdc || isDdcService) && timer.running) {
if (isDdc && timer.running) {
queuedBrightness = value;
return;
}
brightness = value;
if (isDdcService)
Quickshell.execDetached(["ddcutil-client", "-d", displayNum, "setvcp", "10", rounded]);
else if (isAppleDisplay)
if (isAppleDisplay)
Quickshell.execDetached(["asdbctl", "set", rounded]);
else if (isDdc)
Quickshell.execDetached(["ddcutil", "--disable-dynamic-sleep", "--sleep-multiplier", ".1", "--skip-ddc-checks", "-b", busNum, "setvcp", "10", rounded]);
else
Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]);
if (isDdc || isDdcService)
if (isDdc)
timer.restart();
}
Component.onCompleted: initBrightness()
onBusNumChanged: initBrightness()
onDisplayNumChanged: initBrightness()
}
}
+49 -14
View File
@@ -149,36 +149,71 @@ WlSessionLockSurface {
Image {
id: background
anchors.bottomMargin: -8 - lockContent.positions[lockContent.positionIndex].y
anchors.fill: parent
anchors.leftMargin: -8 + lockContent.positions[lockContent.positionIndex].x
anchors.rightMargin: -8 - lockContent.positions[lockContent.positionIndex].x
anchors.topMargin: -8 + lockContent.positions[lockContent.positionIndex].y
fillMode: Image.PreserveAspectCrop
source: WallpaperPath.lockscreenBg
Behavior on anchors.bottomMargin {
Anim {
duration: 5000
}
}
Behavior on anchors.leftMargin {
Anim {
duration: 5000
}
}
Behavior on anchors.rightMargin {
Anim {
duration: 5000
}
}
Behavior on anchors.topMargin {
Anim {
duration: 5000
}
}
}
Item {
id: lockContent
property int positionIndex: 0
readonly property var positions: [Qt.point(0, 0), Qt.point(4, 0), Qt.point(4, 4), Qt.point(0, 4), Qt.point(-4, 4), Qt.point(-4, 0), Qt.point(-4, -4), Qt.point(0, -4), Qt.point(4, -4),]
readonly property int radius: size / 4 * Appearance.rounding.scale
readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4
anchors.centerIn: parent
anchors.horizontalCenterOffset: positions[positionIndex].x
anchors.verticalCenterOffset: positions[positionIndex].y
implicitHeight: size
implicitWidth: size
scale: 0
// MultiEffect {
// anchors.fill: lockBg
// autoPaddingEnabled: false
// blur: 1
// blurEnabled: true
// blurMax: 64
// maskEnabled: true
// maskSource: lockBg
//
// source: ShaderEffectSource {
// sourceItem: background
// sourceRect: Qt.rect(lockBg.x, lockBg.y, lockBg.width, lockBg, height)
// }
// }
Behavior on anchors.horizontalCenterOffset {
Anim {
duration: 5000
}
}
Behavior on anchors.verticalCenterOffset {
Anim {
duration: 5000
}
}
Timer {
interval: 120000
repeat: true
running: true
onTriggered: {
lockContent.positionIndex = (lockContent.positionIndex + 1) % lockContent.positions.length;
}
}
CustomRect {
id: lockBg
+1 -3
View File
@@ -102,8 +102,7 @@ Item {
to: 1.0
value: root.brightness
onPressedChanged: {
if (!pressed) {
onMoved: {
if (Config.osd.allMonBrightness) {
for (const mon of Brightness.monitors) {
mon.setBrightness(value);
@@ -116,7 +115,6 @@ Item {
}
}
}
}
component WrappedLoader: Loader {
required property bool shouldBeActive
+3 -3
View File
@@ -28,9 +28,7 @@ Scope {
visible: false
Connections {
target: root
onShouldShowChanged: {
function onShouldShowChanged(): void {
if (root.shouldShow) {
panelWindow.visible = true;
openAnim.start();
@@ -38,6 +36,8 @@ Scope {
closeAnim.start();
}
}
target: root
}
Anim {
@@ -267,8 +267,6 @@ Item {
function restoreFromData() {
let data = Wallpapers.getCrop(wrapper.currentScreen.name);
console.log(data.x, data.y);
if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) {
zoom = data.zoom > 0 ? data.zoom : 1.0;
x = imageX + (data.x * scaledImg.paintedWidth);
+60 -14
View File
@@ -148,16 +148,39 @@ VerticalFadeFlickable {
}
}
ListView {
Column {
id: resultList
Layout.fillWidth: true
cacheBuffer: 10000
implicitHeight: contentHeight
interactive: false
spacing: Appearance.padding.large
delegate: ColumnLayout {
add: Transition {
Anim {
from: 0
property: "opacity"
to: 1
type: Anim.DefaultEffects
}
}
move: Transition {
Anim {
properties: "x,y"
}
Anim {
property: "opacity"
to: 1
type: Anim.DefaultEffects
}
}
Repeater {
model: ScriptModel {
objectProp: "pageIdx"
values: root.groups
}
ColumnLayout {
id: group
required property int index
@@ -173,25 +196,51 @@ VerticalFadeFlickable {
MaterialIcon {
color: DynamicColors.palette.m3primary
fill: 1
font.pointSize: Appearance.font.size.large
text: group.modelData.icon
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3primary
color: DynamicColors.palette.m3secondary
elide: Text.ElideRight
font.pointSize: Appearance.font.size.large
text: group.modelData.page
}
}
ColumnLayout {
Column {
id: cardList
Layout.fillWidth: true
spacing: Appearance.spacing.extraSmall / 2
add: Transition {
Anim {
from: 0
property: "opacity"
to: 1
type: Anim.DefaultEffects
}
}
move: Transition {
Anim {
properties: "x,y"
}
Anim {
property: "opacity"
to: 1
type: Anim.DefaultEffects
}
}
Repeater {
model: group.modelData.entries
model: ScriptModel {
objectProp: "anchor"
values: group.modelData.entries
}
CustomRect {
id: result
@@ -201,7 +250,6 @@ VerticalFadeFlickable {
readonly property bool isLast: index === group.modelData.entries.length - 1
required property var modelData
Layout.fillWidth: true
bottomLeftRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall
bottomRightRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall
color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
@@ -211,6 +259,7 @@ VerticalFadeFlickable {
}
topLeftRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall
topRightRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall
width: cardList.width
RadiusBehavior on bottomLeftRadius {
}
@@ -249,7 +298,7 @@ VerticalFadeFlickable {
elide: Text.ElideRight
font.pointSize: Appearance.font.size.medium
text: SettingsSearcher.highlight(result.modelData.title, root.search, DynamicColors.palette.m3primary)
textFormat: Text.StyledText
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
}
CustomText {
@@ -258,7 +307,7 @@ VerticalFadeFlickable {
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: SettingsSearcher.highlight(result.modelData.subtext, root.search, DynamicColors.palette.m3primary)
textFormat: Text.StyledText
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
visible: result.modelData.subtext.length > 0
}
}
@@ -292,9 +341,6 @@ VerticalFadeFlickable {
}
}
}
model: ScriptModel {
objectProp: "pageIdx"
values: root.groups
}
}
+26 -3
View File
@@ -10,16 +10,39 @@ Singleton {
id: root
property var fzfFinder: null
readonly property var highlightCache: ({
"search": "",
"pattern": null
})
property var inverted: ({})
property var ranking: ({})
function highlight(text: string, search: string, colour: color): string {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const tokens = tokenize(search);
if (tokens.length === 0)
if (search.length === 0)
return escaped;
const cache = root.highlightCache;
if (search !== cache.search) {
const tokens = tokenize(search);
cache.search = search;
if (tokens.length === 0)
cache.pattern = null;
else {
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
}
}
const pattern = cache.pattern;
if (!pattern)
return escaped;
pattern.lastIndex = 0;
if (!pattern.test(escaped))
return escaped;
pattern.lastIndex = 0;
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
}
-2
View File
@@ -39,8 +39,6 @@ Item {
sState.animatingContainer: content.opacity < 1
sState.currentPageIdx: ["wallpaper"][0]
sState.screen: root.screen
onClose: console.log("shouldclose")
}
}
}
+4 -4
View File
@@ -12,10 +12,10 @@ Item {
id: root
property bool completed
property real cropHeight: displayData.height ?? 1.0
property real cropWidth: displayData.width ?? 1.0
property real cropX: displayData.x ?? 0.0
property real cropY: displayData.y ?? 0.0
property real cropHeight: displayData?.height ?? 1.0
property real cropWidth: displayData?.width ?? 1.0
property real cropX: displayData?.x ?? 0.0
property real cropY: displayData?.y ?? 0.0
property WallpaperImage current
readonly property var displayData: Wallpapers.getCrop(screen.name)
required property ShellScreen screen
+19
View File
@@ -0,0 +1,19 @@
file(GLOB ZSHELL_CLI_WHEEL "@ZSHELL_CLI_DIST_DIR@/*.whl")
if(NOT ZSHELL_CLI_WHEEL)
message(FATAL_ERROR "No zshell-cli wheel found in @ZSHELL_CLI_DIST_DIR@")
endif()
set(_zshell_installer_args "--prefix=@CMAKE_INSTALL_PREFIX@")
if(DEFINED ENV{DESTDIR} AND NOT "$ENV{DESTDIR}" STREQUAL "")
list(APPEND _zshell_installer_args "--destdir=$ENV{DESTDIR}")
endif()
execute_process(
COMMAND "@Python3_EXECUTABLE@" -m installer
${_zshell_installer_args}
${ZSHELL_CLI_WHEEL}
RESULT_VARIABLE ZSHELL_CLI_INSTALL_RESULT
)
if(NOT ZSHELL_CLI_INSTALL_RESULT EQUAL 0)
message(FATAL_ERROR "zshell-cli wheel install failed")
endif()
+12
View File
@@ -0,0 +1,12 @@
set(ZSHELL_CLI_BIN_DIR "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/usr/bin")
file(MAKE_DIRECTORY "${ZSHELL_CLI_BIN_DIR}")
file(RELATIVE_PATH ZSHELL_CLI_TARGET
"${ZSHELL_CLI_BIN_DIR}"
"${CMAKE_INSTALL_PREFIX}/@INSTALL_LIBDIR@/zshell-cli/zshell-cli"
)
file(CREATE_LINK
"${ZSHELL_CLI_TARGET}"
"${ZSHELL_CLI_BIN_DIR}/zshell-cli" SYMBOLIC
)
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
cd "$(dirname $0)/../src" || exit
python3 -m zshell "$@"
-32
View File
@@ -1,32 +0,0 @@
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "zshell"
requires-python = ">=3.13"
version = "0.1.0"
dependencies = [
"typer",
"pillow",
"jinja2",
"materialyoucolor"
]
[project.scripts]
zshell-cli = "zshell:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.sdist]
only-include = [
"src",
]
[tool.ruff]
line-length = 120
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+26 -8
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
import typer
from typer._completion_shared import install, _get_shell_name
from typer._completion_classes import completion_init
from zshell.subcommands import shell, scheme, screenshot, wallpaper, record
from typer._completion_shared import _get_shell_name, install
from zshell.subcommands import record, scheme, screenshot, shell, wallpaper
app = typer.Typer(name="zshell-cli", add_completion=False)
@@ -23,9 +25,17 @@ def _completion_installed() -> bool:
case "zsh":
return (Path.home() / ".zfunc" / "_zshell-cli").exists()
case "bash":
return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists()
return (
Path.home() / ".bash_completions" / "zshell-cli.sh"
).exists()
case "fish":
return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists()
return (
Path.home()
/ ".config"
/ "fish"
/ "completions"
/ "zshell-cli.fish"
).exists()
return False
@@ -40,10 +50,15 @@ def _install_completion() -> None:
try:
_, path = install(prog_name="zshell-cli")
print(f"zshell-cli: Shell completion installed ({shell}: {path})")
print("zshell-cli: Restart your shell or source the file to enable tab-completion.")
print(
"zshell-cli: Restart your shell or source the file to enable tab-completion."
)
except Exception as e:
print(f"zshell-cli: Failed to install shell completion: {e}", file=sys.stderr)
raise typer.Exit(code=1)
print(
f"zshell-cli: Failed to install shell completion: {e}",
file=sys.stderr,
)
raise typer.Exit(code=1) from None
def main() -> None:
@@ -53,5 +68,8 @@ def main() -> None:
if "_ZSHELL_CLI_COMPLETE" in os.environ:
completion_init()
if sys.stdout.isatty() and not _completion_installed():
print("zshell-cli: Tip: run with --install-autocomplete for tab completion.", file=sys.stderr)
print(
"zshell-cli: Tip: run with --install-autocomplete for tab completion.",
file=sys.stderr,
)
app()
+1 -1
View File
@@ -1,4 +1,4 @@
from . import main
from zshell import main
if __name__ == "__main__":
main()
+87 -27
View File
@@ -1,9 +1,9 @@
import os
import contextlib
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Optional
import typer
@@ -18,7 +18,9 @@ TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings"))
RECORDINGS_DIR = os.getenv(
"ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")
)
def _read_extra_args() -> list[str]:
@@ -32,34 +34,54 @@ def _read_extra_args() -> list[str]:
def _is_recording() -> bool:
return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
return (
subprocess.run(
["pidof", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]:
def _notify(
summary: str,
body: str = "",
actions: list | None = None,
timeout: int = 5000,
) -> int | None:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions:
for action in actions:
args.extend(["-A", action])
try:
proc = subprocess.run(args, capture_output=True, text=True)
return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
return (
int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
)
except Exception:
return None
def _close_notification(notif_id: int):
subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["notify-send", "--close", str(notif_id)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _get_monitors() -> list[dict]:
try:
res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
res = subprocess.run(
["hyprctl", "monitors", "-j"], capture_output=True, text=True
)
return json.loads(res.stdout)
except Exception:
return []
def _focused_monitor_name() -> Optional[str]:
def _focused_monitor_name() -> str | None:
for m in _get_monitors():
if m.get("focused"):
return m["name"]
@@ -71,7 +93,12 @@ def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
intersecting = []
for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh):
if not (
region[2] <= mx
or region[0] >= mx + mw
or region[3] <= my
or region[1] >= my + mh
):
intersecting.append(m)
return intersecting
@@ -80,23 +107,30 @@ def _highest_refresh(monitors: list[dict]) -> float:
return max((m["refreshRate"] for m in monitors), default=60.0)
def _slurp_region() -> Optional[str]:
def _slurp_region() -> str | None:
try:
return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip()
return subprocess.check_output(
["slurp", "-f", "%wx%h+%x+%y"], text=True
).strip()
except subprocess.CalledProcessError:
return None
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
def _parse_geometry(geometry: str) -> tuple[int, int, int, int] | None:
import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match:
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
return (
int(match.group(3)),
int(match.group(4)),
int(match.group(1)),
int(match.group(2)),
)
return None
def start_recording(region: Optional[str], sound: bool):
def start_recording(region: str | None, sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER]
extra_args = _read_extra_args()
@@ -118,7 +152,9 @@ def start_recording(region: Optional[str], sound: bool):
monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors)
cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))])
cmd.extend(
["-w", "region", "-region", geometry, "-f", str(int(framerate))]
)
else:
monitor_name = _focused_monitor_name()
@@ -137,7 +173,12 @@ def start_recording(region: Optional[str], sound: bool):
cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
cmd,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None:
@@ -145,12 +186,20 @@ def start_recording(region: Optional[str], sound: bool):
time.sleep(1)
if not _is_recording():
_notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
_notify(
"Recording failed",
"Check gpu-screen-recorder output.",
timeout=5000,
)
raise typer.Exit(code=1)
def stop_recording(clipboard: bool):
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["pkill", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(50):
if not _is_recording():
@@ -166,10 +215,8 @@ def stop_recording(clipboard: bool):
TEMP_RECORDING.rename(final_path)
if NOTIF_ID_FILE.is_file():
try:
with contextlib.suppress(Exception):
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
except Exception:
pass
NOTIF_ID_FILE.unlink()
if clipboard:
@@ -183,21 +230,34 @@ def stop_recording(clipboard: bool):
def toggle_pause():
subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["pkill", "-USR2", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
typer.echo("Toggled pause.")
@app.command()
def record(
region: Optional[str] = typer.Option(
region: str | None = typer.Option(
None,
"--region",
"-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
),
sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."),
pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."),
clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."),
sound: bool = typer.Option(
False, "--sound", "-s", help="Record audio from default output."
),
pause: bool = typer.Option(
False, "--pause", "-p", help="Toggle pause/resume."
),
clipboard: bool = typer.Option(
False,
"--clipboard",
"-c",
help="Copy the final recording path to clipboard.",
),
):
"""Start or stop a screen recording with gpu-screen-recorder."""
if pause:
+101 -53
View File
@@ -1,26 +1,33 @@
import typer
import contextlib
import json
import shutil
import os
import sys
import re
import shutil
import subprocess
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined
from typing import Any, Optional, Tuple
from zshell.utils.schemepalettes import get_palette, list_schemes, resolve_preset
import sys
from pathlib import Path
from PIL import Image
from typing import Any
import typer
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined
from materialyoucolor.dynamiccolor.material_dynamic_colors import (
MaterialDynamicColors,
)
from materialyoucolor.hct.hct import Hct
from materialyoucolor.quantize import QuantizeCelebi
from materialyoucolor.score.score import Score
from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
from materialyoucolor.hct.hct import Hct
from materialyoucolor.utils.color_utils import argb_from_rgb
from materialyoucolor.utils.math_utils import (
difference_degrees,
rotation_direction,
sanitize_degrees_double,
)
from PIL import Image
from zshell.utils.schemepalettes import (
get_palette,
list_schemes,
resolve_preset,
)
app = typer.Typer()
@@ -73,7 +80,9 @@ def _complete_accent(ctx, incomplete):
@app.command()
def list_presets(
json_format: bool = typer.Option(False, "--json", help="Output in JSON format"),
json_format: bool = typer.Option(
False, "--json", help="Output in JSON format"
),
):
schemes = list_schemes()
if json_format:
@@ -106,25 +115,25 @@ def list_presets(
@app.command()
def generate(
image_path: Optional[Path] = typer.Option(
image_path: Path | None = typer.Option(
None, help="Path to source image. Required for image mode."
),
scheme: Optional[str] = typer.Option(
scheme: str | None = typer.Option(
None,
help="Color scheme algorithm to use for image mode. Ignored in preset mode.",
autocompletion=_complete_scheme_name,
),
preset: Optional[str] = typer.Option(
preset: str | None = typer.Option(
None,
help="Name of a premade scheme in this format: <scheme>:<variant>",
autocompletion=_complete_preset,
),
mode: Optional[str] = typer.Option(
mode: str | None = typer.Option(
None,
help="Mode of the preset scheme (dark or light).",
autocompletion=_complete_mode,
),
accent: Optional[str] = typer.Option(
accent: str | None = typer.Option(
None,
help="Accent for schemes that support it (e.g. mauve).",
autocompletion=_complete_accent,
@@ -139,7 +148,7 @@ def generate(
HOME = str(os.getenv("HOME"))
OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json")
SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt")
THUMB_PATH = Path(HOME + "/.cache/zshell/imagecache/thumbnail.jpg")
THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails")
WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json")
TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates")
@@ -147,20 +156,28 @@ def generate(
CONFIG = Path(HOME + "/.config/zshell/config.json")
if preset is not None and image_path is not None:
raise typer.BadParameter("Use either --image-path or --preset, not both.")
raise typer.BadParameter(
"Use either --image-path or --preset, not both."
)
def get_scheme_class(scheme_name: str):
match scheme_name:
case "fruit-salad":
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad
from materialyoucolor.scheme.scheme_fruit_salad import (
SchemeFruitSalad,
)
return SchemeFruitSalad
case "expressive":
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive
from materialyoucolor.scheme.scheme_expressive import (
SchemeExpressive,
)
return SchemeExpressive
case "monochrome":
from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome
from materialyoucolor.scheme.scheme_monochrome import (
SchemeMonochrome,
)
return SchemeMonochrome
case "rainbow":
@@ -168,7 +185,9 @@ def generate(
return SchemeRainbow
case "tonal-spot":
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
from materialyoucolor.scheme.scheme_tonal_spot import (
SchemeTonalSpot,
)
return SchemeTonalSpot
case "neutral":
@@ -176,7 +195,9 @@ def generate(
return SchemeNeutral
case "fidelity":
from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity
from materialyoucolor.scheme.scheme_fidelity import (
SchemeFidelity,
)
return SchemeFidelity
case "content":
@@ -188,7 +209,9 @@ def generate(
return SchemeVibrant
case _:
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad
from materialyoucolor.scheme.scheme_fruit_salad import (
SchemeFruitSalad,
)
return SchemeFruitSalad
@@ -273,7 +296,8 @@ def generate(
diff = difference_degrees(from_hct.hue, to_hct.hue)
rotation = min(diff * 0.8, 100)
output_hue = sanitize_degrees_double(
from_hct.hue + rotation * rotation_direction(from_hct.hue, to_hct.hue)
from_hct.hue
+ rotation * rotation_direction(from_hct.hue, to_hct.hue)
)
tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost)))
return Hct.from_hct(output_hue, from_hct.chroma, tone)
@@ -307,17 +331,32 @@ def generate(
return out
def generate_thumbnail(image_path, thumbnail_path, size=(128, 128)):
thumbnail_file = Path(thumbnail_path)
def thumbnail_cache_path(image_path: Path, thumb_dir: Path) -> Path:
stat = image_path.stat()
key = f"{image_path.stem}_{stat.st_size}_{int(stat.st_mtime)}"
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
return thumb_dir / f"{safe_key}_thumbnail.jpg"
def generate_thumbnail(
image_path: Path, thumb_dir: Path, size=(128, 128)
) -> Path:
thumb_dir.mkdir(parents=True, exist_ok=True)
cache_path = thumbnail_cache_path(image_path, thumb_dir)
if cache_path.exists():
return cache_path
image = Image.open(image_path)
image.draft("RGB", size)
image = image.convert("RGB")
image.thumbnail(size, Image.Resampling.NEAREST)
image.save(cache_path, "JPEG")
thumbnail_file.parent.mkdir(parents=True, exist_ok=True)
image.save(thumbnail_path, "JPEG")
return cache_path
def apply_terms(sequences: str, sequences_tmux: str, state_path: Path) -> None:
def apply_terms(
sequences: str, sequences_tmux: str, state_path: Path
) -> None:
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(sequences, encoding="utf-8")
@@ -361,7 +400,7 @@ def generate(
mode = mode.lower()
preference = "prefer-dark" if mode == "dark" else "prefer-light"
try:
with contextlib.suppress(FileNotFoundError):
subprocess.run(
[
"gsettings",
@@ -374,8 +413,6 @@ def generate(
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass
def apply_qt_mode(mode: str, home: str) -> None:
mode = mode.lower()
@@ -399,10 +436,8 @@ def generate(
)
if count > 0 and new_text != text:
try:
with contextlib.suppress(OSError):
qt_conf.write_text(new_text, encoding="utf-8")
except OSError:
pass
def build_template_context(
*,
@@ -466,7 +501,7 @@ def generate(
ESC = "\x1b"
return f"{ESC}Ptmux;{seq.replace(ESC, ESC + ESC)}{ESC}\\"
def parse_output_directive(first_line: str) -> Optional[Path]:
def parse_output_directive(first_line: str) -> Path | None:
s = first_line.strip()
if not s.startswith("#") or s.startswith("#!"):
return None
@@ -478,7 +513,7 @@ def generate(
expanded = os.path.expandvars(os.path.expanduser(target))
return Path(expanded)
def split_directive_and_body(text: str) -> Tuple[Optional[Path], str]:
def split_directive_and_body(text: str) -> tuple[Path | None, str]:
lines = text.splitlines(keepends=True)
if not lines:
return None, ""
@@ -506,7 +541,9 @@ def generate(
rendered_outputs: list[Path] = []
for tpl_path in sorted(p for p in templates_dir.rglob("*") if p.is_file()):
for tpl_path in sorted(
p for p in templates_dir.rglob("*") if p.is_file()
):
rel = tpl_path.relative_to(templates_dir)
if any(part.startswith(".") for part in rel.parts):
@@ -523,14 +560,14 @@ def generate(
template = env.from_string(body)
text = template.render(**context)
except Exception as e:
raise RuntimeError(f"Template render failed for '{rel}': {e}") from e
raise RuntimeError(
f"Template render failed for '{rel}': {e}"
) from e
out_path.write_text(text, encoding="utf-8")
try:
with contextlib.suppress(OSError):
shutil.copymode(tpl_path, out_path)
except OSError:
pass
rendered_outputs.append(out_path)
@@ -547,14 +584,16 @@ def generate(
result = QuantizeCelebi(pixel_array, 128)
return Hct.from_int(Score.score(result)[0])
def generate_color_scheme(seed: Hct, mode: str, scheme_class) -> dict[str, str]:
def generate_color_scheme(
seed: Hct, mode: str, scheme_class
) -> dict[str, str]:
is_dark = mode.lower() == "dark"
scheme = scheme_class(seed, is_dark, 0.0)
color_dict = {}
for color in vars(MaterialDynamicColors).keys():
for color in vars(MaterialDynamicColors):
color_name = getattr(MaterialDynamicColors, color)
if hasattr(color_name, "get_hct"):
color_int = color_name.get_hct(scheme).to_int()
@@ -563,7 +602,7 @@ def generate(
return color_dict
def int_to_hex(argb_int):
return "#{:06X}".format(argb_int & 0xFFFFFF)
return f"#{argb_int & 0xFFFFFF:06X}"
try:
with CONFIG.open() as f:
@@ -586,7 +625,9 @@ def generate(
(v.accents for v in meta.variants if v.id == p_variant), ()
)
if accent not in var_accents:
available = ", ".join(var_accents) if var_accents else "none"
available = (
", ".join(var_accents) if var_accents else "none"
)
raise typer.BadParameter(
f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}"
)
@@ -596,9 +637,14 @@ def generate(
if p_scheme in schemes:
meta = schemes[p_scheme]
variant = next(
(vari for vari in meta.variants if vari.id == p_variant), None
(vari for vari in meta.variants if vari.id == p_variant),
None,
)
if variant and requested_mode not in variant.modes and variant.modes:
if (
variant
and requested_mode not in variant.modes
and variant.modes
):
resolved_mode = sorted(variant.modes)[0]
palette_obj = get_palette(
@@ -623,13 +669,13 @@ def generate(
seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#"))
else:
image_path = image_path or Path(WALL_PATH)
generate_thumbnail(image_path, str(THUMB_PATH))
seed = seed_from_image(THUMB_PATH)
thumb_path = generate_thumbnail(image_path, THUMB_DIR)
seed = seed_from_image(thumb_path)
name = "dynamic"
flavor = "default"
if smart:
effective_mode = smart_mode(THUMB_PATH)
effective_mode = smart_mode(thumb_path)
elif mode is not None:
effective_mode = mode
else:
@@ -675,7 +721,9 @@ def generate(
print(f"rendered: {p}")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w") as f:
tmp_output = OUTPUT.with_suffix(".json.tmp")
with open(tmp_output, "w") as f:
json.dump(output_dict, f, indent=4)
os.replace(tmp_output, OUTPUT)
except Exception as e:
print(f"Error: {e}")
+3 -2
View File
@@ -1,4 +1,5 @@
import subprocess
import typer
args = ["qs", "-c", "zshell"]
@@ -8,9 +9,9 @@ app = typer.Typer()
@app.command()
def start():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["open"], check=True)
subprocess.run([*args, "ipc", "call", "picker", "open"], check=True)
@app.command()
def start_freeze():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["openFreeze"], check=True)
subprocess.run([*args, "ipc", "call", "picker", "openFreeze"], check=True)
+19 -11
View File
@@ -11,7 +11,7 @@ app = typer.Typer()
@app.command()
def kill():
result = subprocess.run(args + ["kill"], capture_output=True)
result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode != 0:
sys.stderr.write("No running instance to kill.\n")
sys.exit(1)
@@ -19,10 +19,11 @@ def kill():
def start_instance(no_daemon: bool = False) -> None:
result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True)
result = subprocess.run(
args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True
)
stdout = result.stdout.decode().strip()
if stdout:
if "already running" in stdout.lower():
if stdout and "already running" in stdout.lower():
sys.stderr.write(stdout + "\n")
sys.exit(1)
if result.returncode != 0:
@@ -38,10 +39,10 @@ def start(no_daemon: bool = False):
@app.command()
def restart(no_daemon: bool = False):
subprocess.run(args + ["kill"], capture_output=True)
subprocess.run([*args, "kill"], capture_output=True)
deadline = time.monotonic() + 2.5
while time.monotonic() < deadline:
result = subprocess.run(args + ["kill"], capture_output=True)
result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode == 255:
break
time.sleep(0.25)
@@ -50,7 +51,7 @@ def restart(no_daemon: bool = False):
@app.command()
def show():
result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True)
result = subprocess.run([*args, "ipc", "show"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
@@ -60,7 +61,7 @@ def show():
@app.command()
def log():
result = subprocess.run(args + ["log"], capture_output=True)
result = subprocess.run([*args, "log"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
@@ -70,7 +71,9 @@ def log():
@app.command()
def lock():
result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True)
result = subprocess.run(
[*args, "ipc", "call", "lock", "lock"], capture_output=True
)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
@@ -78,8 +81,13 @@ def lock():
@app.command()
def call(target: str, method: str, method_args: list[str] = typer.Argument(None)):
result = subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), capture_output=True)
def call(
target: str, method: str, method_args: list[str] = typer.Argument(None)
):
result = subprocess.run(
args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []),
capture_output=True,
)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
+8 -5
View File
@@ -1,9 +1,9 @@
import subprocess
import typer
from typing import Annotated
from PIL import Image, ImageFilter
from pathlib import Path
from typing import Annotated
import typer
from PIL import Image, ImageFilter
args = ["qs", "-c", "zshell"]
@@ -12,7 +12,10 @@ app = typer.Typer()
@app.command()
def set(wallpaper: Path):
subprocess.run(args + ["ipc"] + ["call"] + ["wallpaper"] + ["set"] + [wallpaper], check=True)
subprocess.run(
[*args, "ipc", "call", "wallpaper", "set", wallpaper],
check=True,
)
@app.command()
+25 -17
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from importlib.resources import files
from importlib.resources.abc import Traversable
from pathlib import PurePosixPath
ASSETS = Path(__file__).resolve().parent.parent / "assets" / "schemes"
ASSETS: Traversable = files("zshell") / "assets" / "schemes"
@dataclass(frozen=True)
@@ -30,7 +32,7 @@ class Palette:
accent: str | None = None
def _parse_txt(path: Path) -> dict[str, str]:
def _parse_txt(path: Traversable) -> dict[str, str]:
colors: dict[str, str] = {}
for line in path.read_text().splitlines():
line = line.strip()
@@ -46,7 +48,7 @@ def _parse_txt(path: Path) -> dict[str, str]:
def _discover_schemes() -> dict[str, SchemeMeta]:
schemes: dict[str, SchemeMeta] = {}
for scheme_dir in sorted(ASSETS.iterdir()):
for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name):
if not scheme_dir.is_dir() or scheme_dir.name.startswith("."):
continue
@@ -54,7 +56,7 @@ def _discover_schemes() -> dict[str, SchemeMeta]:
display_name = sid.capitalize()
variants: list[SchemeVariant] = []
for var_dir in sorted(scheme_dir.iterdir()):
for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name):
if not var_dir.is_dir() or var_dir.name.startswith("."):
continue
@@ -62,9 +64,10 @@ def _discover_schemes() -> dict[str, SchemeMeta]:
accents: set[str] = set()
for f in var_dir.iterdir():
if f.suffix != ".txt":
name = PurePosixPath(f.name)
if name.suffix != ".txt":
continue
stem = f.stem
stem = name.stem
if "-" in stem:
maybe_accent, maybe_mode = stem.rsplit("-", 1)
if maybe_mode in ("dark", "light"):
@@ -99,25 +102,28 @@ def _discover_schemes() -> dict[str, SchemeMeta]:
SCHEMES: dict[str, SchemeMeta] = _discover_schemes()
def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None) -> Palette:
def get_palette(
scheme: str, variant: str, mode: str, accent: str | None = None
) -> Palette:
if scheme not in SCHEMES:
raise KeyError(f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}")
raise KeyError(
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}"
)
meta = SCHEMES[scheme]
var_ids = {v.id for v in meta.variants}
if variant not in var_ids:
raise KeyError(f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}")
raise KeyError(
f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}"
)
if accent:
filename = f"{accent}-{mode}.txt"
else:
filename = f"{mode}.txt"
filename = f"{accent}-{mode}.txt" if accent else f"{mode}.txt"
txt_path = ASSETS / scheme / variant / filename
if not txt_path.exists():
if not txt_path.is_file():
txt_path = ASSETS / scheme / variant / f"{mode}.txt"
if not txt_path.exists():
if not txt_path.is_file():
var_info = next(v for v in meta.variants if v.id == variant)
raise FileNotFoundError(
f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}"
@@ -125,7 +131,9 @@ def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None)
colors = _parse_txt(txt_path)
return Palette(colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent)
return Palette(
colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent
)
def list_schemes() -> dict[str, SchemeMeta]:
+42 -14
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
import pytest
from pathlib import Path
import pytest
from zshell.utils import schemepalettes as sp
@@ -12,8 +13,12 @@ def tmp_schemes(tmp_path: Path) -> Path:
gmedium = schemes / "gruvbox" / "medium"
gmedium.mkdir(parents=True)
(gmedium / "dark.txt").write_text("background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n")
(gmedium / "light.txt").write_text("background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n")
(gmedium / "dark.txt").write_text(
"background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n"
)
(gmedium / "light.txt").write_text(
"background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n"
)
ghard = schemes / "gruvbox" / "hard"
ghard.mkdir(parents=True)
@@ -21,14 +26,24 @@ def tmp_schemes(tmp_path: Path) -> Path:
cmocha = schemes / "catppuccin" / "mocha"
cmocha.mkdir(parents=True)
(cmocha / "dark.txt").write_text("background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n")
(cmocha / "mauve-dark.txt").write_text("background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n")
(cmocha / "green-dark.txt").write_text("background 1e1e2e\nprimary a6e3a1\nsecondary 5b8964\nsurface 313244\n")
(cmocha / "dark.txt").write_text(
"background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n"
)
(cmocha / "mauve-dark.txt").write_text(
"background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n"
)
(cmocha / "green-dark.txt").write_text(
"background 1e1e2e\nprimary a6e3a1\nsecondary 5b8964\nsurface 313244\n"
)
clatte = schemes / "catppuccin" / "latte"
clatte.mkdir(parents=True)
(clatte / "light.txt").write_text("background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n")
(clatte / "mauve-light.txt").write_text("background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n")
(clatte / "light.txt").write_text(
"background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n"
)
(clatte / "mauve-light.txt").write_text(
"background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n"
)
cextra = schemes / "extra" / "default"
cextra.mkdir(parents=True)
@@ -81,13 +96,17 @@ class TestDiscoverSchemes:
def test_variant_has_modes(self):
schemes = sp._discover_schemes()
gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium")
gmedium = next(
v for v in schemes["gruvbox"].variants if v.id == "medium"
)
assert "dark" in gmedium.modes
assert "light" in gmedium.modes
def test_catppuccin_has_accents(self):
schemes = sp._discover_schemes()
mocha = next(v for v in schemes["catppuccin"].variants if v.id == "mocha")
mocha = next(
v for v in schemes["catppuccin"].variants if v.id == "mocha"
)
assert "mauve" in mocha.accents
assert "green" in mocha.accents
assert "rosewater" in mocha.accents
@@ -95,7 +114,9 @@ class TestDiscoverSchemes:
def test_non_accent_scheme_has_no_accents(self):
schemes = sp._discover_schemes()
gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium")
gmedium = next(
v for v in schemes["gruvbox"].variants if v.id == "medium"
)
assert gmedium.accents == ()
@@ -124,11 +145,15 @@ class TestGetPalette:
sp.get_palette("nope", "medium", "dark")
def test_unknown_variant_raises(self):
with pytest.raises(KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'"):
with pytest.raises(
KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'"
):
sp.get_palette("gruvbox", "bogus", "dark")
def test_unknown_accent_falls_back(self):
pal = sp.get_palette("catppuccin", "mocha", "dark", accent="nonexistent")
pal = sp.get_palette(
"catppuccin", "mocha", "dark", accent="nonexistent"
)
assert pal.accent == "nonexistent"
assert pal.colors["primary"] is not None
@@ -164,4 +189,7 @@ class TestResolvePreset:
assert sp.resolve_preset("default") == ("default", "default")
def test_edge_spaces(self):
assert sp.resolve_preset(" catppuccin : mocha ") == (" catppuccin ", " mocha ")
assert sp.resolve_preset(" catppuccin : mocha ") == (
" catppuccin ",
" mocha ",
)
+56 -15
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from subprocess import CompletedProcess
from unittest.mock import patch, call
from unittest.mock import call, patch
from typer.testing import CliRunner
from zshell.subcommands.shell import app
@@ -21,11 +21,15 @@ class TestKill:
def test_kill_runs_qs_kill_success(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n")
invoke("kill")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "kill"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run")
def test_kill_no_instance_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 255, b"", b"No running instances\n")
mock_run.return_value = CompletedProcess(
[], 255, b"", b"No running instances\n"
)
result = runner.invoke(app, ["kill"])
assert result.exit_code != 0
assert "No running instance to kill" in result.output
@@ -34,19 +38,32 @@ class TestKill:
class TestStart:
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_default_daemon(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
mock_run.return_value = CompletedProcess(
[], 0, b"", b"Launching config\n"
)
invoke("start")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "-n", "-d"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_no_daemon(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
mock_run.return_value = CompletedProcess(
[], 0, b"", b"Launching config\n"
)
invoke("start", "--no-daemon")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "-n"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_already_running_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"An instance of this configuration is already running.\n", b"")
mock_run.return_value = CompletedProcess(
[],
0,
b"An instance of this configuration is already running.\n",
b"",
)
result = runner.invoke(app, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
@@ -62,10 +79,14 @@ class TestStart:
class TestShow:
@patch("zshell.subcommands.shell.subprocess.run")
def test_show_runs_ipc_show(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"target visibilities\n", b"")
mock_run.return_value = CompletedProcess(
[], 0, b"target visibilities\n", b""
)
result = invoke("show")
assert "target visibilities" in result.output
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "show"], capture_output=True
)
class TestLog:
@@ -73,7 +94,9 @@ class TestLog:
def test_log_runs_qs_log(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"")
invoke("log")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "log"], capture_output=True
)
class TestLock:
@@ -81,7 +104,10 @@ class TestLock:
def test_lock_runs_ipc_call_lock(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("lock")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "call", "lock", "lock"],
capture_output=True,
)
class TestCall:
@@ -89,14 +115,27 @@ class TestCall:
def test_call_no_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], capture_output=True)
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "call", "target", "method"],
capture_output=True,
)
@patch("zshell.subcommands.shell.subprocess.run")
def test_call_with_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method", "arg1", "arg2")
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "call", "target", "method", "arg1", "arg2"],
[
"qs",
"-c",
"zshell",
"ipc",
"call",
"target",
"method",
"arg1",
"arg2",
],
capture_output=True,
)
@@ -106,7 +145,9 @@ class TestRestart:
@patch("zshell.subcommands.shell.subprocess.run")
def test_restart_kills_then_starts(self, mock_run, mock_start):
mock_run.side_effect = [
CompletedProcess([], 0, b"", b"Killed abc\n"), # first kill (captured)
CompletedProcess(
[], 0, b"", b"Killed abc\n"
), # first kill (captured)
CompletedProcess([], 255, b"", b""), # poll → no instance
]
invoke("restart")
+59
View File
@@ -0,0 +1,59 @@
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "zshell"
requires-python = ">=3.13"
version = "0.1.0"
dependencies = [
"typer",
"pillow",
"jinja2",
"materialyoucolor"
]
[project.scripts]
zshell-cli = "zshell:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build]
include = [
"cli/src/zshell/assets/**",
]
[tool.hatch.build.targets.wheel]
packages = ["cli/src/zshell"]
[tool.hatch.build.targets.sdist]
only-include = [
"cli/src",
]
[tool.ruff]
line-length = 80
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
line-ending = "lf"
docstring-code-format = true
docstring-code-line-length = "dynamic"
[tool.ruff.lint]
ignore = ["E501", "B008"]
select = [
"E",
"F",
"I",
"UP",
"B",
"SIM",
"RUF",
]
[tool.pytest.ini_options]
testpaths = ["cli/tests"]
pythonpath = ["cli/src"]
+81 -36
View File
@@ -4,22 +4,24 @@ import json
import re
import sys
from collections import defaultdict
from functools import lru_cache
from functools import cache
from pathlib import Path
@lru_cache(maxsize=None)
@cache
def read_lines(path: Path) -> tuple[str, ...]:
return tuple(path.read_text().splitlines())
ROW_RE = re.compile(
r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{')
r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{"
)
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
CHECKED_RE = re.compile(r'^\s*checked:\s*(?:Config)\.([\w.]+)\s*$')
CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$")
ONTOGGLED_RE = re.compile(
r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$')
r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$"
)
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
SKIP_LABELS = {"Muted", "None"}
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
@@ -45,8 +47,7 @@ def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
text = (settings / "PageRegistry.qml").read_text().splitlines()
start = next(
i for i, line in enumerate(text)
if re.search(r'\bpages\s*:\s*\[', line)
i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
)
out: list[tuple[str, str]] = []
@@ -92,14 +93,16 @@ def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
return out
BLOCK_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$')
BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$")
def _strip_comment(line: str) -> str:
return line.split("//", 1)[0].rstrip()
def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], int]:
def parse_block(
lines: list[str], i: int
) -> tuple[str, list[tuple[str, list]], int]:
line = _strip_comment(lines[i]).strip()
m = BLOCK_RE.match(line)
if not m:
@@ -152,8 +155,9 @@ def parse_page_comps(settings: Path) -> list[list[str]]:
text = (settings / "PageCompRegistry.qml").read_text().splitlines()
start = next(
i for i, line in enumerate(text)
if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line))
i
for i, line in enumerate(text)
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
)
comps: list[list[str]] = []
@@ -180,10 +184,12 @@ def parse_page_comps(settings: Path) -> list[list[str]]:
return comps
def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]:
def dedup_crumbs(
labels: list[str], icons: list[str]
) -> tuple[list[str], list[str]]:
out_labels: list[str] = []
out_icons: list[str] = []
for lbl, ico in zip(labels, icons):
for lbl, ico in zip(labels, icons, strict=False):
if out_labels and out_labels[-1] == lbl:
continue
out_labels.append(lbl)
@@ -227,7 +233,10 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
if mo:
pos = int(mo.group(1))
nav_children.setdefault(name, {})[pos] = (
pending_icon or "tune", pending_label or "", section)
pending_icon or "tune",
pending_label or "",
section,
)
pending_icon = pending_label = None
nav: dict[str, dict] = {}
@@ -236,8 +245,12 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
continue
main = names[0]
main_icon, main_label = top_meta.get(top_idx, ("tune", main))
nav[main] = {"pageIdx": top_idx, "subPath": [],
"crumbIcons": [main_icon], "crumbLabels": [main_label]}
nav[main] = {
"pageIdx": top_idx,
"subPath": [],
"crumbIcons": [main_icon],
"crumbLabels": [main_label],
}
children = dict(nav_children.get(main, {}))
opened_via_subpage = set()
for owner, kids in nav_children.items():
@@ -259,19 +272,26 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
labels = [main_label] + ([section] if section else []) + [label]
icons = [main_icon] + ([icon] if section else []) + [icon]
labels, icons = dedup_crumbs(labels, icons)
nav[child] = {"pageIdx": top_idx, "subPath": [pos],
nav[child] = {
"pageIdx": top_idx,
"subPath": [pos],
"crumbIcons": icons,
"crumbLabels": labels}
for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items():
"crumbLabels": labels,
}
for gpos, (gicon, glabel, gsection) in nav_children.get(
child, {}
).items():
if gpos >= len(names):
continue
glabels = labels + ([gsection] if gsection else []) + [glabel]
gicons = icons + ([gicon] if gsection else []) + [gicon]
glabels, gicons = dedup_crumbs(glabels, gicons)
nav[names[gpos]] = {
"pageIdx": top_idx, "subPath": [pos, gpos],
"pageIdx": top_idx,
"subPath": [pos, gpos],
"crumbIcons": gicons,
"crumbLabels": glabels}
"crumbLabels": glabels,
}
return nav
@@ -290,10 +310,12 @@ def tokenize(text: str) -> list[str]:
SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
SECTION_RE = re.compile(r'^\s*SectionHeader\s*\{')
SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{")
def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]:
def extract_settings(
files: dict[str, Path], nav: dict[str, dict]
) -> list[dict]:
entries: list[dict] = []
for comp, meta in nav.items():
pf = files.get(comp)
@@ -337,22 +359,35 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]
toggled_path = tg.group(1)
toggle_path = (
checked_path
if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
if row_type == "ToggleRow"
and checked_path
and checked_path == toggled_path
else ""
)
if label and label not in SKIP_LABELS and anchor:
extra = " ".join(meta["crumbLabels"]) + \
" " + section + " " + (subtext or "")
entries.append({
"pageIdx": meta["pageIdx"], "subPath": meta["subPath"],
extra = (
" ".join(meta["crumbLabels"])
+ " "
+ section
+ " "
+ (subtext or "")
)
entries.append(
{
"pageIdx": meta["pageIdx"],
"subPath": meta["subPath"],
"crumbIcons": meta["crumbIcons"],
"crumbLabels": meta["crumbLabels"],
"title": label, "anchor": anchor,
"title": label,
"anchor": anchor,
"section": section,
"subtext": subtext or "",
"togglePath": toggle_path,
"keywords": " ".join(sorted(set(tokenize(label + " " + extra)))),
})
"keywords": " ".join(
sorted(set(tokenize(label + " " + extra)))
),
}
)
i += 1
return entries
@@ -372,7 +407,9 @@ def build_inverted_and_ranking(entries: list[dict]):
seen.add(tok)
for tok, ids in inverted.items():
ids.sort(key=lambda i: ranking[tok][i], reverse=True)
return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()}
return inverted, {
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
}
def main() -> int:
@@ -387,14 +424,22 @@ def main() -> int:
inverted, ranking = build_inverted_and_ranking(entries)
for e in entries:
e.pop("keywords", None)
out.write_text(json.dumps({
out.write_text(
json.dumps(
{
"version": 2,
"entries": entries,
"inverted": inverted,
"ranking": ranking,
}, ensure_ascii=False, indent=2))
print(f"settings index: {len(entries)} entries, "
f"{len(inverted)} tokens -> {out}")
},
ensure_ascii=False,
indent=2,
)
)
print(
f"settings index: {len(entries)} entries, "
f"{len(inverted)} tokens -> {out}"
)
print("files:", len(files))
print("comps:", len(parse_page_comps(settings)))
print("registry:", len(parse_page_registry(settings)))