18 Commits
Author SHA1 Message Date
Inorishio aadecbfe44 Chore: created Spacer component, moved x available networks, added connected networks
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 17s
Python / test (pull_request) Successful in 59s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m31s
C++ / build (pull_request) Successful in 2m37s
2026-07-08 22:22:03 +02:00
zach 53f0aa80ae Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 30s
Python / test (pull_request) Successful in 42s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m36s
C++ / build (pull_request) Successful in 2m20s
2026-07-08 13:41:27 +02:00
Inorishio d9856c9409 Chore: created NetworkItem component, renamed network header to Wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 14s
Python / test (pull_request) Successful in 46s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m51s
C++ / build (pull_request) Successful in 2m36s
2026-07-02 22:46:09 +02:00
zach 8dc9753e2b feat: don't add the same network twice
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 30s
Python / lint-format (pull_request) Successful in 48s
Python / test (pull_request) Successful in 40s
C++ / build (pull_request) Successful in 2m8s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m13s
2026-07-02 22:09:25 +02:00
zach 2a4bd3dfdd chore: remove old code
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 17s
Python / lint-format (pull_request) Successful in 31s
Python / test (pull_request) Successful in 39s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m31s
C++ / build (pull_request) Successful in 2m17s
2026-07-02 21:58:01 +02:00
zach f75f51ef50 fix: sort is now correct
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 18s
Python / lint-format (pull_request) Successful in 21s
Python / test (pull_request) Successful in 48s
C++ / build (pull_request) Successful in 2m59s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m12s
2026-07-02 21:54:58 +02:00
zach e0873304d1 add sorting by strength
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 15s
Python / lint-format (pull_request) Successful in 32s
Python / test (pull_request) Successful in 39s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m45s
C++ / build (pull_request) Successful in 2m47s
2026-07-02 21:53:42 +02:00
zach 99a0155c17 Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 14s
Python / test (pull_request) Successful in 46s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m30s
C++ / build (pull_request) Successful in 2m32s
2026-07-02 21:46:15 +02:00
zach f309debea5 set scannerEnabled when popout is visible + remove scan button
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 18s
Python / lint-format (pull_request) Successful in 20s
Python / test (pull_request) Successful in 47s
C++ / build (pull_request) Successful in 3m9s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m15s
2026-07-02 16:06:52 +02:00
zach 0979fd014d Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 16s
Python / lint-format (pull_request) Successful in 32s
Python / test (pull_request) Successful in 38s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m49s
C++ / build (pull_request) Successful in 3m5s
2026-07-02 15:16:22 +02:00
zach 656bd0a54d Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 11s
Python / lint-format (pull_request) Successful in 16s
Python / test (pull_request) Successful in 46s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m37s
C++ / build (pull_request) Successful in 2m22s
2026-06-30 22:42:51 +02:00
zach f01001dcfa merge main into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 9s
Python / lint-format (pull_request) Successful in 16s
Python / test (pull_request) Successful in 49s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m42s
C++ / build (pull_request) Successful in 2m28s
2026-06-30 18:15:43 +02:00
zach bc6b0d50ab Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 34s
Python / lint-format (pull_request) Successful in 35s
Python / test (pull_request) Successful in 1m11s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m58s
C++ / build (pull_request) Successful in 4m31s
2026-06-29 22:10:45 +02:00
zach f27c9afc25 more methods for wifi and scanning + layout
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 15s
Python / test (pull_request) Successful in 29s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m6s
2026-06-26 15:15:03 +02:00
Inorishio 5962fc8354 Merge branch 'main' into module/wifi
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 17s
Python / lint-format (pull_request) Successful in 27s
Python / test (pull_request) Successful in 50s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m39s
2026-06-24 18:58:36 +02:00
Inorishio 1adcb9841c Added network properties, nicNames, netDevices, networkName < reminder I should use layouts.
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 16s
Python / test (pull_request) Successful in 50s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m52s
2026-06-22 23:34:58 +02:00
Inorishio 0e0f8555ed fixing singleton in helpers for network + getting output from systray 2026-06-15 20:48:01 +02:00
Inorishio 1d63e67554 removed network module and moved it to tray, have a popout window without content! everything still in wi.i.p. 2026-06-13 18:22:03 +02:00
55 changed files with 2610 additions and 3132 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ name: Rebuild CI Image
on: on:
schedule: schedule:
- cron: "0 6 * * 1" - cron: '0 6 * * 1'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -11,7 +11,7 @@ jobs:
container: container:
image: node:26-alpine image: node:26-alpine
env: env:
IMAGE: git.aramjonghu.dev/aramjonghu/zshell-ci:latest IMAGE: git.aramjonghu.nl/aramjonghu/zshell-ci:latest
steps: steps:
- name: Checkout - name: Checkout
@@ -21,7 +21,7 @@ jobs:
run: apk add --no-cache docker-cli run: apk add --no-cache docker-cli
- name: Login to registry - name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.dev --username aramjonghu --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.nl --username aramjonghu --password-stdin
- name: Build image - name: Build image
run: docker build -t "$IMAGE" -f ci/Dockerfile . run: docker build -t "$IMAGE" -f ci/Dockerfile .
+1 -34
View File
@@ -4,43 +4,10 @@ on:
pull_request: pull_request:
jobs: 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: build:
runs-on: alpine runs-on: alpine
container: container:
image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest image: git.aramjonghu.nl/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: steps:
- name: Checkout - name: Checkout
@@ -1,10 +1,10 @@
name: JS/TS name: Lint & Format (JS/TS)
on: on:
pull_request: pull_request:
jobs: jobs:
fmt: lint-format:
runs-on: alpine runs-on: alpine
container: node:26-alpine container: node:26-alpine
@@ -18,6 +18,7 @@ jobs:
git git
- name: Prettier - name: Prettier
continue-on-error: true
run: | run: |
if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then 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 npx --yes prettier --check "**/*.{js,jsx,ts,tsx,mjs,cjs}" --ignore-path .prettierignore
@@ -25,19 +26,6 @@ jobs:
echo "No JS/TS files found" echo "No JS/TS files found"
fi 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 - name: ESLint
run: | run: |
if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then
@@ -4,7 +4,7 @@ on:
pull_request: pull_request:
jobs: jobs:
fmt: lint-format:
runs-on: alpine runs-on: alpine
container: node:26-alpine container: node:26-alpine
@@ -23,28 +23,11 @@ jobs:
pip install --no-cache-dir ruff pip install --no-cache-dir ruff
- name: Format check - name: Format check
continue-on-error: true
run: | run: |
. .venv/bin/activate . .venv/bin/activate
ruff format --check . 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 - name: Lint
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -80,30 +63,3 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
cd cli cd cli
python -m pytest tests/ -v 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/
+85
View File
@@ -0,0 +1,85 @@
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"
-152
View File
@@ -1,152 +0,0 @@
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
+1
View File
@@ -15,3 +15,4 @@ dist/
**/target/ **/target/
**/test-plugins/ **/test-plugins/
**/Charts/ **/Charts/
**/network-dev/
+32 -89
View File
@@ -36,15 +36,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install") set(ENABLE_MODULES "plugin;shell" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(CMAKE_INSTALL_MESSAGE NEVER)
add_compile_options( add_compile_options(
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wall -Wextra -Wpedantic -Wshadow -Wconversion
-Wold-style-cast -Wnull-dereference -Wdouble-promotion -Wold-style-cast -Wnull-dereference -Wdouble-promotion
@@ -74,84 +72,48 @@ if("shell" IN_LIST ENABLE_MODULES)
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist") set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
set(ZSHELL_CLI_SRC "${CMAKE_SOURCE_DIR}/cli/src/zshell") set(ZSHELL_CLI_SRC "${CMAKE_SOURCE_DIR}/cli/src/zshell")
find_program(NUITKA_EXECUTABLE nuitka) find_program(NUITKA_EXECUTABLE nuitka REQUIRED)
if(NUITKA_EXECUTABLE) file(GLOB_RECURSE ZSHELL_CLI_SOURCES CONFIGURE_DEPENDS
file(GLOB_RECURSE ZSHELL_CLI_SOURCES CONFIGURE_DEPENDS "${ZSHELL_CLI_SRC}/*.py"
"${ZSHELL_CLI_SRC}/*.py" )
) file(GLOB_RECURSE ZSHELL_CLI_ASSETS CONFIGURE_DEPENDS
file(GLOB_RECURSE ZSHELL_CLI_ASSETS CONFIGURE_DEPENDS "${ZSHELL_CLI_SRC}/assets/*"
"${ZSHELL_CLI_SRC}/assets/*" )
)
add_custom_command( add_custom_command(
OUTPUT "${ZSHELL_CLI_DIST}/zshell-cli" OUTPUT "${ZSHELL_CLI_DIST}/zshell-cli"
COMMAND ${CMAKE_COMMAND} -E make_directory "${ZSHELL_CLI_BUILD_DIR}" COMMAND ${CMAKE_COMMAND} -E make_directory "${ZSHELL_CLI_BUILD_DIR}"
COMMAND ${CMAKE_COMMAND} -E rm -rf "${ZSHELL_CLI_DIST}" COMMAND ${CMAKE_COMMAND} -E rm -rf "${ZSHELL_CLI_DIST}"
COMMAND COMMAND
${NUITKA_EXECUTABLE} ${NUITKA_EXECUTABLE}
--standalone --standalone
--include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets --include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets
--output-dir=${ZSHELL_CLI_BUILD_DIR} --output-dir=${ZSHELL_CLI_BUILD_DIR}
--output-filename=zshell-cli --output-filename=zshell-cli
${CMAKE_SOURCE_DIR}/cli/src/zshell/ ${CMAKE_SOURCE_DIR}/cli/src/zshell/
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/cli WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/cli
DEPENDS ${ZSHELL_CLI_SOURCES} ${ZSHELL_CLI_ASSETS} DEPENDS ${ZSHELL_CLI_SOURCES} ${ZSHELL_CLI_ASSETS}
) )
add_custom_target(zshell-cli ALL DEPENDS "${ZSHELL_CLI_DIST}/zshell-cli") 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(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) install(DIRECTORY "${ZSHELL_CLI_DIST}/" DESTINATION "${INSTALL_LIBDIR}/zshell-cli" PATTERN "zshell-cli" EXCLUDE)
configure_file( configure_file(
"${CMAKE_SOURCE_DIR}/Plugins/cmake/zshell-cli.cmake.in" "${CMAKE_SOURCE_DIR}/Plugins/cmake/zshell-cli.cmake.in"
"${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake" "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake"
@ONLY @ONLY
) )
install(SCRIPT "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake") 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() endif()
if("plugin" IN_LIST ENABLE_MODULES) if("plugin" IN_LIST ENABLE_MODULES)
add_subdirectory(Plugins) add_subdirectory(Plugins)
endif() endif()
if("shell" IN_LIST ENABLE_MODULES) if("shell" IN_LIST ENABLE_MODULES)
@@ -168,22 +130,3 @@ if("shell" IN_LIST ENABLE_MODULES)
# Greeter # Greeter
install(DIRECTORY Greeter/ DESTINATION "${INSTALL_GREETERCONFDIR}") install(DIRECTORY Greeter/ DESTINATION "${INSTALL_GREETERCONFDIR}")
endif() endif()
if("m3shapes" IN_LIST ENABLE_MODULES)
message(STATUS "Fetching M3Shapes module")
include(FetchContent)
set(M3SHAPES_REV bdc327b29f95394a732baf3c9b19658ba23755b6)
FetchContent_Declare(
m3shapes_external
GIT_REPOSITORY https://github.com/soramanew/m3shapes.git
GIT_TAG ${M3SHAPES_REV}
SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/m3shapes-${M3SHAPES_REV}"
)
FetchContent_MakeAvailable(m3shapes_external)
message(STATUS "Done fetching M3Shapes module")
# Fix m3shapes wrong rpath
if(TARGET m3shapesplugin)
set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN")
endif()
endif()
+1 -1
View File
@@ -6,7 +6,7 @@ ListView {
property bool doneFakeFlick property bool doneFakeFlick
interactive: !Visibilities.getForActive()?.isDrawing interactive: !Visibilities.getForActive().isDrawing
maximumFlickVelocity: 3000 maximumFlickVelocity: 3000
rebound: Transition { rebound: Transition {
-114
View File
@@ -1,114 +0,0 @@
import QtQuick
import Quickshell
import M3Shapes
import qs.Config
MaterialShape {
id: root
property bool animated: true
property real cRotation
property bool containsIcon
property real dampingRatio: 0.6
property real lRotation
property int morphAnimRotation: 60
property real morphScale: 0.14
property alias rotateAnimDuration: rotateAnim.duration
property int shapeIndex
property list<int> shapes: {
if (containsIcon)
return [MaterialShape.SoftBurst, MaterialShape.Cookie9Sided, MaterialShape.Pill, MaterialShape.Sunny, MaterialShape.Cookie4Sided, MaterialShape.Oval];
return [MaterialShape.SoftBurst, MaterialShape.Cookie9Sided, MaterialShape.Pentagon, MaterialShape.Pill, MaterialShape.Sunny, MaterialShape.Cookie4Sided, MaterialShape.Oval];
}
readonly property real springDuration: {
const wn = Math.sqrt(stiffness);
const r = -dampingRatio * wn;
const c = 1 / Math.sqrt(1 - dampingRatio * dampingRatio);
return Math.log(visibilityThreshold / c) / r;
}
readonly property real springMaxVelocity: {
const wn = Math.sqrt(stiffness);
const factor = Math.exp(-z * Math.acos(z) / Math.sqrt(1 - z * z));
return wn * factor;
}
property bool springSettled: true
property real stiffness: 180
property real thisLRotation
property real visibilityThreshold: 0.075
function spring(t: real): var {
const wn = Math.sqrt(stiffness);
const za = dampingRatio * wn;
const wd = wn * Math.sqrt(1 - dampingRatio * dampingRatio);
const r = za / wd;
const pos = 1 - Math.exp(-za * t) * (Math.cos(wd * t) + r * Math.sin(wd * t));
const vel = Math.exp(-za * t) * (wn * wn / wd) * Math.sin(wd * t);
return [pos, vel];
}
color: DynamicColors.palette.m3primary
implicitSize: 38
toShape: shapes[0]
RotationAnimation on cRotation {
id: rotateAnim
duration: 4666
easing.type: Easing.Linear
from: 0
loops: Animation.Infinite
running: root.animated
to: 360
}
Behavior on color {
CAnim {
}
}
ElapsedTimer {
id: timer
}
FrameAnimation {
running: root.animated && !root.springSettled
onTriggered: {
const t = timer.elapsed();
if (t >= root.springDuration) {
root.springSettled = true;
} else {
const [pos, vel] = root.spring(t);
root.morphProgress = Math.min(1, pos); // Overshooting the morph looks weird
root.thisLRotation = pos * root.morphAnimRotation;
root.scale = 1 + vel * root.morphScale / root.springMaxVelocity;
}
}
}
Timer {
interval: 650
repeat: true
running: root.animated
triggeredOnStart: true
onTriggered: {
root.beginBatchUpdate();
root.fromShape = root.toShape;
root.shapeIndex = (root.shapeIndex + 1) % root.shapes.length;
root.toShape = root.shapes[root.shapeIndex];
root.morphProgress = 0;
root.rotation = root.rotation;
root.lRotation = (root.lRotation + root.thisLRotation) % 360;
root.thisLRotation = 0;
root.rotation = Qt.binding(() => root.cRotation + root.lRotation + root.thisLRotation);
root.springSettled = false;
timer.restart();
root.endBatchUpdate();
}
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: !enabled ? undefined : Qt.PointingHandCursor cursorShape: !enabled ? undefined : Qt.PointingHandCursor
enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing enabled: parent.enabled && !Visibilities.getForActive().isDrawing
hoverEnabled: true hoverEnabled: true
Behavior on stateOpacity { Behavior on stateOpacity {
+13 -43
View File
@@ -6,55 +6,25 @@ import qs.Effects
CustomListView { CustomListView {
id: root id: root
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
property real fadeAmount: 0.1 property real fadeAmount: 0.1
readonly property bool horizontal: orientation === ListView.Horizontal property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
function contentSize(): real {
return horizontal ? contentWidth : contentHeight;
}
function fadeShouldBeActive(isStart: bool): bool { function fadeShouldBeActive(isStart: bool): bool {
// When content is smaller than flickable size, hide fade when rebound starts. // When content is smaller than flickable size, hide fade when rebound starts
if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) { if (contentHeight + topMargin + bottomMargin < height && rebound.running && ((isStart ? verticalOvershoot > 0 : verticalOvershoot < 0)))
return false; return false;
}
if (isStart) if (isStart)
return visibleStart() > 0; return visibleArea.yPosition > 0;
return visibleArea.yPosition + visibleArea.heightRatio < 1;
return visibleStart() + visibleRatio() < 1;
} }
function marginEnd(): real { flickableDirection: Flickable.VerticalFlick
return horizontal ? rightMargin : bottomMargin;
}
function marginStart(): real {
return horizontal ? leftMargin : topMargin;
}
function overshootStart(): real {
return horizontal ? horizontalOvershoot : verticalOvershoot;
}
function viewportSize(): real {
return horizontal ? width : height;
}
function visibleRatio(): real {
return horizontal ? visibleArea.widthRatio : visibleArea.heightRatio;
}
function visibleStart(): real {
return horizontal ? visibleArea.xPosition : visibleArea.yPosition;
}
flickableDirection: horizontal ? Flickable.HorizontalFlick : Flickable.VerticalFlick
layer.enabled: true layer.enabled: true
orientation: ListView.Vertical
Behavior on endFadeOpacity { Behavior on bottomFadeOpacity {
Anim { Anim {
type: Anim.SlowEffects type: Anim.SlowEffects
} }
@@ -70,10 +40,10 @@ CustomListView {
visible: false visible: false
gradient: Gradient { gradient: Gradient {
orientation: root.horizontal ? Gradient.Horizontal : Gradient.Vertical orientation: Gradient.Vertical
GradientStop { GradientStop {
color: Qt.rgba(0, 0, 0, root.startFadeOpacity) color: Qt.rgba(0, 0, 0, root.topFadeOpacity)
position: 0 position: 0
} }
@@ -88,13 +58,13 @@ CustomListView {
} }
GradientStop { GradientStop {
color: Qt.rgba(0, 0, 0, root.endFadeOpacity) color: Qt.rgba(0, 0, 0, root.bottomFadeOpacity)
position: 1 position: 1
} }
} }
} }
} }
Behavior on startFadeOpacity { Behavior on topFadeOpacity {
Anim { Anim {
type: Anim.SlowEffects type: Anim.SlowEffects
} }
-46
View File
@@ -1,46 +0,0 @@
import QtQuick
import QtQuick.Shapes
import qs.Config
Shape {
id: root
property real amplitude: 3
property color color: DynamicColors.palette.m3surfaceContainer
readonly property real waveHeight: amplitude * 2
property int waves: 4
asynchronous: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.color
strokeColor: "transparent"
strokeWidth: 0
Behavior on fillColor {
CAnim {
}
}
PathSvg {
path: {
const w = root.width;
const h = root.height;
const a = root.amplitude;
const n = Math.max(1, root.waves);
const wl = w / n;
const half = wl / 2;
let d = `M 0,${a} `;
for (let i = 0; i < n; ++i) {
const x = i * wl;
d += `Q ${x + half / 2},${-a} ${x + half},${a} `;
d += `Q ${x + half + half / 2},${3 * a} ${x + wl},${a} `;
}
d += `L ${w},${h} L 0,${h} Z`;
return d;
}
}
}
}
-4
View File
@@ -40,10 +40,6 @@ JsonObject {
id: "tray", id: "tray",
enabled: true enabled: true
}, },
{
id: "network",
enabled: false
},
{ {
id: "clock", id: "clock",
enabled: true enabled: true
+11
View File
@@ -311,6 +311,17 @@ 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 target: root.visibilities
} }
} }
+1
View File
@@ -38,6 +38,7 @@ Region {
R { R {
panel: root.panels.osdWrapper panel: root.panels.osdWrapper
width: panel.width * (1 - root.panels.osd.offsetScale) + root.borderThickness width: panel.width * (1 - root.panels.osd.offsetScale) + root.borderThickness
x: root.win.width - width
} }
R { R {
+9 -1
View File
@@ -26,7 +26,7 @@ CustomWindow {
if (focusGrab.active) if (focusGrab.active)
return 0; 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 0;
return 100; return 100;
@@ -162,6 +162,14 @@ CustomWindow {
Component.onCompleted: Visibilities.load(root.screen, this) Component.onCompleted: Visibilities.load(root.screen, this)
} }
IpcHandler {
function toggleLauncher(fix: string): void {
visibilities.launcher = !visibilities.launcher;
}
target: "visibilities"
}
Binding { Binding {
property: "bar" property: "bar"
target: visibilities target: visibilities
+42 -15
View File
@@ -11,13 +11,8 @@ Singleton {
id: root id: root
property bool appleDisplayPresent: false 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> ddcMonitors: []
property list<var> ddcServiceMon: []
readonly property list<Monitor> monitors: variants.instances readonly property list<Monitor> monitors: variants.instances
function decreaseBrightness(): void { function decreaseBrightness(): void {
@@ -61,6 +56,8 @@ Singleton {
onMonitorsChanged: { onMonitorsChanged: {
ddcMonitors = []; ddcMonitors = [];
ddcServiceMon = [];
ddcServiceProc.running = true;
ddcProc.running = true; ddcProc.running = true;
} }
@@ -95,6 +92,26 @@ 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 { CustomShortcut {
description: "Increase brightness" description: "Increase brightness"
name: "brightnessUp" name: "brightnessUp"
@@ -166,12 +183,16 @@ Singleton {
id: monitor id: monitor
property real brightness property real brightness
readonly property string busNum: ddcInfo?.busNum ?? "" readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? ""
readonly property var ddcInfo: root.ddcMonitorMap[modelData.name] ?? null readonly property string displayNum: root.ddcServiceMon.find(m => m.name === modelData.model)?.display ?? ""
readonly property Process initProc: Process { readonly property Process initProc: Process {
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
if (monitor.isAppleDisplay) { 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) {
const val = parseInt(text.trim()); const val = parseInt(text.trim());
monitor.brightness = val / 101; monitor.brightness = val / 101;
} else { } else {
@@ -182,11 +203,12 @@ Singleton {
} }
} }
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
readonly property bool isDdc: ddcInfo !== null readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name)
readonly property bool isDdcService: Config.services.ddcutilService
required property ShellScreen modelData required property ShellScreen modelData
property real queuedBrightness: NaN property real queuedBrightness: NaN
readonly property Timer timer: Timer { readonly property Timer timer: Timer {
interval: 400 interval: 500
onTriggered: { onTriggered: {
if (!isNaN(monitor.queuedBrightness)) { if (!isNaN(monitor.queuedBrightness)) {
@@ -197,7 +219,9 @@ Singleton {
} }
function initBrightness(): void { function initBrightness(): void {
if (isAppleDisplay) if (isDdcService)
initProc.command = ["ddcutil-client", "-d", displayNum, "getvcp", "10"];
else if (isAppleDisplay)
initProc.command = ["asdbctl", "get"]; initProc.command = ["asdbctl", "get"];
else if (isDdc) else if (isDdc)
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]; initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"];
@@ -213,25 +237,28 @@ Singleton {
if (Math.round(brightness * 100) === rounded) if (Math.round(brightness * 100) === rounded)
return; return;
if (isDdc && timer.running) { if ((isDdc || isDdcService) && timer.running) {
queuedBrightness = value; queuedBrightness = value;
return; return;
} }
brightness = value; brightness = value;
if (isAppleDisplay) if (isDdcService)
Quickshell.execDetached(["ddcutil-client", "-d", displayNum, "setvcp", "10", rounded]);
else if (isAppleDisplay)
Quickshell.execDetached(["asdbctl", "set", rounded]); Quickshell.execDetached(["asdbctl", "set", rounded]);
else if (isDdc) else if (isDdc)
Quickshell.execDetached(["ddcutil", "--disable-dynamic-sleep", "--sleep-multiplier", ".1", "--skip-ddc-checks", "-b", busNum, "setvcp", "10", rounded]); Quickshell.execDetached(["ddcutil", "--disable-dynamic-sleep", "--sleep-multiplier", ".1", "--skip-ddc-checks", "-b", busNum, "setvcp", "10", rounded]);
else else
Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]); Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]);
if (isDdc) if (isDdc || isDdcService)
timer.restart(); timer.restart();
} }
Component.onCompleted: initBrightness() Component.onCompleted: initBrightness()
onBusNumChanged: initBrightness() onBusNumChanged: initBrightness()
onDisplayNumChanged: initBrightness()
} }
} }
+51
View File
@@ -0,0 +1,51 @@
pragma Singleton
import Quickshell
import Quickshell.Networking
import QtQuick
Singleton {
id: root
property bool active: false
readonly property list<Network> connectedNetworks: networks.filter(n => n.connected)
readonly property list<NetworkDevice> devices: Networking.devices.values
readonly property list<Network> knownNetworks: networks.filter(n => n.known && !n.known.ConnectionState.Connected)
readonly property list<Network> networks: {
const list = [];
for (const d of wifiDevices) {
for (const n of d.networks.values) {
if (!list.includes(n))
list.push(n);
}
}
return list;
}
property bool scanning: false
readonly property list<Network> unknownNetworks: networks.filter(n => !n.known)
readonly property list<WifiDevice> wifiDevices: devices.filter(d => wifiDevice(d))
readonly property bool wifiEnabled: Networking.wifiEnabled
function isSecure(security): bool {
return (security === WifiSecurityType.WpaPsk || security === WifiSecurityType.Wpa2Psk || security === WifiSecurityType.Sae);
}
function setScan(value: bool): void {
for (const d of wifiDevices) {
d.scannerEnabled = value;
}
}
function setWifi(value: bool): void {
Networking.wifiEnabled = value;
}
function wifiDevice(dev): bool {
return dev.type === DeviceType.Wifi;
}
onActiveChanged: {
for (const d of wifiDevices)
d.scannerEnabled = active;
}
}
+3 -31
View File
@@ -1,12 +1,12 @@
pragma Singleton pragma Singleton
import QtQml
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import QtQml
import ZShell import ZShell
import qs.Components
import qs.Config import qs.Config
import qs.Components
Singleton { Singleton {
id: root id: root
@@ -15,24 +15,7 @@ Singleton {
readonly property list<MprisPlayer> list: Mpris.players.values readonly property list<MprisPlayer> list: Mpris.players.values
property alias manualActive: props.manualActive property alias manualActive: props.manualActive
function getArtUrl(player: MprisPlayer): string {
if (!player)
return "";
if (player.trackArtUrl)
return player.trackArtUrl;
const url = player.metadata["xesam:url"] ?? "";
if (url.startsWith("https://www.youtube.com/watch")) {
// Fallback for youtube
const id = url.match(/[?&]v=([\w-]{11})/)?.[1];
return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : "";
}
return "";
}
function getIdentity(player: MprisPlayer): string { function getIdentity(player: MprisPlayer): string {
if (!player)
return "";
const alias = Config.services.playerAliases.find(a => a.from === player.identity); const alias = Config.services.playerAliases.find(a => a.from === player.identity);
return alias?.to ?? player.identity; return alias?.to ?? player.identity;
} }
@@ -42,12 +25,9 @@ Singleton {
if (!Config.utilities.toasts.nowPlaying) { if (!Config.utilities.toasts.nowPlaying) {
return; return;
} }
if (root.active.trackArtist != "" && root.active.trackTitle != "") {
Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(root.active.trackArtist).arg(root.active.trackTitle), "music_note");
}
} }
target: root.active target: active
} }
PersistentProperties { PersistentProperties {
@@ -58,10 +38,8 @@ Singleton {
reloadableId: "players" reloadableId: "players"
} }
// qmllint disable unresolved-type
CustomShortcut { CustomShortcut {
description: "Toggle media playback" description: "Toggle media playback"
// qmllint enable unresolved-type
name: "mediaToggle" name: "mediaToggle"
onPressed: { onPressed: {
@@ -71,10 +49,8 @@ Singleton {
} }
} }
// qmllint disable unresolved-type
CustomShortcut { CustomShortcut {
description: "Previous track" description: "Previous track"
// qmllint enable unresolved-type
name: "mediaPrev" name: "mediaPrev"
onPressed: { onPressed: {
@@ -84,10 +60,8 @@ Singleton {
} }
} }
// qmllint disable unresolved-type
CustomShortcut { CustomShortcut {
description: "Next track" description: "Next track"
// qmllint enable unresolved-type
name: "mediaNext" name: "mediaNext"
onPressed: { onPressed: {
@@ -97,10 +71,8 @@ Singleton {
} }
} }
// qmllint disable unresolved-type
CustomShortcut { CustomShortcut {
description: "Stop media playback" description: "Stop media playback"
// qmllint enable unresolved-type
name: "mediaStop" name: "mediaStop"
onPressed: root.active?.stop() onPressed: root.active?.stop()
+32 -107
View File
@@ -1,7 +1,7 @@
pragma Singleton pragma Singleton
import QtQuick
import Quickshell import Quickshell
import QtQuick
import ZShell import ZShell
import qs.Config import qs.Config
@@ -12,15 +12,15 @@ Singleton {
property var cc property var cc
property string city property string city
readonly property string description: cc?.weatherDesc ?? qsTr("No weather") readonly property string description: cc?.weatherDesc ?? qsTr("No weather")
readonly property string feelsLike: formatTemp(cc?.feelsLikeC) readonly property string feelsLike: `${cc?.feelsLikeC ?? 0}°C`
property list<var> forecast property list<var> forecast
property list<var> hourlyForecast property list<var> hourlyForecast
readonly property int humidity: cc?.humidity ?? 0 readonly property int humidity: cc?.humidity ?? 0
readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert"
property string loc property string loc
readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), "h:mm") : "--:--"
readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), "h:mm") : "--:--"
readonly property string temp: formatTemp(cc?.tempC) readonly property string temp: `${cc?.tempC ?? 0}°C`
readonly property real windSpeed: cc?.windSpeed ?? 0 readonly property real windSpeed: cc?.windSpeed ?? 0
function fetchCityFromCoords(coords: string): void { function fetchCityFromCoords(coords: string): void {
@@ -29,48 +29,29 @@ Singleton {
return; return;
} }
const [lat, lon] = coords.split(",").map(s => s.trim()); const [lat, lon] = coords.split(",");
const lang = Qt.locale().name.split("_")[0] || "en"; const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`;
Requests.get(url, text => {
const fallbackToBigDataCloud = () => {
const fallbackUrl = `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=${lang}`;
Requests.get(fallbackUrl, text => {
const geo = JSON.parse(text);
const geoCity = geo.city || geo.locality;
if (geoCity) {
city = fixCityName(geoCity);
cachedCities.set(coords, city);
} else {
city = "Unknown City";
}
});
};
const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson&accept-language=${lang}`;
Requests.get(nominatimUrl, text => {
const geo = JSON.parse(text).features?.[0]?.properties.geocoding; const geo = JSON.parse(text).features?.[0]?.properties.geocoding;
if (geo) { if (geo) {
const geoCity = geo.type === "city" ? geo.name : geo.city; const geoCity = geo.type === "city" ? geo.name : geo.city;
if (geoCity) { city = geoCity;
city = fixCityName(geoCity); cachedCities.set(coords, geoCity);
cachedCities.set(coords, city); } else {
return; city = "Unknown City";
}
} }
fallbackToBigDataCloud(); });
}, fallbackToBigDataCloud);
} }
function fetchCoordsFromCity(cityName: string): void { function fetchCoordsFromCity(cityName: string): void {
const lang = Qt.locale().name.split("_")[0] || "en"; const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`;
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=${lang}&format=json`;
Requests.get(url, text => { Requests.get(url, text => {
const json = JSON.parse(text); const json = JSON.parse(text);
if (json.results && json.results.length > 0) { if (json.results && json.results.length > 0) {
const result = json.results[0]; const result = json.results[0];
loc = result.latitude + "," + result.longitude; loc = result.latitude + "," + result.longitude;
city = fixCityName(result.name); city = result.name;
} else { } else {
loc = ""; loc = "";
reload(); reload();
@@ -91,21 +72,25 @@ Singleton {
cc = { cc = {
weatherCode: json.current.weather_code, weatherCode: json.current.weather_code,
weatherDesc: getWeatherCondition(json.current.weather_code), weatherDesc: getWeatherCondition(json.current.weather_code),
tempC: json.current.temperature_2m, tempC: Math.round(json.current.temperature_2m),
feelsLikeC: json.current.apparent_temperature, tempF: Math.round(toFahrenheit(json.current.temperature_2m)),
feelsLikeC: Math.round(json.current.apparent_temperature),
feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)),
humidity: json.current.relative_humidity_2m, humidity: json.current.relative_humidity_2m,
windSpeed: json.current.wind_speed_10m, windSpeed: json.current.wind_speed_10m,
isDay: json.current.is_day, isDay: json.current.is_day,
sunrise: json.daily.sunrise[0].replace("T", " "), sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0].replace("T", " ") sunset: json.daily.sunset[0]
}; };
const forecastList = []; const forecastList = [];
for (let i = 0; i < json.daily.time.length; i++) for (let i = 0; i < json.daily.time.length; i++)
forecastList.push({ forecastList.push({
date: json.daily.time[i].replace(/-/g, "/"), date: json.daily.time[i],
maxTempC: json.daily.temperature_2m_max[i], maxTempC: Math.round(json.daily.temperature_2m_max[i]),
minTempC: json.daily.temperature_2m_min[i], maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])),
minTempC: Math.round(json.daily.temperature_2m_min[i]),
minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])),
weatherCode: json.daily.weather_code[i], weatherCode: json.daily.weather_code[i],
icon: Icons.getWeatherIcon(json.daily.weather_code[i]) icon: Icons.getWeatherIcon(json.daily.weather_code[i])
}); });
@@ -114,8 +99,7 @@ Singleton {
const hourlyList = []; const hourlyList = [];
const now = new Date(); const now = new Date();
for (let i = 0; i < json.hourly.time.length; i++) { for (let i = 0; i < json.hourly.time.length; i++) {
const time = new Date(json.hourly.time[i].replace("T", " ")); const time = new Date(json.hourly.time[i]);
if (time < now) if (time < now)
continue; continue;
@@ -123,7 +107,7 @@ Singleton {
timestamp: json.hourly.time[i], timestamp: json.hourly.time[i],
hour: time.getHours(), hour: time.getHours(),
tempC: Math.round(json.hourly.temperature_2m[i]), tempC: Math.round(json.hourly.temperature_2m[i]),
precipChance: json.hourly.precipitation_probability[i], tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])),
weatherCode: json.hourly.weather_code[i], weatherCode: json.hourly.weather_code[i],
icon: Icons.getWeatherIcon(json.hourly.weather_code[i]) icon: Icons.getWeatherIcon(json.hourly.weather_code[i])
}); });
@@ -132,59 +116,6 @@ Singleton {
}); });
} }
function fixCityName(cityName: string): string {
if (!cityName)
return "";
const mapping = {
// Polish
"Poznan": "Poznań",
"Wroclaw": "Wrocław",
"Krakow": "Kraków",
"Gdansk": "Gdańsk",
"Lodz": "Łódź",
"Rzeszow": "Rzeszów",
"Torun": "Toruń",
"Bialystok": "Białystok",
"Czestochowa": "Częstochowa",
"Plock": "Płock",
"Ruda Slaska": "Ruda Śląska",
"Dabrowa Gornicza": "Dąbrowa Górnicza",
"Elblag": "Elbląg",
"Gorzow Wielkopolski": "Gorzów Wielkopolski",
"Zielona Gora": "Zielona Góra",
"Slupsk": "Słupsk",
// German
"Munchen": "München",
"Koln": "Köln",
"Dusseldorf": "Düsseldorf",
"Nurnberg": "Nürnberg",
// French & Spanish & Portuguese
"Sao Paulo": "São Paulo",
"Montreal": "Montréal",
"Quebec": "Québec",
"Bogota": "Bogotá",
"Medellin": "Medellín",
"Cordoba": "Córdoba",
// Turkish
"Istanbul": "İstanbul",
"Izmir": "İzmir",
// Scandinavian & others
"Malmo": "Malmö",
"Goteborg": "Göteborg",
"Zurich": "Zürich",
"Geneve": "Genève"
};
return mapping[cityName] || cityName;
}
function formatTemp(temp: var): string {
return Config.services.useFahrenheit ? `${temp !== undefined ? Math.round(toFahrenheit(temp)) : "--"}°F` : `${temp !== undefined ? Math.round(temp) : "--"}°C`;
}
function getWeatherCondition(code: string): string { function getWeatherCondition(code: string): string {
const conditions = { const conditions = {
"0": "Clear", "0": "Clear",
@@ -223,9 +154,9 @@ Singleton {
if (!loc || loc.indexOf(",") === -1) if (!loc || loc.indexOf(",") === -1)
return ""; return "";
const [lat, lon] = loc.split(",").map(s => s.trim()); const [lat, lon] = loc.split(",");
const baseUrl = "https://api.open-meteo.com/v1/forecast"; const baseUrl = "https://api.open-meteo.com/v1/forecast";
const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m,precipitation_probability", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"];
return baseUrl + "?" + params.join("&"); return baseUrl + "?" + params.join("&");
} }
@@ -258,14 +189,7 @@ Singleton {
onLocChanged: fetchWeatherData() onLocChanged: fetchWeatherData()
Connections { // Refresh current location hourly
function onWeatherLocationChanged(): void {
root.reload();
}
target: Config.services
}
Timer { Timer {
interval: 3600000 // 1 hour interval: 3600000 // 1 hour
repeat: true repeat: true
@@ -276,5 +200,6 @@ Singleton {
ElapsedTimer { ElapsedTimer {
id: timer id: timer
} }
} }
-10
View File
@@ -6,7 +6,6 @@ import QtQuick.Layouts
import qs.Modules import qs.Modules
import qs.Config import qs.Config
import qs.Modules.SysTray import qs.Modules.SysTray
import qs.Modules.Network
import qs.Modules.Updates import qs.Modules.Updates
RowLayout { RowLayout {
@@ -169,15 +168,6 @@ RowLayout {
} }
} }
DelegateChoice {
roleValue: "network"
delegate: WrappedLoader {
sourceComponent: NetworkWidget {
}
}
}
DelegateChoice { DelegateChoice {
roleValue: "media" roleValue: "media"
-1
View File
@@ -10,7 +10,6 @@ import qs.Config
import qs.Helpers import qs.Helpers
import qs.Modules.SysTray import qs.Modules.SysTray
import qs.Modules.SysTray.Widgets import qs.Modules.SysTray.Widgets
import qs.Modules.Network
Item { Item {
id: root id: root
-1
View File
@@ -6,7 +6,6 @@ import QtQuick
import qs.Config import qs.Config
import qs.Components import qs.Components
import qs.Modules.WSOverview import qs.Modules.WSOverview
import qs.Modules.Network
import qs.Modules.SysTray.Popouts import qs.Modules.SysTray.Popouts
import qs.Modules.Updates import qs.Modules.Updates
+41 -31
View File
@@ -4,66 +4,76 @@ import qs.Components
import qs.Helpers import qs.Helpers
import qs.Config import qs.Config
CustomClippingRect { RowLayout {
id: root id: root
required property var lock required property var lock
implicitHeight: layout.implicitHeight spacing: Appearance.spacing.large * 2
implicitWidth: layout.implicitWidth
radius: Appearance.rounding.large
RowLayout { ColumnLayout {
id: layout Layout.fillWidth: true
spacing: Appearance.spacing.normal
anchors.fill: parent CustomRect {
spacing: Appearance.spacing.large * 2
ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Appearance.spacing.normal color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: weather.implicitHeight
radius: Appearance.rounding.small
topLeftRadius: Appearance.rounding.large
WeatherInfo { WeatherInfo {
id: weather id: weather
Layout.fillWidth: true
rootHeight: root.height rootHeight: root.height
} }
}
CustomRect {
Layout.fillWidth: true
color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: resources.implicitHeight
radius: Appearance.rounding.small
Resources { Resources {
id: resources id: resources
Layout.fillWidth: true
} }
}
CustomClippingRect {
Layout.fillHeight: true
Layout.fillWidth: true
bottomLeftRadius: Appearance.rounding.large
color: DynamicColors.tPalette.m3surfaceContainer
radius: Appearance.rounding.small
Media { Media {
id: media id: media
Layout.fillHeight: true
Layout.fillWidth: true
lock: root.lock lock: root.lock
} }
} }
}
Center { Center {
lock: root.lock lock: root.lock
} }
ColumnLayout { ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
CustomRect {
Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
spacing: Appearance.spacing.normal bottomRightRadius: Appearance.rounding.large
color: DynamicColors.tPalette.m3surfaceContainer
radius: Appearance.rounding.small
topRightRadius: Appearance.rounding.large
CustomRect { NotifDock {
Layout.fillHeight: true lock: root.lock
Layout.fillWidth: true
bottomRightRadius: Appearance.rounding.large
color: DynamicColors.tPalette.m3surfaceContainer
radius: Appearance.rounding.small
topRightRadius: Appearance.rounding.large
NotifDock {
lock: root.lock
}
} }
} }
} }
+145 -54
View File
@@ -1,110 +1,201 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import qs.Modules
import ZShell.Components
import qs.Components import qs.Components
import qs.Config
import qs.Helpers import qs.Helpers
import qs.Config
CustomClippingRect { Item {
id: root id: root
required property var lock required property var lock
color: DynamicColors.tPalette.m3surfaceContainer anchors.fill: parent
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
radius: Appearance.rounding.small
FadeImage {
id: image
Image {
anchors.fill: parent anchors.fill: parent
asynchronous: true asynchronous: true
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
layer.enabled: true layer.enabled: true
opacity: status === Image.Ready ? 1 : 0 opacity: status === Image.Ready ? 1 : 0
source: Players.getArtUrl(Players.active) source: Players.active?.trackArtUrl ?? ""
sourceSize: { sourceSize.height: height
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; sourceSize.width: width
return Qt.size(width * dpr, height * dpr);
}
layer.effect: OpacityMask {
maskSource: mask
}
Behavior on opacity { Behavior on opacity {
Anim { Anim {
type: Anim.StandardExtraLarge duration: Appearance.anim.durations.extraLarge
} }
} }
}
CustomRect { Rectangle {
anchors.fill: parent id: mask
color: DynamicColors.palette.m3surface
opacity: 0.7 anchors.fill: parent
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
color: Qt.rgba(0, 0, 0, 0.5)
position: 0
}
GradientStop {
color: Qt.rgba(0, 0, 0, 0.2)
position: 0.4
}
GradientStop {
color: Qt.rgba(0, 0, 0, 0)
position: 0.8
}
} }
} }
ColumnLayout { ColumnLayout {
id: layout id: layout
anchors.left: parent.left anchors.fill: parent
anchors.margins: Appearance.padding.extraLarge anchors.margins: Appearance.padding.large
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter CustomText {
spacing: Appearance.spacing.extraSmall Layout.bottomMargin: Appearance.spacing.larger
Layout.topMargin: Appearance.padding.large
color: DynamicColors.palette.m3onSurfaceVariant
font.family: Appearance.font.family.mono
font.weight: 500
text: qsTr("Now playing")
}
CustomText { CustomText {
Layout.fillWidth: true Layout.fillWidth: true
animate: true animate: true
color: DynamicColors.palette.m3primary color: DynamicColors.palette.m3primary
elide: Text.ElideRight elide: Text.ElideRight
font.pointSize: Appearance.font.size.medium font.family: Appearance.font.family.mono
font.pointSize: Appearance.font.size.large
font.weight: 600
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
text: (Players.active?.trackTitle ?? qsTr("Nothing playing")) || qsTr("Unknown track") text: Players.active?.trackArtist ?? qsTr("No media")
} }
CustomText { CustomText {
Layout.fillWidth: true Layout.fillWidth: true
animate: true animate: true
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight elide: Text.ElideRight
font.pointSize: Appearance.font.size.small font.family: Appearance.font.family.mono
font.pointSize: Appearance.font.size.larger
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
text: (Players.active?.trackArtist ?? qsTr("Try playing some music!")) || qsTr("Unknown artist") text: Players.active?.trackTitle ?? qsTr("No media")
} }
ButtonRow { RowLayout {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Appearance.spacing.small Layout.bottomMargin: Appearance.padding.large
spacing: Appearance.spacing.extraSmall Layout.topMargin: Appearance.spacing.large * 1.2
spacing: Appearance.spacing.large
PlayerControl {
function onClicked(): void {
if (Players.active?.canGoPrevious)
Players.active.previous();
}
IconButton {
enabled: Players.active?.canGoPrevious
icon: "skip_previous" icon: "skip_previous"
isRound: true
shapeMorph: true
type: IconButton.Tonal
onClicked: Players.active?.previous()
} }
IconButton { PlayerControl {
checked: Players.active?.isPlaying ?? false function onClicked(): void {
enabled: Players.active?.canTogglePlaying if (Players.active?.canTogglePlaying)
icon: Players.active?.isPlaying ? "pause" : "play_arrow" Players.active.togglePlaying();
implicitWidth: implicitHeight + Appearance.padding.largeIncreased * 2 }
isRound: true
shapeMorph: true
onClicked: Players.active?.togglePlaying() active: Players.active?.isPlaying ?? false
animate: true
icon: active ? "pause" : "play_arrow"
level: active ? 2 : 1
set_color: "Primary"
} }
IconButton { PlayerControl {
enabled: Players.active?.canGoNext function onClicked(): void {
if (Players.active?.canGoNext)
Players.active.next();
}
icon: "skip_next" icon: "skip_next"
isRound: true }
shapeMorph: true }
type: IconButton.Tonal }
onClicked: Players.active?.next() component PlayerControl: CustomRect {
id: control
property bool active
property alias animate: controlIcon.animate
property alias icon: controlIcon.text
property int level: 1
property string set_color: "Secondary"
function onClicked(): void {
}
Layout.preferredWidth: implicitWidth + (controlState.pressed ? Appearance.padding.normal * 2 : active ? Appearance.padding.small * 2 : 0)
color: active ? DynamicColors.palette[`m3${set_color.toLowerCase()}`] : DynamicColors.palette[`m3${set_color.toLowerCase()}Container`]
implicitHeight: controlIcon.implicitHeight + Appearance.padding.normal * 2
implicitWidth: controlIcon.implicitWidth + Appearance.padding.large * 2
radius: active || controlState.pressed ? Appearance.rounding.small : Appearance.rounding.normal
Behavior on Layout.preferredWidth {
Anim {
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
Behavior on radius {
Anim {
duration: Appearance.anim.durations.expressiveFastSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
}
}
Elevation {
anchors.fill: parent
level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level
radius: parent.radius
z: -1
}
StateLayer {
id: controlState
color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`]
onClicked: {
control.onClicked();
}
}
MaterialIcon {
id: controlIcon
anchors.centerIn: parent
color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`]
fill: control.active ? 1 : 0
font.pointSize: Appearance.font.size.large
Behavior on fill {
Anim {
}
} }
} }
} }
+47 -48
View File
@@ -1,82 +1,81 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import M3Shapes
import ZShell.Services import ZShell.Services
import qs.Components import qs.Components
import qs.Helpers
import qs.Config import qs.Config
import qs.Effects
CustomRect { GridLayout {
id: root id: root
readonly property real fontScale: { anchors.left: parent.left
const diff = width / 391 - 1; // 391 is the width at 1080 height screen anchors.margins: Appearance.padding.large
return 1 + Math.pow(Math.abs(diff), 0.8) * Math.sign(diff); anchors.right: parent.right
} columnSpacing: Appearance.spacing.large
columns: 2
color: DynamicColors.tPalette.m3surfaceContainer rowSpacing: Appearance.spacing.large
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 rows: 1
radius: Appearance.rounding.small
ServiceRef {
service: Cpu
}
ServiceRef { ServiceRef {
service: Memory service: Memory
} }
ServiceRef { ServiceRef {
service: Storage service: Cpu
} }
RowLayout { Resource {
id: layout Layout.bottomMargin: Appearance.padding.large
Layout.topMargin: Appearance.padding.large
anchors.fill: parent fgColor: DynamicColors.palette.m3primary
anchors.margins: Appearance.padding.large icon: "memory"
spacing: Appearance.spacing.large value: Cpu.percentage
Resource {
id: cpu
fgColor: DynamicColors.palette.m3primary
icon: "memory"
value: Cpu.percentage
}
Resource {
fgColor: DynamicColors.palette.m3tertiary
icon: "memory_alt"
value: Memory.percentage
}
Resource {
fgColor: DynamicColors.palette.m3secondary
icon: "hard_disk"
value: Storage.percentage
}
} }
component Resource: CircularProgress { Resource {
Layout.bottomMargin: Appearance.padding.large
Layout.topMargin: Appearance.padding.large
fgColor: DynamicColors.palette.m3secondary
icon: "memory_alt"
value: Memory.percentage
}
component Resource: CustomRect {
id: res id: res
required property color fgColor
required property string icon required property string icon
required property real value
Layout.fillWidth: true Layout.fillWidth: true
implicitSize: width color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
implicitHeight: width
radius: Appearance.rounding.large
Behavior on clampedVal { Behavior on value {
Anim { Anim {
duration: Appearance.anim.durations.large
} }
} }
CircularProgress {
id: circ
anchors.fill: parent
bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3)
fgColor: res.fgColor
padding: Appearance.padding.large * 3
strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal
value: res.value
}
MaterialIcon { MaterialIcon {
id: icon
anchors.centerIn: parent anchors.centerIn: parent
color: res.fgColor color: res.fgColor
font.pointSize: Appearance.font.size.extraLarge font.pointSize: (circ.arcRadius * 0.7) || 1
font.weight: 600
text: res.icon text: res.icon
} }
} }
-62
View File
@@ -1,62 +0,0 @@
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Config
import qs.Helpers
ColumnLayout {
id: root
required property int rootHeight
spacing: Appearance.spacing.extraSmall
CustomText {
Layout.alignment: Qt.AlignHCenter
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
text: Weather.description
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Appearance.spacing.small
CustomText {
id: temp
animate: true
color: DynamicColors.palette.m3primary
font.pointSize: Appearance.font.size.large
text: Weather.temp
}
MaterialIcon {
animate: true
color: DynamicColors.palette.m3secondary
text: Weather.icon
}
}
CustomText {
Layout.alignment: Qt.AlignHCenter
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
text: qsTr("Feels like %1").arg(Weather.temp)
visible: root.rootHeight > 550
}
CustomText {
Layout.alignment: Qt.AlignHCenter
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.medium
text: {
const today = Weather.forecast[0];
return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC));
}
visible: root.rootHeight > 550
}
}
-103
View File
@@ -1,103 +0,0 @@
import QtQuick
import QtQuick.Layouts
import M3Shapes
import ZShell
import qs.Components
import qs.Helpers
import qs.Config
CustomRect {
id: root
color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
implicitHeight: header.anchors.margins + header.implicitHeight + Appearance.spacing.small + layout.implicitHeight + layout.anchors.bottomMargin
radius: Appearance.rounding.small
RowLayout {
id: header
anchors.left: parent.left
anchors.margins: Appearance.padding.largeIncreased
anchors.top: parent.top
spacing: Appearance.spacing.small
MaterialIcon {
Layout.topMargin: Math.round(fontInfo.pointSize * 0.12)
font.pointSize: Appearance.font.size.medium
text: "schedule"
}
CustomText {
id: title
font.pointSize: Appearance.font.size.medium
text: qsTr("Hourly forecast")
}
}
VerticalFadeListView {
id: layout
anchors.bottom: parent.bottom
anchors.bottomMargin: Appearance.padding.largeIncreased
anchors.left: parent.left
anchors.margins: Appearance.padding.large
anchors.right: parent.right
implicitHeight: contentItem.childrenRect.height
model: Weather.hourlyForecast
orientation: VerticalFadeListView.Horizontal
spacing: Appearance.spacing.normal
delegate: ColumnLayout {
id: hour
readonly property var cond: modelData
required property int index
required property var modelData
spacing: Appearance.spacing.extraSmall
MaterialShape {
Layout.alignment: Qt.AlignHCenter
color: Qt.alpha(DynamicColors.palette.m3primary, hour.index === 0 ? 1 : 0)
implicitSize: temp.implicitHeight + Appearance.padding.normal * 2
shape: MaterialShape.Cookie4Sided
Behavior on color {
CAnim {
}
}
CustomText {
id: temp
anchors.centerIn: parent
color: hour.index === 0 ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface
font.pointSize: Appearance.font.size.medium
text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F
}
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: DynamicColors.palette.m3secondary
font.pointSize: Appearance.font.size.extraLarge
text: hour.cond.icon
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: DynamicColors.palette.m3primary
text: hour.cond.precipChance + "%"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Appearance.spacing.extraSmall
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.small
text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00")
}
}
}
}
+150 -34
View File
@@ -1,23 +1,162 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.Modules.Lock.Weather import QtQuick.Layouts
import qs.Config
import qs.Components import qs.Components
import qs.Helpers import qs.Helpers
import qs.Config
CustomRect { ColumnLayout {
id: root id: root
required property int rootHeight required property int rootHeight
readonly property bool showForecast: rootHeight >= 700
color: DynamicColors.tPalette.m3surfaceContainer anchors.left: parent.left
implicitHeight: { anchors.margins: Appearance.padding.large * 2
const base = brief.implicitHeight + brief.anchors.topMargin; anchors.right: parent.right
if (showForecast) spacing: Appearance.spacing.small
return base + Appearance.spacing.large + forecast.implicitHeight + forecast.anchors.margins;
return base + brief.anchors.topMargin; Loader {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: -Appearance.padding.large
Layout.topMargin: Appearance.padding.large * 2
active: root.rootHeight > 610
visible: active
sourceComponent: CustomText {
color: DynamicColors.palette.m3primary
font.pointSize: Appearance.font.size.extraLarge
font.weight: 500
text: qsTr("Weather")
}
}
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.large
MaterialIcon {
animate: true
color: DynamicColors.palette.m3secondary
font.pointSize: Appearance.font.size.extraLarge * 2.5
text: Weather.icon
}
ColumnLayout {
spacing: Appearance.spacing.small
CustomText {
Layout.fillWidth: true
animate: true
color: DynamicColors.palette.m3secondary
elide: Text.ElideRight
font.pointSize: Appearance.font.size.large
font.weight: 500
text: Weather.description
}
CustomText {
Layout.fillWidth: true
animate: true
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font.pointSize: Appearance.font.size.normal
text: qsTr("Humidity: %1%").arg(Weather.humidity)
}
}
Loader {
Layout.rightMargin: Appearance.padding.smaller
active: root.width > 400
visible: active
sourceComponent: ColumnLayout {
spacing: Appearance.spacing.small
CustomText {
Layout.fillWidth: true
animate: true
color: DynamicColors.palette.m3primary
elide: Text.ElideLeft
font.pointSize: Appearance.font.size.extraLarge
font.weight: 500
horizontalAlignment: Text.AlignRight
text: Weather.temp
}
CustomText {
Layout.fillWidth: true
animate: true
color: DynamicColors.palette.m3outline
elide: Text.ElideLeft
font.pointSize: Appearance.font.size.smaller
horizontalAlignment: Text.AlignRight
text: qsTr("Feels like: %1").arg(Weather.feelsLike)
}
}
}
}
Loader {
id: forecastLoader
Layout.bottomMargin: Appearance.padding.large * 2
Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.smaller
active: root.rootHeight > 820
visible: active
sourceComponent: RowLayout {
spacing: Appearance.spacing.large
Repeater {
model: {
const forecast = Weather.hourlyForecast;
const count = root.width < 320 ? 3 : root.width < 400 ? 4 : 5;
if (!forecast)
return Array.from({
length: count
}, () => null);
return forecast.slice(0, count);
}
ColumnLayout {
id: forecastHour
required property var modelData
Layout.fillWidth: true
spacing: Appearance.spacing.small
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3outline
font.pointSize: Appearance.font.size.larger
horizontalAlignment: Text.AlignHCenter
text: {
const hour = forecastHour.modelData?.hour ?? 0;
return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`;
}
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
font.pointSize: Appearance.font.size.extraLarge * 1.5
font.weight: 500
text: forecastHour.modelData?.icon ?? "cloud_alert"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: DynamicColors.palette.m3secondary
font.pointSize: Appearance.font.size.larger
text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C`
}
}
}
}
} }
radius: Appearance.rounding.small
Timer { Timer {
interval: 900000 // 15 minutes interval: 900000 // 15 minutes
@@ -27,27 +166,4 @@ CustomRect {
onTriggered: Weather.reload() onTriggered: Weather.reload()
} }
BriefInfo {
id: brief
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Appearance.padding.extraLarge
rootHeight: root.rootHeight
}
Loader {
id: forecast
active: root.showForecast
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: Appearance.padding.large
anchors.right: parent.right
asynchronous: true
sourceComponent: Forecast {
}
}
} }
-36
View File
@@ -1,36 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Networking
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Config
import qs.Modules
import qs.Helpers as Helpers
Item {
id: root
required property var wrapper
ColumnLayout {
id: layout
spacing: 8
Repeater {
model: Helpers.Network.devices
CustomRadioButton {
id: network
required property NetworkDevice modelData
checked: Helpers.Network.activeDevice?.name === modelData.name
text: modelData.description
visible: modelData.name !== "lo"
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Modules
Item {
id: root
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: layout.implicitWidth
RowLayout {
id: layout
anchors.bottom: parent.bottom
anchors.top: parent.top
MaterialIcon {
Layout.alignment: Qt.AlignVCenter
text: "android_wifi_4_bar"
}
}
}
+8 -6
View File
@@ -102,13 +102,15 @@ Item {
to: 1.0 to: 1.0
value: root.brightness value: root.brightness
onMoved: { onPressedChanged: {
if (Config.osd.allMonBrightness) { if (!pressed) {
for (const mon of Brightness.monitors) { if (Config.osd.allMonBrightness) {
mon.setBrightness(value); for (const mon of Brightness.monitors) {
mon.setBrightness(value);
}
} else {
root.monitor?.setBrightness(value);
} }
} else {
root.monitor?.setBrightness(value);
} }
} }
} }
+3 -3
View File
@@ -28,7 +28,9 @@ Scope {
visible: false visible: false
Connections { Connections {
function onShouldShowChanged(): void { target: root
onShouldShowChanged: {
if (root.shouldShow) { if (root.shouldShow) {
panelWindow.visible = true; panelWindow.visible = true;
openAnim.start(); openAnim.start();
@@ -36,8 +38,6 @@ Scope {
closeAnim.start(); closeAnim.start();
} }
} }
target: root
} }
Anim { Anim {
-25
View File
@@ -32,31 +32,6 @@ Item {
implicitHeight: width implicitHeight: width
radius: Appearance.rounding.large radius: Appearance.rounding.large
Loader {
active: opacity > 0
anchors.centerIn: parent
opacity: img.status === Image.Ready ? 0 : 1
Behavior on opacity {
Anim {
}
}
sourceComponent: CustomRect {
color: DynamicColors.palette.m3primaryContainer
implicitHeight: loadingIndicator.implicitSize + Appearance.padding.large * 2
implicitWidth: loadingIndicator.implicitSize + Appearance.padding.large * 2
radius: Appearance.rounding.full
LoadingIndicator {
id: loadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: Math.min(imgWrapper.width, imgWrapper.height) * 0.3
}
}
}
Image { Image {
id: img id: img
+116 -162
View File
@@ -148,200 +148,154 @@ VerticalFadeFlickable {
} }
} }
Column { ListView {
id: resultList id: resultList
Layout.fillWidth: true Layout.fillWidth: true
cacheBuffer: 10000
implicitHeight: contentHeight
interactive: false
spacing: Appearance.padding.large spacing: Appearance.padding.large
add: Transition { delegate: ColumnLayout {
Anim { id: group
from: 0
property: "opacity"
to: 1
type: Anim.DefaultEffects
}
}
move: Transition {
Anim {
properties: "x,y"
}
Anim { required property int index
property: "opacity" required property var modelData
to: 1
type: Anim.DefaultEffects
}
}
Repeater { spacing: Appearance.spacing.small
model: ScriptModel { width: resultList.width
objectProp: "pageIdx"
values: root.groups RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Appearance.padding.small
spacing: Appearance.spacing.small
MaterialIcon {
color: DynamicColors.palette.m3primary
font.pointSize: Appearance.font.size.large
text: group.modelData.icon
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3primary
elide: Text.ElideRight
font.pointSize: Appearance.font.size.large
text: group.modelData.page
}
} }
ColumnLayout { ColumnLayout {
id: group Layout.fillWidth: true
spacing: Appearance.spacing.extraSmall / 2
required property int index Repeater {
required property var modelData model: group.modelData.entries
spacing: Appearance.spacing.small CustomRect {
width: resultList.width id: result
RowLayout { required property int index
Layout.fillWidth: true readonly property bool isFirst: index === 0
Layout.leftMargin: Appearance.padding.small readonly property bool isLast: index === group.modelData.entries.length - 1
spacing: Appearance.spacing.small required property var modelData
MaterialIcon {
color: DynamicColors.palette.m3primary
fill: 1
font.pointSize: Appearance.font.size.large
text: group.modelData.icon
}
CustomText {
Layout.fillWidth: true Layout.fillWidth: true
color: DynamicColors.palette.m3secondary bottomLeftRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall
elide: Text.ElideRight bottomRightRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall
font.pointSize: Appearance.font.size.large color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
text: group.modelData.page implicitHeight: {
} const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2;
} return h % 2 === 0 ? h : h + 1;
Column {
id: cardList
Layout.fillWidth: true
spacing: Appearance.spacing.extraSmall / 2
add: Transition {
Anim {
from: 0
property: "opacity"
to: 1
type: Anim.DefaultEffects
} }
} topLeftRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall
move: Transition { topRightRadius: layer.pressed ? Appearance.rounding.medium : isFirst ? Appearance.rounding.large : Appearance.rounding.extraSmall
Anim {
properties: "x,y" RadiusBehavior on bottomLeftRadius {
}
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
} }
Anim { ColumnLayout {
property: "opacity" id: resultLayout
to: 1
type: Anim.DefaultEffects
}
}
Repeater { anchors.fill: parent
model: ScriptModel { anchors.margins: Appearance.padding.large
objectProp: "anchor" anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large
values: group.modelData.entries spacing: Appearance.spacing.small / 2
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: {
const labels = result.modelData.crumbLabels.slice(1);
const section = result.modelData.section;
const parts = section && section !== labels[labels.length - 1] ? labels.concat(section) : labels;
return parts.join(" \u203a ");
}
visible: text.length > 0
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3onSurface
elide: Text.ElideRight
font.pointSize: Appearance.font.size.medium
text: SettingsSearcher.highlight(result.modelData.title, root.search, DynamicColors.palette.m3primary)
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3outline
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: SettingsSearcher.highlight(result.modelData.subtext, root.search, DynamicColors.palette.m3primary)
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
visible: result.modelData.subtext.length > 0
}
} }
CustomRect { StateLayer {
id: result id: layer
required property int index z: 1
readonly property bool isFirst: index === 0
readonly property bool isLast: index === group.modelData.entries.length - 1
required property var modelData
bottomLeftRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall onClicked: {
bottomRightRadius: layer.pressed ? Appearance.rounding.medium : isLast ? Appearance.rounding.large : Appearance.rounding.extraSmall root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2)
implicitHeight: {
const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2;
return h % 2 === 0 ? h : h + 1;
} }
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 { CustomSwitch {
} id: toggle
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
ColumnLayout { anchors.right: parent.right
id: resultLayout anchors.rightMargin: Appearance.padding.large
anchors.verticalCenter: parent.verticalCenter
cLayer: 3
checked: result.modelData.toggleValue
scale: 0.85
transformOrigin: Item.Right
visible: result.modelData.togglePath
z: 2
anchors.fill: parent onToggled: result.modelData.setToggle(checked)
anchors.margins: Appearance.padding.large
anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large
spacing: Appearance.spacing.small / 2
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: {
const labels = result.modelData.crumbLabels.slice(1);
const section = result.modelData.section;
const parts = section && section !== labels[labels.length - 1] ? labels.concat(section) : labels;
return parts.join(" \u203a ");
}
visible: text.length > 0
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3onSurface
elide: Text.ElideRight
font.pointSize: Appearance.font.size.medium
text: SettingsSearcher.highlight(result.modelData.title, root.search, DynamicColors.palette.m3primary)
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3outline
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: SettingsSearcher.highlight(result.modelData.subtext, root.search, DynamicColors.palette.m3primary)
textFormat: text.includes("<font") ? Text.StyledText : Text.PlainText
visible: result.modelData.subtext.length > 0
}
}
StateLayer {
id: layer
z: 1
onClicked: {
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
}
}
CustomSwitch {
id: toggle
anchors.right: parent.right
anchors.rightMargin: Appearance.padding.large
anchors.verticalCenter: parent.verticalCenter
cLayer: 3
checked: result.modelData.toggleValue
scale: 0.85
transformOrigin: Item.Right
visible: result.modelData.togglePath
z: 2
onToggled: result.modelData.setToggle(checked)
}
} }
} }
} }
} }
} }
model: ScriptModel {
objectProp: "pageIdx"
values: root.groups
}
} }
CustomText { CustomText {
+1 -10
View File
@@ -48,6 +48,7 @@ PageBase {
Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing
active: root.clockFormats.find(item => item.value === Config.general.dateFormat) active: root.clockFormats.find(item => item.value === Config.general.dateFormat)
first: true first: true
last: true
menuItems: root.clockFormats menuItems: root.clockFormats
settingAnchor: "bar-clock-format" settingAnchor: "bar-clock-format"
subtext: qsTr("Change how time is displayed in the widget") subtext: qsTr("Change how time is displayed in the widget")
@@ -57,15 +58,5 @@ PageBase {
Config.general.dateFormat = item.value; Config.general.dateFormat = item.value;
} }
} }
ToggleRow {
checked: Config.services.useTwelveHourClock
last: true
settingAnchor: "bar-clock-twelve-hour"
subtext: qsTr("Format timestamps for twelve or twenty-four hours in UI")
text: qsTr("Twelve hour clock")
onToggled: Config.services.useTwelveHourClock = checked
}
} }
} }
@@ -39,13 +39,14 @@ PageBase {
onToggled: Config.bar.tray.showMicrophone = checked onToggled: Config.bar.tray.showMicrophone = checked
} }
ToggleRow {
checked: Config.bar.tray.showNetwork
text: qsTr("Network")
onToggled: Config.bar.tray.showNetwork = checked
}
//////// FOR LATER: //////// FOR LATER:
// ToggleRow {
// checked: Config.bar.tray.showNetwork
// text: qsTr("Network")
//
// onToggled: Config.bar.tray.showNetwork = checked
// }
// //
// ToggleRow { // ToggleRow {
// checked: Config.bar.tray.showWifi // checked: Config.bar.tray.showWifi
@@ -94,5 +95,15 @@ PageBase {
onToggled: Config.bar.popouts.upower = checked onToggled: Config.bar.popouts.upower = checked
} }
ToggleRow {
checked: Config.bar.popouts.network
last: true
settingAnchor: "bar-status-network-popout"
subtext: qsTr("Show a details popout when hovering the network icon")
text: qsTr("Network popout on hover")
onToggled: Config.bar.popouts.network = checked
}
} }
} }
+223
View File
@@ -0,0 +1,223 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Components
import qs.Config
import qs.Helpers
CustomClippingRect {
id: root
required property var wrapper
anchors.horizontalCenter: parent.horizontalCenter
color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: networkPopContent.height + networks.implicitHeight + networkPopContent.anchors.margins + networks.anchors.margins * 2
implicitWidth: 500 + 8 * 2
radius: (20 - Appearance.padding.small) * Appearance.rounding.scale
Component.onCompleted: Network.active = true
Component.onDestruction: Network.active = false
ColumnLayout {
id: networkPopContent
anchors.left: parent.left
anchors.margins: Appearance.padding.large
anchors.right: parent.right
anchors.top: parent.top
CustomText {
Layout.preferredHeight: visible ? implicitHeight : 0
Layout.rightMargin: Appearance.padding.extraSmall
font.pointSize: Appearance.font.size.large
text: qsTr("Wifi")
}
Toggle {
Layout.preferredHeight: visible ? implicitHeight : 0
checked: Network.wifiEnabled
label: qsTr("WiFi enabled")
toggle.onToggled: Network.setWifi(checked)
}
}
ColumnLayout {
id: networks
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: Appearance.padding.normal
anchors.right: parent.right
anchors.top: networkPopContent.bottom
CustomText {
Layout.leftMargin: Appearance.padding.normal
text: qsTr("Connected")
visible: Network.connectedNetworks.length > 0
}
Repeater {
visible: Network.connectedNetworks.length > 0
model: ScriptModel {
values: [...Network.connectedNetworks].sort((a, b) => {
return b.signalStrength - a.signalStrength;
})
}
NetworkItem {
}
}
Spacer {
visible: Network.connectedNetworks.length > 0
}
CustomText {
Layout.leftMargin: Appearance.padding.normal
text: qsTr("Known networks")
visible: Network.knownNetworks.length > 0
}
Repeater {
visible: Network.knownNetworks.length > 0
model: ScriptModel {
values: [...Network.knownNetworks].sort((a, b) => {
return b.signalStrength - a.signalStrength;
})
}
NetworkItem {
}
}
Spacer {
visible: Network.knownNetworks.length > 0
}
CustomText {
Layout.leftMargin: Appearance.padding.normal
text: qsTr("Networks")
}
CustomText {
Layout.leftMargin: Appearance.padding.normal
Layout.preferredHeight: visible ? implicitHeight : 0
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.extraSmall
text: qsTr("%1 networks available").arg(Network.networks.length) // qmllint disable missing-property
}
Repeater {
id: networkRepeater
model: ScriptModel {
values: [...Network.unknownNetworks].sort((a, b) => {
return b.signalStrength - a.signalStrength;
})
}
NetworkItem {
}
}
}
component NetworkItem: CustomRect {
id: knownNetworkItem
required property var modelData
Layout.fillWidth: true
Layout.preferredHeight: visible ? knownNetworkRow.implicitHeight + Appearance.padding.smaller * 2 : 0
Layout.rightMargin: Appearance.padding.extraSmall
radius: Appearance.rounding.small
RowLayout {
id: knownNetworkRow
anchors.fill: parent
anchors.leftMargin: Appearance.padding.larger
anchors.rightMargin: Appearance.padding.normal
opacity: 0
scale: 0.7
spacing: Appearance.spacing.small
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {
}
}
Component.onCompleted: {
opacity = 1;
scale = 1;
}
MaterialIcon {
color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant
text: Icons.getNetworkIcon(knownNetworkItem.modelData.signalStrength * 100, Network.isSecure(knownNetworkItem.modelData.security))
}
CustomText {
Layout.fillWidth: true
Layout.leftMargin: Appearance.spacing.extraSmall
Layout.rightMargin: Appearance.spacing.extraSmall
color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface
elide: Text.ElideRight
text: knownNetworkItem.modelData.name
}
CustomRect {
color: Qt.alpha(DynamicColors.palette.m3primary, knownNetworkItem.modelData.active ? 1 : 0)
implicitHeight: knownWirelessConnectIcon.implicitHeight + Appearance.padding.extraSmall
implicitWidth: implicitHeight
radius: Appearance.rounding.full
// CircularIndicator {
// anchors.fill: parent
// running: knownNetworkItem.loading
// }
}
}
StateLayer {
color: knownNetworkItem.modelData.active ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface
onClicked: {
NetworkPassword.requestOpen();
}
}
}
component Spacer: Item {
id: spacer
Layout.preferredHeight: networkRepeater.count > 0 ? Appearance.spacing.extraSmall : 0
}
component Toggle: RowLayout {
property alias checked: toggle.checked
required property string label
property alias toggle: toggle
Layout.fillWidth: true
Layout.rightMargin: Appearance.padding.extraSmall
spacing: Appearance.spacing.small
CustomText {
Layout.fillWidth: true
text: parent.label
}
CustomSwitch {
id: toggle
}
}
}
+9
View File
@@ -52,6 +52,11 @@ RowLayout {
id: "audio", id: "audio",
item: child item: child
}; };
if (child.objectName === "networkWidget" && Config.bar.popouts.network)
return {
id: "network",
item: child
};
if (child.objectName === "upowerWidget" && Config.bar.popouts.upower) if (child.objectName === "upowerWidget" && Config.bar.popouts.upower)
return { return {
id: "upower", id: "upower",
@@ -146,6 +151,10 @@ RowLayout {
} }
} }
NetworkWidget {
objectName: "networkWidget"
}
UPowerWidget { UPowerWidget {
Layout.fillHeight: true Layout.fillHeight: true
objectName: "upowerWidget" objectName: "upowerWidget"
+24
View File
@@ -0,0 +1,24 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Daemons
import qs.Modules
import qs.Config
import qs.Components
RowLayout {
id: root
// property color barColor: DynamicColors.palette.m3primary
property color textColor: DynamicColors.palette.m3onSurface
MaterialIcon {
Layout.alignment: Qt.AlignVCenter
animate: true
color: root.textColor // Network.connected ? root.textColor : DynamicColors.palette.m3error
fill: 1
font.pointSize: Appearance.font.size.larger
text: "android_wifi_4_bar"
}
}
+4 -4
View File
@@ -12,10 +12,10 @@ Item {
id: root id: root
property bool completed property bool completed
property real cropHeight: displayData?.height ?? 1.0 property real cropHeight: displayData.height ?? 1.0
property real cropWidth: displayData?.width ?? 1.0 property real cropWidth: displayData.width ?? 1.0
property real cropX: displayData?.x ?? 0.0 property real cropX: displayData.x ?? 0.0
property real cropY: displayData?.y ?? 0.0 property real cropY: displayData.y ?? 0.0
property WallpaperImage current property WallpaperImage current
readonly property var displayData: Wallpapers.getCrop(screen.name) readonly property var displayData: Wallpapers.getCrop(screen.name)
required property ShellScreen screen required property ShellScreen screen
-19
View File
@@ -1,19 +0,0 @@
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()
+5 -27
View File
@@ -21,39 +21,17 @@ source = "vcs"
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
"cli/src/zshell/assets/**", "src/zshell/assets/**",
] ]
[tool.hatch.build.targets.wheel]
packages = ["cli/src/zshell"]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
only-include = [ only-include = [
"cli/src", "src",
] ]
[tool.ruff] [tool.ruff]
line-length = 80 line-length = 120
[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] [tool.pytest.ini_options]
testpaths = ["cli/tests"] testpaths = ["tests"]
pythonpath = ["cli/src"] pythonpath = ["src"]
+33 -51
View File
@@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
import typer import typer
from typer._completion_shared import install, _get_shell_name
from typer._completion_classes import completion_init from typer._completion_classes import completion_init
from typer._completion_shared import _get_shell_name, install from zshell.subcommands import shell, scheme, screenshot, wallpaper, record
from zshell.subcommands import record, scheme, screenshot, shell, wallpaper
app = typer.Typer(name="zshell-cli", add_completion=False) app = typer.Typer(name="zshell-cli", add_completion=False)
@@ -20,56 +18,40 @@ app.add_typer(record.app, name="record")
def _completion_installed() -> bool: def _completion_installed() -> bool:
shell = _get_shell_name() shell = _get_shell_name()
match shell: match shell:
case "zsh": case "zsh":
return (Path.home() / ".zfunc" / "_zshell-cli").exists() return (Path.home() / ".zfunc" / "_zshell-cli").exists()
case "bash": case "bash":
return ( return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists()
Path.home() / ".bash_completions" / "zshell-cli.sh" case "fish":
).exists() return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists()
case "fish": return False
return (
Path.home()
/ ".config"
/ "fish"
/ "completions"
/ "zshell-cli.fish"
).exists()
return False
def _install_completion() -> None: def _install_completion() -> None:
if _completion_installed(): if _completion_installed():
print("zshell-cli: Shell completion already installed.") print("zshell-cli: Shell completion already installed.")
sys.exit(0) sys.exit(0)
shell = _get_shell_name() shell = _get_shell_name()
if shell is None: if shell is None:
print("zshell-cli: Unable to detect shell type.", file=sys.stderr) print("zshell-cli: Unable to detect shell type.", file=sys.stderr)
sys.exit(1) sys.exit(1)
try: try:
_, path = install(prog_name="zshell-cli") _, path = install(prog_name="zshell-cli")
print(f"zshell-cli: Shell completion installed ({shell}: {path})") print(f"zshell-cli: Shell completion installed ({shell}: {path})")
print( print("zshell-cli: Restart your shell or source the file to enable tab-completion.")
"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)
except Exception as e: 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: def main() -> None:
if "--install-autocomplete" in sys.argv: if "--install-autocomplete" in sys.argv:
_install_completion() _install_completion()
return return
if "_ZSHELL_CLI_COMPLETE" in os.environ: if "_ZSHELL_CLI_COMPLETE" in os.environ:
completion_init() completion_init()
if sys.stdout.isatty() and not _completion_installed(): if sys.stdout.isatty() and not _completion_installed():
print( print("zshell-cli: Tip: run with --install-autocomplete for tab completion.", file=sys.stderr)
"zshell-cli: Tip: run with --install-autocomplete for tab completion.", app()
file=sys.stderr,
)
app()
+1 -1
View File
@@ -1,4 +1,4 @@
from zshell import main from zshell import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+135 -195
View File
@@ -1,9 +1,9 @@
import contextlib
import json
import os import os
import json
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional
import typer import typer
@@ -18,253 +18,193 @@ TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4" REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt" NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv( RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings"))
"ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")
)
def _read_extra_args() -> list[str]: def _read_extra_args() -> list[str]:
try: try:
if CONFIG.is_file(): if CONFIG.is_file():
data = json.loads(CONFIG.read_text()) data = json.loads(CONFIG.read_text())
return data.get("record", {}).get("extraArgs", []) return data.get("record", {}).get("extraArgs", [])
except Exception: except Exception:
pass pass
return [] return []
def _is_recording() -> bool: def _is_recording() -> bool:
return ( return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
subprocess.run(
["pidof", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def _notify( def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]:
summary: str, args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
body: str = "", if actions:
actions: list | None = None, for action in actions:
timeout: int = 5000, args.extend(["-A", action])
) -> int | None: try:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"] proc = subprocess.run(args, capture_output=True, text=True)
if actions: return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
for action in actions: except Exception:
args.extend(["-A", action]) return None
try:
proc = subprocess.run(args, capture_output=True, text=True)
return (
int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
)
except Exception:
return None
def _close_notification(notif_id: int): def _close_notification(notif_id: int):
subprocess.run( subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
["notify-send", "--close", str(notif_id)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _get_monitors() -> list[dict]: def _get_monitors() -> list[dict]:
try: try:
res = subprocess.run( res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
["hyprctl", "monitors", "-j"], capture_output=True, text=True return json.loads(res.stdout)
) except Exception:
return json.loads(res.stdout) return []
except Exception:
return []
def _focused_monitor_name() -> str | None: def _focused_monitor_name() -> Optional[str]:
for m in _get_monitors(): for m in _get_monitors():
if m.get("focused"): if m.get("focused"):
return m["name"] return m["name"]
return None return None
def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]: def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
region = (x, y, x + w, y + h) region = (x, y, x + w, y + h)
intersecting = [] intersecting = []
for m in _get_monitors(): for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"] mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
if not ( if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh):
region[2] <= mx intersecting.append(m)
or region[0] >= mx + mw return intersecting
or region[3] <= my
or region[1] >= my + mh
):
intersecting.append(m)
return intersecting
def _highest_refresh(monitors: list[dict]) -> float: def _highest_refresh(monitors: list[dict]) -> float:
return max((m["refreshRate"] for m in monitors), default=60.0) return max((m["refreshRate"] for m in monitors), default=60.0)
def _slurp_region() -> str | None: def _slurp_region() -> Optional[str]:
try: try:
return subprocess.check_output( return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip()
["slurp", "-f", "%wx%h+%x+%y"], text=True except subprocess.CalledProcessError:
).strip() return None
except subprocess.CalledProcessError:
return None
def _parse_geometry(geometry: str) -> tuple[int, int, int, int] | None: def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
import re import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry) match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match: if match:
return ( return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
int(match.group(3)), return None
int(match.group(4)),
int(match.group(1)),
int(match.group(2)),
)
return None
def start_recording(region: str | None, sound: bool): def start_recording(region: Optional[str], sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True) STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER] cmd = [RECORDER]
extra_args = _read_extra_args() extra_args = _read_extra_args()
if region: if region:
if region.lower() == "slurp" or not region: if region.lower() == "slurp" or not region:
geometry = _slurp_region() geometry = _slurp_region()
if not geometry: if not geometry:
typer.echo("Region selection cancelled.") typer.echo("Region selection cancelled.")
raise typer.Abort() raise typer.Abort()
else: else:
geometry = region geometry = region
parsed = _parse_geometry(geometry) parsed = _parse_geometry(geometry)
if not parsed: if not parsed:
typer.echo("Invalid geometry format.") typer.echo("Invalid geometry format.")
raise typer.Abort() raise typer.Abort()
x, y, w, h = parsed x, y, w, h = parsed
monitors = _monitors_intersecting_region(x, y, w, h) monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors) framerate = _highest_refresh(monitors)
cmd.extend( cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))])
["-w", "region", "-region", geometry, "-f", str(int(framerate))]
)
else: else:
monitor_name = _focused_monitor_name() monitor_name = _focused_monitor_name()
if not monitor_name: if not monitor_name:
typer.echo("No focused monitor found.") typer.echo("No focused monitor found.")
raise typer.Abort() raise typer.Abort()
monitors = _get_monitors() monitors = _get_monitors()
mon = next((m for m in monitors if m["name"] == monitor_name), None) mon = next((m for m in monitors if m["name"] == monitor_name), None)
rate = int(mon["refreshRate"]) if mon else 60 rate = int(mon["refreshRate"]) if mon else 60
cmd.extend(["-w", monitor_name, "-f", str(rate)]) cmd.extend(["-w", monitor_name, "-f", str(rate)])
if sound: if sound:
cmd.extend(["-a", "default_output"]) cmd.extend(["-a", "default_output"])
cmd.extend(extra_args) cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)]) cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen( subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
cmd,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}") notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None: if notif_id is not None:
NOTIF_ID_FILE.write_text(str(notif_id)) NOTIF_ID_FILE.write_text(str(notif_id))
time.sleep(1) time.sleep(1)
if not _is_recording(): if not _is_recording():
_notify( _notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
"Recording failed", raise typer.Exit(code=1)
"Check gpu-screen-recorder output.",
timeout=5000,
)
raise typer.Exit(code=1)
def stop_recording(clipboard: bool): def stop_recording(clipboard: bool):
subprocess.run( subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
["pkill", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(50): for _ in range(50):
if not _is_recording(): if not _is_recording():
break break
time.sleep(0.1) time.sleep(0.1)
dest_dir = Path(RECORDINGS_DIR) dest_dir = Path(RECORDINGS_DIR)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S") timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
final_path = dest_dir / f"recording_{timestamp}.mp4" final_path = dest_dir / f"recording_{timestamp}.mp4"
if TEMP_RECORDING.exists(): if TEMP_RECORDING.exists():
TEMP_RECORDING.rename(final_path) TEMP_RECORDING.rename(final_path)
if NOTIF_ID_FILE.is_file(): if NOTIF_ID_FILE.is_file():
with contextlib.suppress(Exception): try:
_close_notification(int(NOTIF_ID_FILE.read_text().strip())) _close_notification(int(NOTIF_ID_FILE.read_text().strip()))
NOTIF_ID_FILE.unlink() except Exception:
pass
NOTIF_ID_FILE.unlink()
if clipboard: if clipboard:
subprocess.run( subprocess.run(
["wl-copy", "--type", "text/uri-list", f"file://{final_path}"], ["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
_notify("Recording stopped", f"Saved to {final_path}", timeout=5000) _notify("Recording stopped", f"Saved to {final_path}", timeout=5000)
def toggle_pause(): def toggle_pause():
subprocess.run( subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
["pkill", "-USR2", "-f", RECORDER], typer.echo("Toggled pause.")
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
typer.echo("Toggled pause.")
@app.command() @app.command()
def record( def record(
region: str | None = typer.Option( region: Optional[str] = typer.Option(
None, None,
"--region", "--region",
"-r", "-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.", help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
), ),
sound: bool = typer.Option( sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."),
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."),
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.""" """Start or stop a screen recording with gpu-screen-recorder."""
if pause: if pause:
toggle_pause() toggle_pause()
raise typer.Exit() raise typer.Exit()
if _is_recording(): if _is_recording():
stop_recording(clipboard) stop_recording(clipboard)
else: else:
start_recording(region, sound) start_recording(region, sound)
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,5 +1,4 @@
import subprocess import subprocess
import typer import typer
args = ["qs", "-c", "zshell"] args = ["qs", "-c", "zshell"]
@@ -9,9 +8,9 @@ app = typer.Typer()
@app.command() @app.command()
def start(): def start():
subprocess.run([*args, "ipc", "call", "picker", "open"], check=True) subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["open"], check=True)
@app.command() @app.command()
def start_freeze(): def start_freeze():
subprocess.run([*args, "ipc", "call", "picker", "openFreeze"], check=True) subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["openFreeze"], check=True)
+47 -55
View File
@@ -11,84 +11,76 @@ app = typer.Typer()
@app.command() @app.command()
def kill(): def kill():
result = subprocess.run([*args, "kill"], capture_output=True) result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write("No running instance to kill.\n") sys.stderr.write("No running instance to kill.\n")
sys.exit(1) sys.exit(1)
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
def start_instance(no_daemon: bool = False) -> None: def start_instance(no_daemon: bool = False) -> None:
result = subprocess.run( result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True)
args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True stdout = result.stdout.decode().strip()
) if stdout:
stdout = result.stdout.decode().strip() if "already running" in stdout.lower():
if stdout and "already running" in stdout.lower(): sys.stderr.write(stdout + "\n")
sys.stderr.write(stdout + "\n") sys.exit(1)
sys.exit(1) if result.returncode != 0:
if result.returncode != 0: stderr = result.stderr.decode().strip()
stderr = result.stderr.decode().strip() sys.stderr.write(stderr + "\n")
sys.stderr.write(stderr + "\n") sys.exit(1)
sys.exit(1)
@app.command() @app.command()
def start(no_daemon: bool = False): def start(no_daemon: bool = False):
start_instance(no_daemon) start_instance(no_daemon)
@app.command() @app.command()
def restart(no_daemon: bool = False): 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 deadline = time.monotonic() + 2.5
while time.monotonic() < deadline: while time.monotonic() < deadline:
result = subprocess.run([*args, "kill"], capture_output=True) result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode == 255: if result.returncode == 255:
break break
time.sleep(0.25) time.sleep(0.25)
start_instance(no_daemon=no_daemon) start_instance(no_daemon=no_daemon)
@app.command() @app.command()
def show(): def show():
result = subprocess.run([*args, "ipc", "show"], capture_output=True) result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
sys.stdout.write(result.stdout.decode()) sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
@app.command() @app.command()
def log(): def log():
result = subprocess.run([*args, "log"], capture_output=True) result = subprocess.run(args + ["log"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
sys.stdout.write(result.stdout.decode()) sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
@app.command() @app.command()
def lock(): def lock():
result = subprocess.run( result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True)
[*args, "ipc", "call", "lock", "lock"], capture_output=True if result.returncode != 0:
) sys.stderr.write(result.stderr.decode())
if result.returncode != 0: sys.exit(1)
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
@app.command() @app.command()
def call( def call(target: str, method: str, method_args: list[str] = typer.Argument(None)):
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:
result = subprocess.run( sys.stderr.write(result.stderr.decode())
args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), sys.exit(1)
capture_output=True, sys.stderr.write(result.stderr.decode())
)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
+24 -27
View File
@@ -1,9 +1,9 @@
import subprocess import subprocess
from pathlib import Path
from typing import Annotated
import typer import typer
from typing import Annotated
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
from pathlib import Path
args = ["qs", "-c", "zshell"] args = ["qs", "-c", "zshell"]
@@ -12,35 +12,32 @@ app = typer.Typer()
@app.command() @app.command()
def set(wallpaper: Path): def set(wallpaper: Path):
subprocess.run( subprocess.run(args + ["ipc"] + ["call"] + ["wallpaper"] + ["set"] + [wallpaper], check=True)
[*args, "ipc", "call", "wallpaper", "set", wallpaper],
check=True,
)
@app.command() @app.command()
def lockscreen( def lockscreen(
input_image: Annotated[ input_image: Annotated[
Path, Path,
typer.Option(), typer.Option(),
], ],
output_path: Annotated[ output_path: Annotated[
Path, Path,
typer.Option(), typer.Option(),
], ],
blur_amount: int = 20, blur_amount: int = 20,
): ):
img = Image.open(input_image) img = Image.open(input_image)
size = img.size size = img.size
if blur_amount == 0: if blur_amount == 0:
img.save(output_path, "PNG") img.save(output_path, "PNG")
return return
if size[0] < 3840 or size[1] < 2160: if size[0] < 3840 or size[1] < 2160:
img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST) img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST)
else: else:
img = img.resize((size[0] // 4, size[1] // 4), Image.Resampling.NEAREST) img = img.resize((size[0] // 4, size[1] // 4), Image.Resampling.NEAREST)
img = img.filter(ImageFilter.GaussianBlur(blur_amount)) img = img.filter(ImageFilter.GaussianBlur(blur_amount))
img.save(output_path, "PNG") img.save(output_path, "PNG")
+95 -98
View File
@@ -10,140 +10,137 @@ ASSETS: Traversable = files("zshell") / "assets" / "schemes"
@dataclass(frozen=True) @dataclass(frozen=True)
class SchemeVariant: class SchemeVariant:
id: str id: str
name: str name: str
modes: frozenset[str] modes: frozenset[str]
accents: tuple[str, ...] = () accents: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class SchemeMeta: class SchemeMeta:
id: str id: str
name: str name: str
variants: tuple[SchemeVariant, ...] variants: tuple[SchemeVariant, ...]
@dataclass @dataclass
class Palette: class Palette:
colors: dict[str, str] colors: dict[str, str]
mode: str mode: str
scheme: str scheme: str
variant: str variant: str
accent: str | None = None accent: str | None = None
def _parse_txt(path: Traversable) -> dict[str, str]: def _parse_txt(path: Traversable) -> dict[str, str]:
colors: dict[str, str] = {} colors: dict[str, str] = {}
for line in path.read_text().splitlines(): for line in path.read_text().splitlines():
line = line.strip() line = line.strip()
if not line or line.startswith("#"): if not line or line.startswith("#"):
continue continue
parts = line.split(None, 1) parts = line.split(None, 1)
if len(parts) == 2: if len(parts) == 2:
key, val = parts key, val = parts
colors[key] = f"#{val}" if not val.startswith("#") else val colors[key] = f"#{val}" if not val.startswith("#") else val
return colors return colors
def _discover_schemes() -> dict[str, SchemeMeta]: def _discover_schemes() -> dict[str, SchemeMeta]:
schemes: dict[str, SchemeMeta] = {} schemes: dict[str, SchemeMeta] = {}
for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name): for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name):
if not scheme_dir.is_dir() or scheme_dir.name.startswith("."): if not scheme_dir.is_dir() or scheme_dir.name.startswith("."):
continue continue
sid = scheme_dir.name sid = scheme_dir.name
display_name = sid.capitalize() display_name = sid.capitalize()
variants: list[SchemeVariant] = [] variants: list[SchemeVariant] = []
for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name): for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name):
if not var_dir.is_dir() or var_dir.name.startswith("."): if not var_dir.is_dir() or var_dir.name.startswith("."):
continue continue
modes: set[str] = set() modes: set[str] = set()
accents: set[str] = set() accents: set[str] = set()
for f in var_dir.iterdir(): for f in var_dir.iterdir():
name = PurePosixPath(f.name) name = PurePosixPath(f.name)
if name.suffix != ".txt": if name.suffix != ".txt":
continue continue
stem = name.stem stem = name.stem
if "-" in stem: if "-" in stem:
maybe_accent, maybe_mode = stem.rsplit("-", 1) maybe_accent, maybe_mode = stem.rsplit("-", 1)
if maybe_mode in ("dark", "light"): if maybe_mode in ("dark", "light"):
modes.add(maybe_mode) modes.add(maybe_mode)
accents.add(maybe_accent) accents.add(maybe_accent)
else: else:
modes.add(stem) modes.add(stem)
else: else:
if stem in ("dark", "light"): if stem in ("dark", "light"):
modes.add(stem) modes.add(stem)
if modes: if modes:
vname = var_dir.name.capitalize() vname = var_dir.name.capitalize()
variants.append( variants.append(
SchemeVariant( SchemeVariant(
id=var_dir.name, id=var_dir.name,
name=vname, name=vname,
modes=frozenset(modes), modes=frozenset(modes),
accents=tuple(sorted(accents)), accents=tuple(sorted(accents)),
) )
) )
schemes[sid] = SchemeMeta( schemes[sid] = SchemeMeta(
id=sid, id=sid,
name=display_name, name=display_name,
variants=tuple(variants), variants=tuple(variants),
) )
return schemes return schemes
SCHEMES: dict[str, SchemeMeta] = _discover_schemes() SCHEMES: dict[str, SchemeMeta] = _discover_schemes()
def get_palette( def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None) -> Palette:
scheme: str, variant: str, mode: str, accent: str | None = None if scheme not in SCHEMES:
) -> Palette: raise KeyError(
if scheme not in SCHEMES: f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}")
raise KeyError(
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}"
)
meta = SCHEMES[scheme] meta = SCHEMES[scheme]
var_ids = {v.id for v in meta.variants} var_ids = {v.id for v in meta.variants}
if variant not in var_ids: if variant not in var_ids:
raise KeyError( raise KeyError(
f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}" f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}")
)
filename = f"{accent}-{mode}.txt" if accent else f"{mode}.txt" if accent:
filename = f"{accent}-{mode}.txt"
else:
filename = f"{mode}.txt"
txt_path = ASSETS / scheme / variant / filename txt_path = ASSETS / scheme / variant / filename
if not txt_path.is_file(): if not txt_path.is_file():
txt_path = ASSETS / scheme / variant / f"{mode}.txt" txt_path = ASSETS / scheme / variant / f"{mode}.txt"
if not txt_path.is_file(): if not txt_path.is_file():
var_info = next(v for v in meta.variants if v.id == variant) var_info = next(v for v in meta.variants if v.id == variant)
raise FileNotFoundError( raise FileNotFoundError(
f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}" f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}"
) )
colors = _parse_txt(txt_path) colors = _parse_txt(txt_path)
return Palette( return Palette(colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent)
colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent
)
def list_schemes() -> dict[str, SchemeMeta]: def list_schemes() -> dict[str, SchemeMeta]:
return dict(SCHEMES) return dict(SCHEMES)
def resolve_preset(spec: str) -> tuple[str, str]: def resolve_preset(spec: str) -> tuple[str, str]:
parts = spec.split(":") parts = spec.split(":")
if len(parts) == 2: if len(parts) == 2:
return parts[0], parts[1] return parts[0], parts[1]
if len(parts) == 1: if len(parts) == 1:
return parts[0], "default" return parts[0], "default"
raise ValueError(f"Invalid preset spec '{spec}'. Use <scheme>:<variant>") raise ValueError(f"Invalid preset spec '{spec}'. Use <scheme>:<variant>")
+121 -149
View File
@@ -1,195 +1,167 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
from pathlib import Path
from zshell.utils import schemepalettes as sp from zshell.utils import schemepalettes as sp
@pytest.fixture @pytest.fixture
def tmp_schemes(tmp_path: Path) -> Path: def tmp_schemes(tmp_path: Path) -> Path:
schemes = tmp_path / "schemes" schemes = tmp_path / "schemes"
schemes.mkdir() schemes.mkdir()
gmedium = schemes / "gruvbox" / "medium" gmedium = schemes / "gruvbox" / "medium"
gmedium.mkdir(parents=True) gmedium.mkdir(parents=True)
(gmedium / "dark.txt").write_text( (gmedium / "dark.txt").write_text("background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n")
"background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n" (gmedium / "light.txt").write_text("background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n")
)
(gmedium / "light.txt").write_text(
"background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n"
)
ghard = schemes / "gruvbox" / "hard" ghard = schemes / "gruvbox" / "hard"
ghard.mkdir(parents=True) ghard.mkdir(parents=True)
(ghard / "dark.txt").write_text("background 0b0d0e\nprimary 81d3e0\n") (ghard / "dark.txt").write_text("background 0b0d0e\nprimary 81d3e0\n")
cmocha = schemes / "catppuccin" / "mocha" cmocha = schemes / "catppuccin" / "mocha"
cmocha.mkdir(parents=True) cmocha.mkdir(parents=True)
(cmocha / "dark.txt").write_text( (cmocha / "dark.txt").write_text("background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n")
"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 / "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 = schemes / "catppuccin" / "latte"
clatte.mkdir(parents=True) clatte.mkdir(parents=True)
(clatte / "light.txt").write_text( (clatte / "light.txt").write_text("background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n")
"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 / "mauve-light.txt").write_text(
"background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n"
)
cextra = schemes / "extra" / "default" cextra = schemes / "extra" / "default"
cextra.mkdir(parents=True) cextra.mkdir(parents=True)
(cextra / "dark.txt").write_text( (cextra / "dark.txt").write_text(
"# this is a comment\n\nbackground 000000\nprimary ffffff\n\n # indented comment \n secondary cccccc\n" "# this is a comment\n\nbackground 000000\nprimary ffffff\n\n # indented comment \n secondary cccccc\n"
) )
return schemes return schemes
class TestParseTxt: class TestParseTxt:
def test_basic(self, tmp_schemes): def test_basic(self, tmp_schemes):
path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" path = tmp_schemes / "gruvbox" / "medium" / "dark.txt"
colors = sp._parse_txt(path) colors = sp._parse_txt(path)
assert colors["background"] == "#101415" assert colors["background"] == "#101415"
assert colors["primary"] == "#81d3e0" assert colors["primary"] == "#81d3e0"
assert colors["surface"] == "#1c2021" assert colors["surface"] == "#1c2021"
def test_adds_hash_prefix(self, tmp_schemes): def test_adds_hash_prefix(self, tmp_schemes):
path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" path = tmp_schemes / "gruvbox" / "medium" / "dark.txt"
colors = sp._parse_txt(path) colors = sp._parse_txt(path)
for v in colors.values(): for v in colors.values():
assert v.startswith("#"), f"value {v!r} missing # prefix" assert v.startswith("#"), f"value {v!r} missing # prefix"
def test_skips_comments_and_empty_lines(self, tmp_schemes): def test_skips_comments_and_empty_lines(self, tmp_schemes):
path = tmp_schemes / "extra" / "default" / "dark.txt" path = tmp_schemes / "extra" / "default" / "dark.txt"
colors = sp._parse_txt(path) colors = sp._parse_txt(path)
assert colors["background"] == "#000000" assert colors["background"] == "#000000"
assert colors["primary"] == "#ffffff" assert colors["primary"] == "#ffffff"
assert colors["secondary"] == "#cccccc" assert colors["secondary"] == "#cccccc"
assert len(colors) == 3 assert len(colors) == 3
class TestDiscoverSchemes: class TestDiscoverSchemes:
def test_discovers_all_schemes(self): def test_discovers_all_schemes(self):
schemes = sp._discover_schemes() schemes = sp._discover_schemes()
assert "gruvbox" in schemes assert "gruvbox" in schemes
assert "catppuccin" in schemes assert "catppuccin" in schemes
assert "everforest" in schemes assert "everforest" in schemes
assert "nord" in schemes assert "nord" in schemes
assert len(schemes) >= 10 assert len(schemes) >= 10
def test_scheme_has_variants(self): def test_scheme_has_variants(self):
schemes = sp._discover_schemes() schemes = sp._discover_schemes()
gruvbox = schemes["gruvbox"] gruvbox = schemes["gruvbox"]
var_ids = {v.id for v in gruvbox.variants} var_ids = {v.id for v in gruvbox.variants}
assert "medium" in var_ids assert "medium" in var_ids
assert "hard" in var_ids assert "hard" in var_ids
assert "soft" in var_ids assert "soft" in var_ids
def test_variant_has_modes(self): def test_variant_has_modes(self):
schemes = sp._discover_schemes() schemes = sp._discover_schemes()
gmedium = next( gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium")
v for v in schemes["gruvbox"].variants if v.id == "medium" assert "dark" in gmedium.modes
) assert "light" in gmedium.modes
assert "dark" in gmedium.modes
assert "light" in gmedium.modes
def test_catppuccin_has_accents(self): def test_catppuccin_has_accents(self):
schemes = sp._discover_schemes() schemes = sp._discover_schemes()
mocha = next( mocha = next(v for v in schemes["catppuccin"].variants if v.id == "mocha")
v for v in schemes["catppuccin"].variants if v.id == "mocha" assert "mauve" in mocha.accents
) assert "green" in mocha.accents
assert "mauve" in mocha.accents assert "rosewater" in mocha.accents
assert "green" in mocha.accents assert len(mocha.accents) >= 14
assert "rosewater" in mocha.accents
assert len(mocha.accents) >= 14
def test_non_accent_scheme_has_no_accents(self): def test_non_accent_scheme_has_no_accents(self):
schemes = sp._discover_schemes() schemes = sp._discover_schemes()
gmedium = next( gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium")
v for v in schemes["gruvbox"].variants if v.id == "medium" assert gmedium.accents == ()
)
assert gmedium.accents == ()
class TestGetPalette: class TestGetPalette:
def test_loads_basic_palette(self): def test_loads_basic_palette(self):
pal = sp.get_palette("gruvbox", "medium", "dark") pal = sp.get_palette("gruvbox", "medium", "dark")
assert pal.scheme == "gruvbox" assert pal.scheme == "gruvbox"
assert pal.variant == "medium" assert pal.variant == "medium"
assert pal.mode == "dark" assert pal.mode == "dark"
assert pal.colors["background"].startswith("#") assert pal.colors["background"].startswith("#")
assert pal.colors["primary"].startswith("#") assert pal.colors["primary"].startswith("#")
def test_loads_accent_palette(self): def test_loads_accent_palette(self):
pal = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") pal = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve")
assert pal.accent == "mauve" assert pal.accent == "mauve"
assert pal.colors["primary"] == "#cba6f7" assert pal.colors["primary"] == "#cba6f7"
def test_different_accent_changes_colors(self): def test_different_accent_changes_colors(self):
mauve = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") mauve = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve")
green = sp.get_palette("catppuccin", "mocha", "dark", accent="green") green = sp.get_palette("catppuccin", "mocha", "dark", accent="green")
assert mauve.colors["primary"] != green.colors["primary"] assert mauve.colors["primary"] != green.colors["primary"]
assert mauve.colors["secondary"] != green.colors["secondary"] assert mauve.colors["secondary"] != green.colors["secondary"]
def test_unknown_scheme_raises(self): def test_unknown_scheme_raises(self):
with pytest.raises(KeyError, match="Unknown scheme 'nope'"): with pytest.raises(KeyError, match="Unknown scheme 'nope'"):
sp.get_palette("nope", "medium", "dark") sp.get_palette("nope", "medium", "dark")
def test_unknown_variant_raises(self): def test_unknown_variant_raises(self):
with pytest.raises( with pytest.raises(KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'"):
KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'" sp.get_palette("gruvbox", "bogus", "dark")
):
sp.get_palette("gruvbox", "bogus", "dark")
def test_unknown_accent_falls_back(self): def test_unknown_accent_falls_back(self):
pal = sp.get_palette( pal = sp.get_palette("catppuccin", "mocha", "dark", accent="nonexistent")
"catppuccin", "mocha", "dark", accent="nonexistent" assert pal.accent == "nonexistent"
) assert pal.colors["primary"] is not None
assert pal.accent == "nonexistent"
assert pal.colors["primary"] is not None
def test_accent_on_non_accent_scheme(self): def test_accent_on_non_accent_scheme(self):
pal = sp.get_palette("gruvbox", "medium", "dark", accent="mauve") pal = sp.get_palette("gruvbox", "medium", "dark", accent="mauve")
assert pal.colors is not None assert pal.colors is not None
def test_non_existent_mode_raises(self): def test_non_existent_mode_raises(self):
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
sp.get_palette("catppuccin", "mocha", "light") sp.get_palette("catppuccin", "mocha", "light")
class TestListSchemes: class TestListSchemes:
def test_returns_dict(self): def test_returns_dict(self):
schemes = sp.list_schemes() schemes = sp.list_schemes()
assert isinstance(schemes, dict) assert isinstance(schemes, dict)
def test_includes_known_schemes(self): def test_includes_known_schemes(self):
schemes = sp.list_schemes() schemes = sp.list_schemes()
assert "catppuccin" in schemes assert "catppuccin" in schemes
assert "gruvbox" in schemes assert "gruvbox" in schemes
class TestResolvePreset: class TestResolvePreset:
def test_two_parts(self): def test_two_parts(self):
assert sp.resolve_preset("gruvbox:medium") == ("gruvbox", "medium") assert sp.resolve_preset("gruvbox:medium") == ("gruvbox", "medium")
def test_three_parts(self): def test_three_parts(self):
with pytest.raises(ValueError, match="Invalid preset spec"): with pytest.raises(ValueError, match="Invalid preset spec"):
sp.resolve_preset("catppuccin:mocha:mauve") sp.resolve_preset("catppuccin:mocha:mauve")
def test_one_part(self): def test_one_part(self):
assert sp.resolve_preset("default") == ("default", "default") assert sp.resolve_preset("default") == ("default", "default")
def test_edge_spaces(self): def test_edge_spaces(self):
assert sp.resolve_preset(" catppuccin : mocha ") == ( assert sp.resolve_preset(" catppuccin : mocha ") == (" catppuccin ", " mocha ")
" catppuccin ",
" mocha ",
)
+93 -134
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from subprocess import CompletedProcess from subprocess import CompletedProcess
from unittest.mock import call, patch from unittest.mock import patch, call
from typer.testing import CliRunner from typer.testing import CliRunner
from zshell.subcommands.shell import app from zshell.subcommands.shell import app
@@ -10,163 +10,122 @@ runner = CliRunner()
def invoke(*args: str): def invoke(*args: str):
result = runner.invoke(app, args) result = runner.invoke(app, args)
if result.exit_code != 0: if result.exit_code != 0:
raise RuntimeError(result.output) raise RuntimeError(result.output)
return result return result
class TestKill: class TestKill:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_kill_runs_qs_kill_success(self, mock_run): def test_kill_runs_qs_kill_success(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n") mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n")
invoke("kill") invoke("kill")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], capture_output=True)
["qs", "-c", "zshell", "kill"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_kill_no_instance_errors(self, mock_run): def test_kill_no_instance_errors(self, mock_run):
mock_run.return_value = CompletedProcess( mock_run.return_value = CompletedProcess([], 255, b"", b"No running instances\n")
[], 255, b"", b"No running instances\n" result = runner.invoke(app, ["kill"])
) assert result.exit_code != 0
result = runner.invoke(app, ["kill"]) assert "No running instance to kill" in result.output
assert result.exit_code != 0
assert "No running instance to kill" in result.output
class TestStart: class TestStart:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_start_default_daemon(self, mock_run): def test_start_default_daemon(self, mock_run):
mock_run.return_value = CompletedProcess( mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
[], 0, b"", b"Launching config\n" invoke("start")
) mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], capture_output=True)
invoke("start")
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "-n", "-d"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_start_no_daemon(self, mock_run): def test_start_no_daemon(self, mock_run):
mock_run.return_value = CompletedProcess( mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
[], 0, b"", b"Launching config\n" invoke("start", "--no-daemon")
) mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], capture_output=True)
invoke("start", "--no-daemon")
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "-n"], capture_output=True
)
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_start_already_running_errors(self, mock_run): def test_start_already_running_errors(self, mock_run):
mock_run.return_value = CompletedProcess( mock_run.return_value = CompletedProcess([], 0, b"An instance of this configuration is already running.\n", b"")
[], result = runner.invoke(app, ["start"])
0, assert result.exit_code != 0
b"An instance of this configuration is already running.\n", assert "already running" in result.output
b"",
)
result = runner.invoke(app, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_start_other_failure_errors(self, mock_run): def test_start_other_failure_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n") mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n")
result = runner.invoke(app, ["start"]) result = runner.invoke(app, ["start"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Config error" in result.output assert "Config error" in result.output
class TestShow: class TestShow:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_show_runs_ipc_show(self, mock_run): def test_show_runs_ipc_show(self, mock_run):
mock_run.return_value = CompletedProcess( mock_run.return_value = CompletedProcess([], 0, b"target visibilities\n", b"")
[], 0, b"target visibilities\n", b"" result = invoke("show")
) assert "target visibilities" in result.output
result = invoke("show") mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], capture_output=True)
assert "target visibilities" in result.output
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "show"], capture_output=True
)
class TestLog: class TestLog:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_log_runs_qs_log(self, mock_run): def test_log_runs_qs_log(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"") mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"")
invoke("log") invoke("log")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], capture_output=True)
["qs", "-c", "zshell", "log"], capture_output=True
)
class TestLock: class TestLock:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_lock_runs_ipc_call_lock(self, mock_run): def test_lock_runs_ipc_call_lock(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"") mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("lock") invoke("lock")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], capture_output=True)
["qs", "-c", "zshell", "ipc", "call", "lock", "lock"],
capture_output=True,
)
class TestCall: class TestCall:
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_call_no_args(self, mock_run): def test_call_no_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"") mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method") invoke("call", "target", "method")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], capture_output=True)
["qs", "-c", "zshell", "ipc", "call", "target", "method"],
capture_output=True,
)
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_call_with_args(self, mock_run): def test_call_with_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"") mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method", "arg1", "arg2") invoke("call", "target", "method", "arg1", "arg2")
mock_run.assert_called_once_with( mock_run.assert_called_once_with(
[ ["qs", "-c", "zshell", "ipc", "call", "target", "method", "arg1", "arg2"],
"qs", capture_output=True,
"-c", )
"zshell",
"ipc",
"call",
"target",
"method",
"arg1",
"arg2",
],
capture_output=True,
)
class TestRestart: class TestRestart:
@patch("zshell.subcommands.shell.start_instance") @patch("zshell.subcommands.shell.start_instance")
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_restart_kills_then_starts(self, mock_run, mock_start): def test_restart_kills_then_starts(self, mock_run, mock_start):
mock_run.side_effect = [ mock_run.side_effect = [
CompletedProcess( CompletedProcess([], 0, b"", b"Killed abc\n"), # first kill (captured)
[], 0, b"", b"Killed abc\n" CompletedProcess([], 255, b"", b""), # poll → no instance
), # first kill (captured) ]
CompletedProcess([], 255, b"", b""), # poll → no instance invoke("restart")
] assert mock_run.call_args_list == [
invoke("restart") call(["qs", "-c", "zshell", "kill"], capture_output=True),
assert mock_run.call_args_list == [ call(["qs", "-c", "zshell", "kill"], capture_output=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True), ]
call(["qs", "-c", "zshell", "kill"], capture_output=True), mock_start.assert_called_once_with(no_daemon=False)
]
mock_start.assert_called_once_with(no_daemon=False)
@patch("zshell.subcommands.shell.start_instance") @patch("zshell.subcommands.shell.start_instance")
@patch("zshell.subcommands.shell.subprocess.run") @patch("zshell.subcommands.shell.subprocess.run")
def test_restart_no_daemon(self, mock_run, mock_start): def test_restart_no_daemon(self, mock_run, mock_start):
mock_run.side_effect = [ mock_run.side_effect = [
CompletedProcess([], 0, b"", b"Killed abc\n"), CompletedProcess([], 0, b"", b"Killed abc\n"),
CompletedProcess([], 255, b"", b""), CompletedProcess([], 255, b"", b""),
] ]
invoke("restart", "--no-daemon") invoke("restart", "--no-daemon")
assert mock_run.call_args_list == [ assert mock_run.call_args_list == [
call(["qs", "-c", "zshell", "kill"], capture_output=True), call(["qs", "-c", "zshell", "kill"], capture_output=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True), call(["qs", "-c", "zshell", "kill"], capture_output=True),
] ]
mock_start.assert_called_once_with(no_daemon=True) mock_start.assert_called_once_with(no_daemon=True)
+307 -352
View File
@@ -4,24 +4,22 @@ import json
import re import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from functools import cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@cache @lru_cache(maxsize=None)
def read_lines(path: Path) -> tuple[str, ...]: def read_lines(path: Path) -> tuple[str, ...]:
return tuple(path.read_text().splitlines()) return tuple(path.read_text().splitlines())
ROW_RE = re.compile( 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\("([^"]+)"\)') LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') 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( 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*"([^"]+)"') ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
SKIP_LABELS = {"Muted", "None"} SKIP_LABELS = {"Muted", "None"}
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
@@ -29,14 +27,14 @@ STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}
def find_pages_dir(settings: Path) -> Path: def find_pages_dir(settings: Path) -> Path:
return settings / "Pages" return settings / "Pages"
def discover_files(settings: Path) -> dict[str, Path]: def discover_files(settings: Path) -> dict[str, Path]:
files: dict[str, Path] = {} files: dict[str, Path] = {}
for p in find_pages_dir(settings).rglob("*.qml"): for p in find_pages_dir(settings).rglob("*.qml"):
files[p.stem] = p files[p.stem] = p
return files return files
PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)') PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)')
@@ -44,409 +42,366 @@ PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
def parse_page_registry(settings: Path) -> list[tuple[str, str]]: def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
text = (settings / "PageRegistry.qml").read_text().splitlines() text = (settings / "PageRegistry.qml").read_text().splitlines()
start = next( 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]] = [] out: list[tuple[str, str]] = []
i = start + 1 i = start + 1
while i < len(text): while i < len(text):
line = text[i].strip() line = text[i].strip()
if line.startswith("]"): if line.startswith("]"):
break break
if line.startswith("//") or not line: if line.startswith("//") or not line:
i += 1 i += 1
continue continue
if line.startswith("{"): if line.startswith("{"):
name = None name = None
icon = None icon = None
i += 1 i += 1
while i < len(text): while i < len(text):
s = text[i].strip() s = text[i].strip()
if s.startswith("}"): if s.startswith("}"):
if name is not None: if name is not None:
out.append((icon or "tune", name)) out.append((icon or "tune", name))
break break
if name is None: if name is None:
m = PAGE_NAME_RE.match(text[i]) m = PAGE_NAME_RE.match(text[i])
if m: if m:
name = m.group(1) name = m.group(1)
if icon is None: if icon is None:
mi = PAGE_ICON_RE.match(text[i]) mi = PAGE_ICON_RE.match(text[i])
if mi: if mi:
icon = mi.group(1) icon = mi.group(1)
i += 1 i += 1
i += 1 i += 1
return out 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: def _strip_comment(line: str) -> str:
return line.split("//", 1)[0].rstrip() return line.split("//", 1)[0].rstrip()
def parse_block( def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], int]:
lines: list[str], i: int line = _strip_comment(lines[i]).strip()
) -> tuple[str, list[tuple[str, list]], int]: m = BLOCK_RE.match(line)
line = _strip_comment(lines[i]).strip() if not m:
m = BLOCK_RE.match(line) raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")
if not m:
raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")
name = m.group(1) name = m.group(1)
i += 1 i += 1
children: list[tuple[str, list]] = [] children: list[tuple[str, list]] = []
while i < len(lines): while i < len(lines):
s = _strip_comment(lines[i]).strip() s = _strip_comment(lines[i]).strip()
if not s: if not s:
i += 1 i += 1
continue continue
if s.startswith("}"): if s.startswith("}"):
return name, children, i + 1 return name, children, i + 1
if BLOCK_RE.match(s): if BLOCK_RE.match(s):
child_name, child_children, i = parse_block(lines, i) child_name, child_children, i = parse_block(lines, i)
children.append((child_name, child_children)) children.append((child_name, child_children))
continue continue
i += 1 i += 1
raise ValueError(f"Unterminated block: {name}") raise ValueError(f"Unterminated block: {name}")
def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
name, children = block name, children = block
if name != "Component": if name != "Component":
return [name] return [name]
for child_name, child_children in children: for child_name, child_children in children:
if child_name == "StackPage": if child_name == "StackPage":
out: list[str] = [] out: list[str] = []
for grand_name, grand_children in child_children: for grand_name, grand_children in child_children:
if grand_name == "Component": if grand_name == "Component":
out.extend(collect_page_names((grand_name, grand_children))) out.extend(collect_page_names((grand_name, grand_children)))
return out return out
if child_name != "Component": if child_name != "Component":
return [child_name] return [child_name]
return [] return []
def parse_page_comps(settings: Path) -> list[list[str]]: def parse_page_comps(settings: Path) -> list[list[str]]:
text = (settings / "PageCompRegistry.qml").read_text().splitlines() text = (settings / "PageCompRegistry.qml").read_text().splitlines()
start = next( start = next(
i i for i, line in enumerate(text)
for i, line in enumerate(text) if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line))
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line)) )
)
comps: list[list[str]] = [] comps: list[list[str]] = []
i = start + 1 i = start + 1
while i < len(text): while i < len(text):
s = _strip_comment(text[i]).strip() s = _strip_comment(text[i]).strip()
if not s: if not s:
i += 1 i += 1
continue continue
if s.startswith("]"): if s.startswith("]"):
break break
if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component":
block = parse_block(text, i) block = parse_block(text, i)
names = collect_page_names((block[0], block[1])) names = collect_page_names((block[0], block[1]))
if names: if names:
comps.append(names) comps.append(names)
i = block[2] i = block[2]
continue continue
i += 1 i += 1
return comps return comps
def dedup_crumbs( def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]:
labels: list[str], icons: list[str] out_labels: list[str] = []
) -> tuple[list[str], list[str]]: out_icons: list[str] = []
out_labels: list[str] = [] for lbl, ico in zip(labels, icons):
out_icons: list[str] = [] if out_labels and out_labels[-1] == lbl:
for lbl, ico in zip(labels, icons, strict=False): continue
if out_labels and out_labels[-1] == lbl: out_labels.append(lbl)
continue out_icons.append(ico)
out_labels.append(lbl) return out_labels, out_icons
out_icons.append(ico)
return out_labels, out_icons
def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
comps = parse_page_comps(settings) comps = parse_page_comps(settings)
registry = parse_page_registry(settings) registry = parse_page_registry(settings)
top_meta: dict[int, tuple[str, str]] = {} top_meta: dict[int, tuple[str, str]] = {}
for i, (icon, label) in enumerate(registry): for i, (icon, label) in enumerate(registry):
top_meta[i] = (icon, label) top_meta[i] = (icon, label)
nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
for names in comps: for names in comps:
for name in names: for name in names:
pf = files.get(name) pf = files.get(name)
if not pf: if not pf:
continue continue
pending_icon = pending_label = None pending_icon = pending_label = None
section = "" section = ""
expect_section = False expect_section = False
for ln in read_lines(pf): for ln in read_lines(pf):
if SECTION_RE.match(ln): if SECTION_RE.match(ln):
expect_section = True expect_section = True
continue continue
ml = LABEL_RE.match(ln) ml = LABEL_RE.match(ln)
if ml: if ml:
if expect_section: if expect_section:
section = ml.group(1) section = ml.group(1)
expect_section = False expect_section = False
else: else:
pending_label = ml.group(1) pending_label = ml.group(1)
continue continue
mi = ICON_RE.match(ln) mi = ICON_RE.match(ln)
if mi: if mi:
pending_icon = mi.group(1) pending_icon = mi.group(1)
mo = re.search(r"openSubPage\((\d+)\)", ln) mo = re.search(r"openSubPage\((\d+)\)", ln)
if mo: if mo:
pos = int(mo.group(1)) pos = int(mo.group(1))
nav_children.setdefault(name, {})[pos] = ( nav_children.setdefault(name, {})[pos] = (
pending_icon or "tune", pending_icon or "tune", pending_label or "", section)
pending_label or "", pending_icon = pending_label = None
section,
)
pending_icon = pending_label = None
nav: dict[str, dict] = {} nav: dict[str, dict] = {}
for top_idx, names in enumerate(comps): for top_idx, names in enumerate(comps):
if not names: if not names:
continue continue
main = names[0] main = names[0]
main_icon, main_label = top_meta.get(top_idx, ("tune", main)) main_icon, main_label = top_meta.get(top_idx, ("tune", main))
nav[main] = { nav[main] = {"pageIdx": top_idx, "subPath": [],
"pageIdx": top_idx, "crumbIcons": [main_icon], "crumbLabels": [main_label]}
"subPath": [], children = dict(nav_children.get(main, {}))
"crumbIcons": [main_icon], opened_via_subpage = set()
"crumbLabels": [main_label], for owner, kids in nav_children.items():
} owner_group = next((ns for ns in comps if owner in ns), None)
children = dict(nav_children.get(main, {})) if not owner_group:
opened_via_subpage = set() continue
for owner, kids in nav_children.items(): for kpos in kids:
owner_group = next((ns for ns in comps if owner in ns), None) if kpos < len(owner_group):
if not owner_group: opened_via_subpage.add(owner_group[kpos])
continue for pos in range(1, len(names)):
for kpos in kids: if pos not in children and names[pos] not in opened_via_subpage:
if kpos < len(owner_group): label = re.sub(r"(Detail)?Page$", "", names[pos])
opened_via_subpage.add(owner_group[kpos]) label = re.sub(r"(?<!^)(?=[A-Z])", " ", label)
for pos in range(1, len(names)): children[pos] = (main_icon, label, "")
if pos not in children and names[pos] not in opened_via_subpage: for pos, (icon, label, section) in children.items():
label = re.sub(r"(Detail)?Page$", "", names[pos]) if pos >= len(names):
label = re.sub(r"(?<!^)(?=[A-Z])", " ", label) continue
children[pos] = (main_icon, label, "") child = names[pos]
for pos, (icon, label, section) in children.items(): labels = [main_label] + ([section] if section else []) + [label]
if pos >= len(names): icons = [main_icon] + ([icon] if section else []) + [icon]
continue labels, icons = dedup_crumbs(labels, icons)
child = names[pos] nav[child] = {"pageIdx": top_idx, "subPath": [pos],
labels = [main_label] + ([section] if section else []) + [label] "crumbIcons": icons,
icons = [main_icon] + ([icon] if section else []) + [icon] "crumbLabels": labels}
labels, icons = dedup_crumbs(labels, icons) for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items():
nav[child] = { if gpos >= len(names):
"pageIdx": top_idx, continue
"subPath": [pos], glabels = labels + ([gsection] if gsection else []) + [glabel]
"crumbIcons": icons, gicons = icons + ([gicon] if gsection else []) + [gicon]
"crumbLabels": labels, glabels, gicons = dedup_crumbs(glabels, gicons)
} nav[names[gpos]] = {
for gpos, (gicon, glabel, gsection) in nav_children.get( "pageIdx": top_idx, "subPath": [pos, gpos],
child, {} "crumbIcons": gicons,
).items(): "crumbLabels": glabels}
if gpos >= len(names): return nav
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],
"crumbIcons": gicons,
"crumbLabels": glabels,
}
return nav
def tokenize(text: str) -> list[str]: def tokenize(text: str) -> list[str]:
toks: list[str] = [] toks: list[str] = []
for word in text.lower().split(): for word in text.lower().split():
parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
for p in parts: for p in parts:
if p not in STOPWORDS and p not in toks: if p not in STOPWORDS and p not in toks:
toks.append(p) toks.append(p)
if len(parts) > 1: if len(parts) > 1:
joined = "".join(parts) joined = "".join(parts)
if joined not in toks: if joined not in toks:
toks.append(joined) toks.append(joined)
return toks return toks
SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)') 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( def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]:
files: dict[str, Path], nav: dict[str, dict] entries: list[dict] = []
) -> list[dict]: for comp, meta in nav.items():
entries: list[dict] = [] pf = files.get(comp)
for comp, meta in nav.items(): if not pf:
pf = files.get(comp) continue
if not pf: lines = read_lines(pf)
continue section = ""
lines = read_lines(pf) i = 0
section = "" while i < len(lines):
i = 0 if SECTION_RE.match(lines[i]):
while i < len(lines): for j in range(i + 1, min(i + 4, len(lines))):
if SECTION_RE.match(lines[i]): m = LABEL_RE.match(lines[j])
for j in range(i + 1, min(i + 4, len(lines))): if m:
m = LABEL_RE.match(lines[j]) section = m.group(1)
if m: break
section = m.group(1) row_match = ROW_RE.match(lines[i])
break if row_match:
row_match = ROW_RE.match(lines[i]) row_type = row_match.group(1)
if row_match: label = anchor = subtext = None
row_type = row_match.group(1) checked_path = toggled_path = None
label = anchor = subtext = None for j in range(i + 1, min(i + 12, len(lines))):
checked_path = toggled_path = None if label is None:
for j in range(i + 1, min(i + 12, len(lines))): m = LABEL_RE.match(lines[j])
if label is None: if m:
m = LABEL_RE.match(lines[j]) label = m.group(1)
if m: if anchor is None:
label = m.group(1) a = ANCHOR_RE.match(lines[j])
if anchor is None: if a:
a = ANCHOR_RE.match(lines[j]) anchor = a.group(1)
if a: if subtext is None:
anchor = a.group(1) st = SUBTEXT_RE.match(lines[j])
if subtext is None: if st:
st = SUBTEXT_RE.match(lines[j]) subtext = st.group(1)
if st: if checked_path is None:
subtext = st.group(1) ch = CHECKED_RE.match(lines[j])
if checked_path is None: if ch:
ch = CHECKED_RE.match(lines[j]) checked_path = ch.group(1)
if ch: if toggled_path is None:
checked_path = ch.group(1) tg = ONTOGGLED_RE.match(lines[j])
if toggled_path is None: if tg:
tg = ONTOGGLED_RE.match(lines[j]) toggled_path = tg.group(1)
if tg: toggle_path = (
toggled_path = tg.group(1) checked_path
toggle_path = ( if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
checked_path else ""
if row_type == "ToggleRow" )
and checked_path if label and label not in SKIP_LABELS and anchor:
and checked_path == toggled_path extra = " ".join(meta["crumbLabels"]) + \
else "" " " + section + " " + (subtext or "")
) entries.append({
if label and label not in SKIP_LABELS and anchor: "pageIdx": meta["pageIdx"], "subPath": meta["subPath"],
extra = ( "crumbIcons": meta["crumbIcons"],
" ".join(meta["crumbLabels"]) "crumbLabels": meta["crumbLabels"],
+ " " "title": label, "anchor": anchor,
+ section "section": section,
+ " " "subtext": subtext or "",
+ (subtext or "") "togglePath": toggle_path,
) "keywords": " ".join(sorted(set(tokenize(label + " " + extra)))),
entries.append( })
{ i += 1
"pageIdx": meta["pageIdx"], return entries
"subPath": meta["subPath"],
"crumbIcons": meta["crumbIcons"],
"crumbLabels": meta["crumbLabels"],
"title": label,
"anchor": anchor,
"section": section,
"subtext": subtext or "",
"togglePath": toggle_path,
"keywords": " ".join(
sorted(set(tokenize(label + " " + extra)))
),
}
)
i += 1
return entries
def build_inverted_and_ranking(entries: list[dict]): def build_inverted_and_ranking(entries: list[dict]):
inverted: dict[str, list[int]] = defaultdict(list) inverted: dict[str, list[int]] = defaultdict(list)
ranking: dict[str, dict[int, float]] = defaultdict(dict) ranking: dict[str, dict[int, float]] = defaultdict(dict)
for idx, e in enumerate(entries): for idx, e in enumerate(entries):
fields = {"title": e["title"], "keywords": e["keywords"]} fields = {"title": e["title"], "keywords": e["keywords"]}
seen: set[str] = set() seen: set[str] = set()
for field, text in fields.items(): for field, text in fields.items():
weight = FIELD_WEIGHT.get(field, 0.2) weight = FIELD_WEIGHT.get(field, 0.2)
for tok in tokenize(text): for tok in tokenize(text):
if idx not in inverted[tok]: if idx not in inverted[tok]:
inverted[tok].append(idx) inverted[tok].append(idx)
ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
seen.add(tok) seen.add(tok)
for tok, ids in inverted.items(): for tok, ids in inverted.items():
ids.sort(key=lambda i: ranking[tok][i], reverse=True) ids.sort(key=lambda i: ranking[tok][i], reverse=True)
return inverted, { return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()}
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
}
def main() -> int: def main() -> int:
if len(sys.argv) != 3: if len(sys.argv) != 3:
print(__doc__) print(__doc__)
return 1 return 1
settings = Path(sys.argv[1]) settings = Path(sys.argv[1])
out = Path(sys.argv[2]) out = Path(sys.argv[2])
files = discover_files(settings) files = discover_files(settings)
nav = build_nav_map(settings, files) nav = build_nav_map(settings, files)
entries = extract_settings(files, nav) entries = extract_settings(files, nav)
inverted, ranking = build_inverted_and_ranking(entries) inverted, ranking = build_inverted_and_ranking(entries)
for e in entries: for e in entries:
e.pop("keywords", None) e.pop("keywords", None)
out.write_text( out.write_text(json.dumps({
json.dumps( "version": 2,
{ "entries": entries,
"version": 2, "inverted": inverted,
"entries": entries, "ranking": ranking,
"inverted": inverted, }, ensure_ascii=False, indent=2))
"ranking": ranking, print(f"settings index: {len(entries)} entries, "
}, f"{len(inverted)} tokens -> {out}")
ensure_ascii=False, print("files:", len(files))
indent=2, print("comps:", len(parse_page_comps(settings)))
) print("registry:", len(parse_page_registry(settings)))
) print("nav:", len(nav))
print( print("entries:", len(entries))
f"settings index: {len(entries)} entries, " return 0
f"{len(inverted)} tokens -> {out}"
)
print("files:", len(files))
print("comps:", len(parse_page_comps(settings)))
print("registry:", len(parse_page_registry(settings)))
print("nav:", len(nav))
print("entries:", len(entries))
return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())