mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
129 Commits
v1.5.1
..
qml-common
| Author | SHA1 | Date | |
|---|---|---|---|
| 990d86d481 | |||
| 526cb157fd | |||
| 92ba96d9f9 | |||
| 5dfd875b9e | |||
| 00b22df802 | |||
| b22b1bef9b | |||
| 5cae48a85d | |||
| 4200270b55 | |||
| c2c180d304 | |||
| 3938e60ce4 | |||
| 2986e354e8 | |||
| bf12665adb | |||
| f8fe16738e | |||
| 3c688cfbd3 | |||
| 92ba2bb57d | |||
| 504c132f78 | |||
| bdaa6bcc9d | |||
| a50bb2cab3 | |||
| b17a90a412 | |||
| 0ffec5011b | |||
| adbbd9b112 | |||
| 95e1d6b72e | |||
| 4cb09dcc1b | |||
| ed44d02202 | |||
| 4352f49ae3 | |||
| 9ddea82063 | |||
| 3c0f2cbc48 | |||
| 6c45413d7a | |||
| 149e1e64b1 | |||
| 5589cfb783 | |||
| ff71502083 | |||
| 1ef5371c3b | |||
| 3951cd6b18 | |||
| 9686f29246 | |||
| 6d206c9521 | |||
| 48e36ae146 | |||
| 7c24e85c19 | |||
| fe1a783ec2 | |||
| 358496134c | |||
| dc924618fb | |||
| fb2dbced08 | |||
| cdaedad969 | |||
| bf408f8d00 | |||
| b169fe0d77 | |||
| 9b67cedaa1 | |||
| 2edf70a144 | |||
| 72a71cacba | |||
| 0440e40a82 | |||
| ec27d4643c | |||
| f18d36f6c2 | |||
| 3254cc6a1e | |||
| 4af3225f21 | |||
| 3c5245914f | |||
| 89814a2c65 | |||
| 5d2093e54a | |||
| 3cd52ca327 | |||
| 5b41d699fa | |||
| 544599bf1c | |||
| 296b3a3d7e | |||
| 729a990fa7 | |||
| c3fa7b2e1d | |||
| 2c5a1a2804 | |||
| 1973526c4e | |||
| bba5502960 | |||
| 52740290b2 | |||
| 27703575bc | |||
| e6504add7b | |||
| f1e9121295 | |||
| 9ff751b82a | |||
| 21eaaef056 | |||
| bb0be2b215 | |||
| 25847c3f03 | |||
| 7b5c25c50f | |||
| 7535b70fa6 | |||
| e3034e4e94 | |||
| 3da19e5c15 | |||
| ca89e12963 | |||
| 4fb6995796 | |||
| 3f5a54aa88 | |||
| d379d251b9 | |||
| 7ab0e01573 | |||
| 2cb48aaf6b | |||
| e4657aa5f9 | |||
| 197d17ac4e | |||
| 31ea83584b | |||
| ea66b136ba | |||
| 06c0ea2afb | |||
| f4f47c0bc5 | |||
| 8a0ed8a50f | |||
| 63eea01243 | |||
| f590a2965a | |||
| 4ab03deded | |||
| 846d07d86a | |||
| 55d88d8dfb | |||
| a803f96f41 | |||
| da7cc5648b | |||
| 75443758f9 | |||
| eaecff93a4 | |||
| 6e7c20c59c | |||
| 406dcfe110 | |||
| e2b3a2e3ca | |||
| 71ab752e1b | |||
| 204ecd0461 | |||
| 0fdfac366e | |||
| 52123458c7 | |||
| 26b2955cf3 | |||
| d82d86df5c | |||
| c445597f83 | |||
| 6a58adfb29 | |||
| 05feb211ba | |||
| a3b2167e58 | |||
| 0b69feaa1a | |||
| 494144a7c7 | |||
| 2a2c1ca9e6 | |||
| 56b7ecb008 | |||
| 867102b82c | |||
| 4bdb7d17b2 | |||
| c44ffae751 | |||
| ce1595d62d | |||
| 45f6232e32 | |||
| 9cf2ca7196 | |||
| c0eeed4e89 | |||
| d0a4c1c56e | |||
| cb0dc9c68d | |||
| 0439d017b9 | |||
| 8008238ca0 | |||
| a095d0ed90 | |||
| a48cce59d4 | |||
| 4c806f83f1 |
@@ -0,0 +1,56 @@
|
|||||||
|
name: Sync flake.lock to dank-qml-common
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "dank-qml-common"
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create GitHub App token
|
||||||
|
id: app_token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: cachix/install-nix-action@v31
|
||||||
|
|
||||||
|
- name: Point flake input at the submodule commit
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
submodule_rev=$(git ls-tree HEAD dank-qml-common --object-only)
|
||||||
|
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
|
||||||
|
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
|
||||||
|
nix flake lock --override-input dank-qml-common "github:AvengeMedia/dank-qml-common/$submodule_rev"
|
||||||
|
|
||||||
|
- name: Commit and push flake.lock update
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git diff --quiet flake.lock; then
|
||||||
|
echo "No changes to flake.lock"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "dms-ci[bot]"
|
||||||
|
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||||
|
git add flake.lock
|
||||||
|
git commit -m "nix: sync flake.lock to dank-qml-common submodule"
|
||||||
|
git pull --rebase origin ${{ github.ref_name }}
|
||||||
|
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install flatpak
|
- name: Install flatpak
|
||||||
run: sudo apt update && sudo apt install -y flatpak
|
run: sudo apt update && sudo apt install -y flatpak
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -55,6 +55,15 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ steps.app_token.outputs.token }}
|
token: ${{ steps.app_token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Preflight — verify Release workflow is dispatchable
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
|
||||||
|
--jq '.state' | grep -qx active ||
|
||||||
|
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
|
||||||
|
|
||||||
- name: Port audit (informational)
|
- name: Port audit (informational)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
@@ -83,7 +92,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Dispatch Release workflow
|
- name: Dispatch Release workflow
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install flatpak
|
- name: Install flatpak
|
||||||
run: sudo apt update && sudo apt install -y flatpak
|
run: sudo apt update && sudo apt install -y flatpak
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -191,11 +192,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Fetch updated tag after version bump
|
- name: Fetch updated tag after version bump
|
||||||
run: |
|
run: |
|
||||||
git fetch origin --force tag ${TAG}
|
git fetch origin --force tag ${TAG}
|
||||||
git checkout ${TAG}
|
git checkout ${TAG}
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -252,6 +255,7 @@ jobs:
|
|||||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||||
- **`dms-qml.tar.gz`** - QML source code only
|
- **`dms-qml.tar.gz`** - QML source code only
|
||||||
|
- **`dms-source.tar.gz`** - Full repository source with bundled DankCommon (for distro packaging)
|
||||||
|
|
||||||
### Checksums
|
### Checksums
|
||||||
- **`*.sha256`** - SHA256 checksums for verifying download integrity
|
- **`*.sha256`** - SHA256 checksums for verifying download integrity
|
||||||
@@ -299,6 +303,22 @@ jobs:
|
|||||||
# Copy completions
|
# Copy completions
|
||||||
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
|
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Replace the DankCommon symlink with real submodule content for packaging
|
||||||
|
rm quickshell/DankCommon
|
||||||
|
cp -r dank-qml-common/DankCommon quickshell/DankCommon
|
||||||
|
|
||||||
|
# Create full source tarball (GitHub tag archives never contain submodule content)
|
||||||
|
VERSION_NUM=${TAG#v}
|
||||||
|
tar --exclude='.git' \
|
||||||
|
--exclude='.github' \
|
||||||
|
--exclude='dank-qml-common' \
|
||||||
|
--exclude='_release_assets' \
|
||||||
|
--exclude='_core_assets' \
|
||||||
|
--exclude='RELEASE_BODY.md' \
|
||||||
|
--transform "s,^\.,DankMaterialShell-${VERSION_NUM},S" \
|
||||||
|
-czf _release_assets/dms-source.tar.gz .
|
||||||
|
(cd _release_assets && sha256sum dms-source.tar.gz > dms-source.tar.gz.sha256)
|
||||||
|
|
||||||
# Create QML source package (exclude build artifacts and git files)
|
# Create QML source package (exclude build artifacts and git files)
|
||||||
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
|
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
|
||||||
cp LICENSE CONTRIBUTING.md quickshell/
|
cp LICENSE CONTRIBUTING.md quickshell/
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
@@ -199,6 +200,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Wait before OBS upload
|
- name: Wait before OBS upload
|
||||||
run: sleep 3
|
run: sleep 3
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -94,6 +95,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Record checked-out source commit
|
- name: Record checked-out source commit
|
||||||
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||||
@@ -225,7 +226,7 @@ jobs:
|
|||||||
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
|
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
|
||||||
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
|
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
|
||||||
TARBALL="$(mktemp)"
|
TARBALL="$(mktemp)"
|
||||||
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
|
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/releases/download/${ARCHIVE_TAG}/dms-source.tar.gz"
|
||||||
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
rm -f "$TARBALL"
|
rm -f "$TARBALL"
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ jobs:
|
|||||||
# into $wrksrc (create_wrksrc=yes handles the rest).
|
# into $wrksrc (create_wrksrc=yes handles the rest).
|
||||||
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
|
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
|
||||||
mkdir -p "$SRC_CACHE"
|
mkdir -p "$SRC_CACHE"
|
||||||
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
tar -czhf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||||
--exclude=void-packages \
|
--exclude=void-packages \
|
||||||
--exclude=r2-repo \
|
--exclude=r2-repo \
|
||||||
--exclude=.git \
|
--exclude=.git \
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v3
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "dank-qml-common"]
|
||||||
|
path = dank-qml-common
|
||||||
|
url = https://github.com/AvengeMedia/dank-qml-common.git
|
||||||
@@ -36,6 +36,14 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: i18n-term-variants
|
||||||
|
name: i18n term variants (no case/punctuation duplicates)
|
||||||
|
entry: python3 quickshell/translations/check_term_variants.py
|
||||||
|
language: system
|
||||||
|
files: ^quickshell/(.*\.qml|translations/(check_term_variants\.py|extract_translations\.py))$
|
||||||
|
pass_filenames: false
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: no-console-in-qml
|
- id: no-console-in-qml
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ To contribute fork this repository, make your changes, and open a pull request.
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
Clone with submodules — the shared widget library ([dank-qml-common](https://github.com/AvengeMedia/dank-qml-common)) is vendored at `dank-qml-common/` and symlinked into `quickshell/DankCommon`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git
|
||||||
|
# or, in an existing clone:
|
||||||
|
git submodule update --init
|
||||||
|
```
|
||||||
|
|
||||||
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
|
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -28,6 +36,24 @@ This will provide:
|
|||||||
|
|
||||||
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
|
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
|
||||||
|
|
||||||
|
## Shared widgets (dank-qml-common)
|
||||||
|
|
||||||
|
Everything under `quickshell/DankCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dank-qml-common` submodule. It is a normal git worktree:
|
||||||
|
|
||||||
|
1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
|
||||||
|
2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
|
||||||
|
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
|
||||||
|
|
||||||
|
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global url."git@github.com:AvengeMedia/".pushInsteadOf "https://github.com/AvengeMedia/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dank-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
|
||||||
|
|
||||||
|
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so `import qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` keep working for the shell and for plugins.
|
||||||
|
|
||||||
## VSCode Setup
|
## VSCode Setup
|
||||||
|
|
||||||
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
|
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
|
||||||
@@ -104,6 +130,8 @@ Text {
|
|||||||
|
|
||||||
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
|
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
|
||||||
|
|
||||||
|
Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dank-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dank-qml-common/DankCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dank-qml-common`-tagged terms from the DMS project.
|
||||||
|
|
||||||
### GO (`core` directory)
|
### GO (`core` directory)
|
||||||
|
|
||||||
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
|
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ clean:
|
|||||||
lint-qml:
|
lint-qml:
|
||||||
@./quickshell/scripts/qmllint-entrypoints.sh
|
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||||
|
|
||||||
|
# Pull the latest dank-qml-common and pin it everywhere it is consumed
|
||||||
|
# (submodule pointer + nix flake input). Commit both in one change.
|
||||||
|
update-common:
|
||||||
|
git submodule update --remote --merge dank-qml-common
|
||||||
|
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
|
||||||
|
|
||||||
# Installation targets
|
# Installation targets
|
||||||
install-bin:
|
install-bin:
|
||||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||||
@@ -43,8 +49,9 @@ install-bin:
|
|||||||
|
|
||||||
install-shell:
|
install-shell:
|
||||||
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
|
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
|
||||||
|
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
|
||||||
@mkdir -p $(SHELL_INSTALL_DIR)
|
@mkdir -p $(SHELL_INSTALL_DIR)
|
||||||
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
|
||||||
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
|
||||||
@echo "Shell files installed"
|
@echo "Shell files installed"
|
||||||
|
|
||||||
|
|||||||
@@ -91,21 +91,30 @@ var authListServicesCmd = &cobra.Command{
|
|||||||
|
|
||||||
var authValidateCmd = &cobra.Command{
|
var authValidateCmd = &cobra.Command{
|
||||||
Use: "validate",
|
Use: "validate",
|
||||||
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
|
Short: "Validate a PAM service file for use by the DMS lock screen",
|
||||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
|
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
path, _ := cmd.Flags().GetString("path")
|
path, _ := cmd.Flags().GetString("path")
|
||||||
service, _ := cmd.Flags().GetString("service")
|
service, _ := cmd.Flags().GetString("service")
|
||||||
|
purpose, _ := cmd.Flags().GetString("purpose")
|
||||||
asJSON, _ := cmd.Flags().GetBool("json")
|
asJSON, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
if (path == "") == (service == "") {
|
if (path == "") == (service == "") {
|
||||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if purpose != "password" && purpose != "u2f" {
|
||||||
|
log.Fatalf("Error: --purpose must be password or u2f")
|
||||||
|
}
|
||||||
|
|
||||||
var result sharedpam.LockscreenPamValidation
|
var result sharedpam.LockscreenPamValidation
|
||||||
switch {
|
switch {
|
||||||
case service != "":
|
case service != "":
|
||||||
result = sharedpam.ValidateLockscreenPamService(service)
|
if purpose == "u2f" {
|
||||||
|
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
||||||
|
} else {
|
||||||
|
result = sharedpam.ValidateLockscreenPamService(service)
|
||||||
|
}
|
||||||
case !filepath.IsAbs(path):
|
case !filepath.IsAbs(path):
|
||||||
result = sharedpam.LockscreenPamValidation{
|
result = sharedpam.LockscreenPamValidation{
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -114,7 +123,11 @@ var authValidateCmd = &cobra.Command{
|
|||||||
Errors: []string{"--path must be an absolute file path"},
|
Errors: []string{"--path must be an absolute file path"},
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
if purpose == "u2f" {
|
||||||
|
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
||||||
|
} else {
|
||||||
|
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if asJSON {
|
if asJSON {
|
||||||
@@ -159,6 +172,7 @@ func init() {
|
|||||||
|
|
||||||
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
||||||
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
||||||
|
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
||||||
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ func updateOtherDistros() error {
|
|||||||
return fmt.Errorf("failed to fetch changes: %w", err)
|
return fmt.Errorf("failed to fetch changes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := updateSubmodules(); err != nil {
|
||||||
|
return fmt.Errorf("failed to update submodules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if currentTag != "" {
|
if currentTag != "" {
|
||||||
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
|
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
|
||||||
latestTagOutput, err := latestTagCmd.Output()
|
latestTagOutput, err := latestTagCmd.Output()
|
||||||
@@ -291,6 +295,10 @@ func updateOtherDistros() error {
|
|||||||
return fmt.Errorf("update cancelled")
|
return fmt.Errorf("update cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := updateSubmodules(); err != nil {
|
||||||
|
return fmt.Errorf("failed to update submodules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
|
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -320,10 +328,21 @@ func updateOtherDistros() error {
|
|||||||
return fmt.Errorf("update cancelled")
|
return fmt.Errorf("update cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := updateSubmodules(); err != nil {
|
||||||
|
return fmt.Errorf("failed to update submodules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("\nUpdate complete!")
|
fmt.Println("\nUpdate complete!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateSubmodules() error {
|
||||||
|
submoduleCmd := exec.Command("git", "submodule", "update", "--init", "--recursive")
|
||||||
|
submoduleCmd.Stdout = os.Stdout
|
||||||
|
submoduleCmd.Stderr = os.Stderr
|
||||||
|
return submoduleCmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
func offerReclone(dmsPath string) bool {
|
func offerReclone(dmsPath string) bool {
|
||||||
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
|
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
@@ -342,7 +361,7 @@ func offerReclone(dmsPath string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Cloning fresh copy...")
|
fmt.Println("Cloning fresh copy...")
|
||||||
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
|
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
|
||||||
cloneCmd.Stdout = os.Stdout
|
cloneCmd.Stdout = os.Stdout
|
||||||
cloneCmd.Stderr = os.Stderr
|
cloneCmd.Stderr = os.Stderr
|
||||||
if err := cloneCmd.Run(); err != nil {
|
if err := cloneCmd.Run(); err != nil {
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ Modes:
|
|||||||
full - Capture the focused output
|
full - Capture the focused output
|
||||||
all - Capture all outputs combined
|
all - Capture all outputs combined
|
||||||
output - Capture a specific output by name
|
output - Capture a specific output by name
|
||||||
window - Capture the focused window (Hyprland/Mango)
|
window - Capture the focused window (Hyprland/Mango/niri)
|
||||||
last - Capture the last selected region
|
last - Capture the last selected region
|
||||||
|
scroll - Select a region, then scroll to capture a stitched tall image
|
||||||
|
|
||||||
Output format (--format):
|
Output format (--format):
|
||||||
png - PNG format (default)
|
png - PNG format (default)
|
||||||
@@ -72,7 +73,9 @@ Examples:
|
|||||||
dms screenshot --no-confirm # Region capture on mouse release
|
dms screenshot --no-confirm # Region capture on mouse release
|
||||||
dms screenshot --cursor=on # Include cursor
|
dms screenshot --cursor=on # Include cursor
|
||||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||||
dms screenshot --json # Print capture metadata as JSON`,
|
dms screenshot --json # Print capture metadata as JSON
|
||||||
|
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||||
|
dms screenshot scroll --interval 250`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssRegionCmd = &cobra.Command{
|
var ssRegionCmd = &cobra.Command{
|
||||||
@@ -110,10 +113,33 @@ If no previous region exists, falls back to interactive selection.`,
|
|||||||
var ssWindowCmd = &cobra.Command{
|
var ssWindowCmd = &cobra.Command{
|
||||||
Use: "window",
|
Use: "window",
|
||||||
Short: "Capture the focused window",
|
Short: "Capture the focused window",
|
||||||
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||||
Run: runScreenshotWindow,
|
Run: runScreenshotWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ssScrollInterval int
|
||||||
|
|
||||||
|
var ssScrollCmd = &cobra.Command{
|
||||||
|
Use: "scroll",
|
||||||
|
Short: "Capture a scrolling region stitched into one tall image",
|
||||||
|
Long: `Select a region, then scroll the content beneath with the mouse wheel or
|
||||||
|
touchpad while frames are captured and stitched vertically. Finish with the
|
||||||
|
on-screen done button; cancel with the cancel button. Enter and Esc work
|
||||||
|
everywhere: most compositors hold the keyboard on the overlay (keyboard
|
||||||
|
scrolling does not reach the app there), while Hyprland leaves the keyboard
|
||||||
|
with the application — keyboard scrolling works, and Enter/Esc act through
|
||||||
|
temporary global binds for the session. The cursor is never included in
|
||||||
|
frames.
|
||||||
|
|
||||||
|
Frames are stitched continuously while scrolling, and revisited content is
|
||||||
|
never duplicated — scrolling up past the starting point extends the image
|
||||||
|
upward. Content jumped past faster than capture can follow is skipped rather
|
||||||
|
than stitched incorrectly.
|
||||||
|
|
||||||
|
Rotated outputs are not supported.`,
|
||||||
|
Run: runScreenshotScroll,
|
||||||
|
}
|
||||||
|
|
||||||
var ssListCmd = &cobra.Command{
|
var ssListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List available outputs",
|
Short: "List available outputs",
|
||||||
@@ -143,7 +169,10 @@ func init() {
|
|||||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||||
|
|
||||||
|
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||||
|
|
||||||
screenshotCmd.AddCommand(ssRegionCmd)
|
screenshotCmd.AddCommand(ssRegionCmd)
|
||||||
|
screenshotCmd.AddCommand(ssScrollCmd)
|
||||||
screenshotCmd.AddCommand(ssFullCmd)
|
screenshotCmd.AddCommand(ssFullCmd)
|
||||||
screenshotCmd.AddCommand(ssAllCmd)
|
screenshotCmd.AddCommand(ssAllCmd)
|
||||||
screenshotCmd.AddCommand(ssOutputCmd)
|
screenshotCmd.AddCommand(ssOutputCmd)
|
||||||
@@ -249,7 +278,7 @@ func runScreenshot(config screenshot.Config) {
|
|||||||
|
|
||||||
// Region select needs the keyboard; drop popout grabs for its duration.
|
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||||
result, err := func() (*screenshot.CaptureResult, error) {
|
result, err := func() (*screenshot.CaptureResult, error) {
|
||||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
|
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
||||||
if interactive {
|
if interactive {
|
||||||
setPopoutScreenshotMode(true)
|
setPopoutScreenshotMode(true)
|
||||||
defer setPopoutScreenshotMode(false)
|
defer setPopoutScreenshotMode(false)
|
||||||
@@ -437,6 +466,12 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
|||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runScreenshotScroll(cmd *cobra.Command, args []string) {
|
||||||
|
config := getScreenshotConfig(screenshot.ModeScroll)
|
||||||
|
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
|
||||||
|
runScreenshot(config)
|
||||||
|
}
|
||||||
|
|
||||||
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
||||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||||
runScreenshot(config)
|
runScreenshot(config)
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
|||||||
Progress: 0.90,
|
Progress: 0.90,
|
||||||
Step: "Cloning DankMaterialShell...",
|
Step: "Cloning DankMaterialShell...",
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git",
|
CommandInfo: "git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git",
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := filepath.Dir(dmsPath)
|
configDir := filepath.Dir(dmsPath)
|
||||||
@@ -528,7 +528,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
|||||||
return fmt.Errorf("failed to create quickshell config directory: %w", err)
|
return fmt.Errorf("failed to create quickshell config directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cloneCmd := exec.CommandContext(ctx, "git", "clone",
|
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recurse-submodules",
|
||||||
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
|
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
|
||||||
if err := cloneCmd.Run(); err != nil {
|
if err := cloneCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
|
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
|
||||||
@@ -553,6 +553,8 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.syncDMSSubmodules(ctx, dmsPath)
|
||||||
|
|
||||||
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
|
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
|
||||||
m.log("DankMaterialShell cloned successfully")
|
m.log("DankMaterialShell cloned successfully")
|
||||||
return nil
|
return nil
|
||||||
@@ -591,6 +593,8 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.syncDMSSubmodules(ctx, dmsPath)
|
||||||
|
|
||||||
m.log("DankMaterialShell updated successfully (git variant)")
|
m.log("DankMaterialShell updated successfully (git variant)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -609,10 +613,19 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.syncDMSSubmodules(ctx, dmsPath)
|
||||||
|
|
||||||
m.log(fmt.Sprintf("Updated to tag: %s", latestTag))
|
m.log(fmt.Sprintf("Updated to tag: %s", latestTag))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ManualPackageInstaller) syncDMSSubmodules(ctx context.Context, dmsPath string) {
|
||||||
|
submoduleCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "submodule", "update", "--init", "--recursive")
|
||||||
|
if err := submoduleCmd.Run(); err != nil {
|
||||||
|
m.logError("Failed to update submodules", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
m.log("Installing xwayland-satellite from source...")
|
m.log("Installing xwayland-satellite from source...")
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
|
|||||||
|
|
||||||
func shouldPreferMinimalInstall(pkg string) bool {
|
func shouldPreferMinimalInstall(pkg string) bool {
|
||||||
switch pkg {
|
switch pkg {
|
||||||
case "niri", "niri-git":
|
case "niri", "niri-git", "hyprland", "hyprland-git":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ func GetLogger() *Logger {
|
|||||||
logger = &Logger{base}
|
logger = &Logger{base}
|
||||||
|
|
||||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||||
_ = SetLogFile(path)
|
logMu.Lock()
|
||||||
|
_ = setLogFile(logger, path)
|
||||||
|
logMu.Unlock()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return logger
|
return logger
|
||||||
@@ -145,15 +147,18 @@ func SetLevel(level string) {
|
|||||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||||
// the file stays plain while stderr keeps its colors.
|
// the file stays plain while stderr keeps its colors.
|
||||||
func SetLogFile(path string) error {
|
func SetLogFile(path string) error {
|
||||||
|
l := GetLogger()
|
||||||
logMu.Lock()
|
logMu.Lock()
|
||||||
defer logMu.Unlock()
|
defer logMu.Unlock()
|
||||||
|
return setLogFile(l, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLogFile(l *Logger, path string) error {
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
logFile.Close()
|
logFile.Close()
|
||||||
logFile = nil
|
logFile = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
l := GetLogger()
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
l.SetOutput(logStderr)
|
l.SetOutput(logStderr)
|
||||||
applyColorProfile(l, logStderr)
|
applyColorProfile(l, logStderr)
|
||||||
|
|||||||
@@ -646,6 +646,7 @@ type lockscreenPamAnalysis struct {
|
|||||||
inlineFingerprint bool
|
inlineFingerprint bool
|
||||||
inlineU2f bool
|
inlineU2f bool
|
||||||
modules []string
|
modules []string
|
||||||
|
authModules []string
|
||||||
unknownDirectives []string
|
unknownDirectives []string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -730,6 +731,9 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack
|
|||||||
}
|
}
|
||||||
if !foundModule && strings.HasSuffix(field, ".so") {
|
if !foundModule && strings.HasSuffix(field, ".so") {
|
||||||
acc.modules = append(acc.modules, field)
|
acc.modules = append(acc.modules, field)
|
||||||
|
if lineType == "auth" {
|
||||||
|
acc.authModules = append(acc.authModules, field)
|
||||||
|
}
|
||||||
foundModule = true
|
foundModule = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,6 +778,14 @@ func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
|
|||||||
return validateLockscreenPam("", path, defaultValidateDeps())
|
return validateLockscreenPam("", path, defaultValidateDeps())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
|
||||||
|
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
|
||||||
|
return validateLockscreenU2fPam("", path, defaultValidateDeps())
|
||||||
|
}
|
||||||
|
|
||||||
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||||
result := LockscreenPamValidation{
|
result := LockscreenPamValidation{
|
||||||
MissingModules: []string{},
|
MissingModules: []string{},
|
||||||
@@ -838,6 +850,64 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||||
|
result := validateLockscreenPam(serviceName, path, deps)
|
||||||
|
if result.Path == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
||||||
|
analysis := resolver.analyzePath(result.Path)
|
||||||
|
if analysis.err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredWarnings := result.Warnings[:0]
|
||||||
|
for _, warning := range result.Warnings {
|
||||||
|
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredWarnings = append(filteredWarnings, warning)
|
||||||
|
}
|
||||||
|
result.Warnings = filteredWarnings
|
||||||
|
|
||||||
|
hasU2fAuth := false
|
||||||
|
unsafeModules := []string{}
|
||||||
|
unsafeSeen := map[string]bool{}
|
||||||
|
for _, ref := range analysis.authModules {
|
||||||
|
name := filepath.Base(ref)
|
||||||
|
if name == "pam_u2f.so" {
|
||||||
|
hasU2fAuth = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
if !unsafeSeen[name] {
|
||||||
|
unsafeSeen[name] = true
|
||||||
|
unsafeModules = append(unsafeModules, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasU2fAuth {
|
||||||
|
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
|
||||||
|
}
|
||||||
|
for _, name := range unsafeModules {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
|
||||||
|
}
|
||||||
|
for _, name := range result.MissingModules {
|
||||||
|
if strings.Contains(name, "pam_u2f") {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Valid = len(result.Errors) == 0
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
|
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
|
||||||
if filepath.IsAbs(ref) {
|
if filepath.IsAbs(ref) {
|
||||||
_, err := deps.stat(ref)
|
_, err := deps.stat(ref)
|
||||||
@@ -895,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string {
|
|||||||
|
|
||||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||||
if deps.isNixOS() {
|
if deps.isNixOS() {
|
||||||
logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
|
logFunc("ℹ NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
||||||
}
|
}
|
||||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
|
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
|
||||||
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
||||||
@@ -994,6 +994,84 @@ func TestValidateLockscreenPam(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateLockscreenU2fPam(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newPamTestEnv(t)
|
||||||
|
env.availableModules["pam_u2f.so"] = true
|
||||||
|
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
|
||||||
|
|
||||||
|
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||||
|
if !result.Valid {
|
||||||
|
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
|
||||||
|
}
|
||||||
|
if !result.InlineU2f {
|
||||||
|
t.Fatalf("expected inline U2F detection, got %+v", result)
|
||||||
|
}
|
||||||
|
if containsSubstr(result.Warnings, "double-prompt") {
|
||||||
|
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newPamTestEnv(t)
|
||||||
|
env.availableModules["pam_unix.so"] = true
|
||||||
|
env.availableModules["pam_u2f.so"] = true
|
||||||
|
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
|
||||||
|
|
||||||
|
result := validateLockscreenU2fPam("login", "", env.validateDeps())
|
||||||
|
if result.Valid {
|
||||||
|
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
|
||||||
|
}
|
||||||
|
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
|
||||||
|
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newPamTestEnv(t)
|
||||||
|
env.availableModules["pam_unix.so"] = true
|
||||||
|
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
|
||||||
|
|
||||||
|
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
|
||||||
|
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
|
||||||
|
t.Fatalf("expected missing-U2F error, got %+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newPamTestEnv(t)
|
||||||
|
env.availableModules["pam_u2f_helper.so"] = true
|
||||||
|
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
|
||||||
|
|
||||||
|
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
|
||||||
|
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
|
||||||
|
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newPamTestEnv(t)
|
||||||
|
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
|
||||||
|
|
||||||
|
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||||
|
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
|
||||||
|
t.Fatalf("expected missing-module error, got %+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func containsSubstr(items []string, substr string) bool {
|
func containsSubstr(items []string, substr string) bool {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if strings.Contains(item, substr) {
|
if strings.Contains(item, substr) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package qmlchecks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGreeterExternalAuthStatusUsesEffectiveFingerprintAvailability(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read greeter QML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
for _, required := range []string{
|
||||||
|
"readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f",
|
||||||
|
"if (greeterPamHasFprint && greeterPamHasU2f)",
|
||||||
|
"if (greeterPamHasFprint)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, required) {
|
||||||
|
t.Fatalf("greeter external-auth status must contain %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package qmlchecks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGreeterRememberLastSessionFallsBackToDesktopID(t *testing.T) {
|
|
||||||
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read greeter QML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := string(data)
|
|
||||||
if !strings.Contains(content, "GreetdMemory.lastSessionDesktopId || desktopIdFromPath(GreetdMemory.lastSessionId)") {
|
|
||||||
t.Fatalf("remembered greeter sessions should derive a desktop id from legacy absolute session paths")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "GreeterState.sessionDesktopIds[i] === savedDesktopId") {
|
|
||||||
t.Fatalf("remembered greeter sessions should match current sessions by desktop id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,3 +23,25 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
|||||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read lock screen PAM QML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
for _, required := range []string{
|
||||||
|
"SettingsData.lockPamExternallyManaged",
|
||||||
|
"SettingsData.lockU2fPamPath",
|
||||||
|
"customU2fPamActive",
|
||||||
|
"u2fSuppressedByPrimaryPam",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(content, required) {
|
||||||
|
t.Fatalf("lock screen PAM must contain %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
|
||||||
|
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,41 +30,30 @@ func DetectCompositor() Compositor {
|
|||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
candidates := []struct {
|
||||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
socket string
|
||||||
swaySocket := os.Getenv("SWAYSOCK")
|
needsStat bool
|
||||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
compositor Compositor
|
||||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
}{
|
||||||
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
|
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
||||||
|
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
||||||
|
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
||||||
|
{os.Getenv("MIRACLESOCK"), true, CompositorMiracle},
|
||||||
|
{os.Getenv("SWAYSOCK"), true, CompositorSway},
|
||||||
|
{os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"), false, CompositorHyprland},
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
// A stale env var from a previous session must not mask the live compositor
|
||||||
case mangoSocket != "":
|
for _, c := range candidates {
|
||||||
if _, err := os.Stat(mangoSocket); err == nil {
|
if c.socket == "" {
|
||||||
detectedCompositor = CompositorMango
|
continue
|
||||||
return detectedCompositor
|
|
||||||
}
|
}
|
||||||
case niriSocket != "":
|
if c.needsStat {
|
||||||
if _, err := os.Stat(niriSocket); err == nil {
|
if _, err := os.Stat(c.socket); err != nil {
|
||||||
detectedCompositor = CompositorNiri
|
continue
|
||||||
return detectedCompositor
|
}
|
||||||
}
|
}
|
||||||
case scrollSocket != "":
|
detectedCompositor = c.compositor
|
||||||
if _, err := os.Stat(scrollSocket); err == nil {
|
|
||||||
detectedCompositor = CompositorScroll
|
|
||||||
return detectedCompositor
|
|
||||||
}
|
|
||||||
case miracleSocket != "":
|
|
||||||
if _, err := os.Stat(miracleSocket); err == nil {
|
|
||||||
detectedCompositor = CompositorMiracle
|
|
||||||
return detectedCompositor
|
|
||||||
}
|
|
||||||
case swaySocket != "":
|
|
||||||
if _, err := os.Stat(swaySocket); err == nil {
|
|
||||||
detectedCompositor = CompositorSway
|
|
||||||
return detectedCompositor
|
|
||||||
}
|
|
||||||
case hyprlandSig != "":
|
|
||||||
detectedCompositor = CompositorHyprland
|
|
||||||
return detectedCompositor
|
return detectedCompositor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +79,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
|||||||
case CompositorMango:
|
case CompositorMango:
|
||||||
return getMangoActiveWindow()
|
return getMangoActiveWindow()
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
|
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,58 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ImageToBuffer(img image.Image) (*ShmBuffer, error) {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
|
buf, err := CreateShmBuffer(w, h, w*4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buf.Data()
|
||||||
|
switch src := img.(type) {
|
||||||
|
case *image.NRGBA:
|
||||||
|
for y := range h {
|
||||||
|
srcOff := y * src.Stride
|
||||||
|
dstOff := y * buf.Stride
|
||||||
|
for x := range w {
|
||||||
|
si, di := srcOff+x*4, dstOff+x*4
|
||||||
|
a := uint32(src.Pix[si+3])
|
||||||
|
data[di+0] = uint8(uint32(src.Pix[si+2]) * a / 255)
|
||||||
|
data[di+1] = uint8(uint32(src.Pix[si+1]) * a / 255)
|
||||||
|
data[di+2] = uint8(uint32(src.Pix[si+0]) * a / 255)
|
||||||
|
data[di+3] = uint8(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *image.RGBA:
|
||||||
|
for y := range h {
|
||||||
|
srcOff := y * src.Stride
|
||||||
|
dstOff := y * buf.Stride
|
||||||
|
for x := range w {
|
||||||
|
si, di := srcOff+x*4, dstOff+x*4
|
||||||
|
data[di+0] = src.Pix[si+2]
|
||||||
|
data[di+1] = src.Pix[si+1]
|
||||||
|
data[di+2] = src.Pix[si+0]
|
||||||
|
data[di+3] = src.Pix[si+3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for y := range h {
|
||||||
|
dstOff := y * buf.Stride
|
||||||
|
for x := range w {
|
||||||
|
cr, cg, cb, ca := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
|
||||||
|
di := dstOff + x*4
|
||||||
|
data[di+0] = uint8(cb >> 8)
|
||||||
|
data[di+1] = uint8(cg >> 8)
|
||||||
|
data[di+2] = uint8(cr >> 8)
|
||||||
|
data[di+3] = uint8(ca >> 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.Format = FormatARGB8888
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
func EncodePNG(w io.Writer, img image.Image) error {
|
func EncodePNG(w io.Writer, img image.Image) error {
|
||||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||||
return enc.Encode(w, img)
|
return enc.Encode(w, img)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const niriScreenshotTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// CaptureNiriWindowImage captures the focused window through niri's
|
||||||
|
// ScreenshotWindow action; niri replies before writing the file, so a second
|
||||||
|
// event-stream connection waits for ScreenshotCaptured. niri also copies the
|
||||||
|
// capture to its own clipboard, which cannot be disabled.
|
||||||
|
func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
|
||||||
|
socket := os.Getenv("NIRI_SOCKET")
|
||||||
|
if socket == "" {
|
||||||
|
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
||||||
|
|
||||||
|
events, err := subscribeNiriEvents(socket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer events.Close()
|
||||||
|
|
||||||
|
if err := requestNiriWindowScreenshot(socket, path, showPointer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
if err := awaitNiriScreenshot(events, path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open niri screenshot: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, err := png.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode niri screenshot: %w", err)
|
||||||
|
}
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribeNiriEvents(socket string) (net.Conn, error) {
|
||||||
|
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect niri socket: %w", err)
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(niriScreenshotTimeout))
|
||||||
|
|
||||||
|
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("subscribe niri events: %w", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitNiriScreenshot(events net.Conn, path string) error {
|
||||||
|
scanner := bufio.NewScanner(events)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
var event struct {
|
||||||
|
ScreenshotCaptured *struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"ScreenshotCaptured"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(scanner.Bytes(), &event) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if event.ScreenshotCaptured != nil && event.ScreenshotCaptured.Path == path {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("await niri screenshot: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("niri event stream closed before screenshot completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNiriWindowScreenshot(socket, path string, showPointer bool) error {
|
||||||
|
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect niri socket: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
|
||||||
|
request := map[string]any{
|
||||||
|
"Action": map[string]any{
|
||||||
|
"ScreenshotWindow": map[string]any{
|
||||||
|
"id": nil,
|
||||||
|
"write_to_disk": true,
|
||||||
|
"show_pointer": showPointer,
|
||||||
|
"path": path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("niri request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("niri reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(line, &reply); err != nil {
|
||||||
|
return fmt.Errorf("parse niri reply: %w", err)
|
||||||
|
}
|
||||||
|
if raw, ok := reply["Err"]; ok {
|
||||||
|
var msg string
|
||||||
|
_ = json.Unmarshal(raw, &msg)
|
||||||
|
return fmt.Errorf("niri screenshot: %s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Screenshoter) captureNiriWindow() (*CaptureResult, error) {
|
||||||
|
img, err := CaptureNiriWindowImage(s.config.Cursor == CursorOn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := ImageToBuffer(img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := 1.0
|
||||||
|
if output := s.findOutputByName(GetFocusedMonitor()); output != nil {
|
||||||
|
scale = output.effectiveScale()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CaptureResult{
|
||||||
|
Buffer: buf,
|
||||||
|
YInverted: false,
|
||||||
|
Format: uint32(FormatARGB8888),
|
||||||
|
Scale: scale,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -95,6 +95,9 @@ type RegionSelector struct {
|
|||||||
showCapturedCursor bool
|
showCapturedCursor bool
|
||||||
shiftHeld bool
|
shiftHeld bool
|
||||||
|
|
||||||
|
phase selectorPhase
|
||||||
|
scroll *scrollSession
|
||||||
|
|
||||||
running bool
|
running bool
|
||||||
cancelled bool
|
cancelled bool
|
||||||
result Region
|
result Region
|
||||||
@@ -167,11 +170,15 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
|||||||
|
|
||||||
r.running = true
|
r.running = true
|
||||||
for r.running {
|
for r.running {
|
||||||
if err := r.ctx.Dispatch(); err != nil {
|
if err := r.dispatchOrTick(); err != nil {
|
||||||
return nil, false, fmt.Errorf("dispatch: %w", err)
|
return nil, false, fmt.Errorf("dispatch: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.scroll != nil && r.scroll.abortErr != nil {
|
||||||
|
return nil, false, r.scroll.abortErr
|
||||||
|
}
|
||||||
|
|
||||||
if r.cancelled || r.capturedBuffer == nil {
|
if r.cancelled || r.capturedBuffer == nil {
|
||||||
return nil, r.cancelled, nil
|
return nil, r.cancelled, nil
|
||||||
}
|
}
|
||||||
@@ -186,6 +193,10 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
|||||||
scale = s
|
scale = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.scroll != nil {
|
||||||
|
yInverted = false
|
||||||
|
format = uint32(r.scroll.format)
|
||||||
|
}
|
||||||
|
|
||||||
return &CaptureResult{
|
return &CaptureResult{
|
||||||
Buffer: r.capturedBuffer,
|
Buffer: r.capturedBuffer,
|
||||||
@@ -696,7 +707,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
|
|||||||
}
|
}
|
||||||
slot.pool = pool
|
slot.pool = pool
|
||||||
|
|
||||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
|
// niri latches surface opacity from the first buffer's format
|
||||||
|
// (observed), so slots are ARGB from the start with A=255 when opaque
|
||||||
|
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), alphaFormat(os.screenFormat))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("create render slot wl_buffer failed", "err", err)
|
log.Error("create render slot wl_buffer failed", "err", err)
|
||||||
pool.Destroy()
|
pool.Destroy()
|
||||||
@@ -738,8 +751,9 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
|||||||
|
|
||||||
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
||||||
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
|
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
|
||||||
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
|
// selection edges are inclusive; the exclusive width edge is one device px past it
|
||||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
|
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width)*scaleX - scaleX
|
||||||
|
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
|
||||||
|
|
||||||
r.selection.hasSelection = true
|
r.selection.hasSelection = true
|
||||||
r.selection.dragging = false
|
r.selection.dragging = false
|
||||||
@@ -769,10 +783,13 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slot.shm.CopyFrom(srcBuf)
|
switch r.phase {
|
||||||
|
case phaseScroll:
|
||||||
// Draw overlay (dimming + selection) into this slot
|
r.drawScrollOverlay(os, slot.shm)
|
||||||
r.drawOverlay(os, slot.shm)
|
default:
|
||||||
|
slot.shm.CopyFrom(srcBuf)
|
||||||
|
r.drawOverlay(os, slot.shm)
|
||||||
|
}
|
||||||
|
|
||||||
if os.viewport != nil {
|
if os.viewport != nil {
|
||||||
_ = os.wlSurface.SetBufferScale(1)
|
_ = os.wlSurface.SetBufferScale(1)
|
||||||
@@ -808,6 +825,8 @@ func (r *RegionSelector) cleanup() {
|
|||||||
r.cursorBuffer.Close()
|
r.cursorBuffer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.cleanupScroll()
|
||||||
|
|
||||||
for _, os := range r.surfaces {
|
for _, os := range r.surfaces {
|
||||||
for _, slot := range os.slots {
|
for _, slot := range os.slots {
|
||||||
if slot == nil {
|
if slot == nil {
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ func (r *RegionSelector) setupPointerHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.phase == phaseScroll {
|
||||||
|
if e.Button != 0x110 || e.State != 1 || r.activeSurface != r.selection.surface {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.scrollBarHit(r.pointerX, r.pointerY) {
|
||||||
|
case "done":
|
||||||
|
r.finishScroll()
|
||||||
|
case "cancel":
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch e.Button {
|
switch e.Button {
|
||||||
case 0x110: // BTN_LEFT
|
case 0x110: // BTN_LEFT
|
||||||
switch e.State {
|
switch e.State {
|
||||||
@@ -135,6 +149,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.phase == phaseScroll {
|
||||||
|
switch e.Key {
|
||||||
|
case 1:
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
case 28, 96:
|
||||||
|
r.finishScroll()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch e.Key {
|
switch e.Key {
|
||||||
case 1:
|
case 1:
|
||||||
r.cancelled = true
|
r.cancelled = true
|
||||||
@@ -152,17 +177,15 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) finishSelection() {
|
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
||||||
if r.selection.surface == nil {
|
if r.selection.surface == nil {
|
||||||
r.running = false
|
return nil, 0, 0, 0, 0
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
os := r.selection.surface
|
os := r.selection.surface
|
||||||
srcBuf := r.getSourceBuffer(os)
|
srcBuf := r.getSourceBuffer(os)
|
||||||
if srcBuf == nil {
|
if srcBuf == nil {
|
||||||
r.running = false
|
return nil, 0, 0, 0, 0
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
||||||
@@ -181,24 +204,10 @@ func (r *RegionSelector) finishSelection() {
|
|||||||
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
||||||
}
|
}
|
||||||
|
|
||||||
bx1 := int(x1 * scaleX)
|
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
|
||||||
by1 := int(y1 * scaleY)
|
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
|
||||||
bx2 := int(x2 * scaleX)
|
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
|
||||||
by2 := int(y2 * scaleY)
|
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
|
||||||
|
|
||||||
// Clamp to buffer bounds
|
|
||||||
if bx1 < 0 {
|
|
||||||
bx1 = 0
|
|
||||||
}
|
|
||||||
if by1 < 0 {
|
|
||||||
by1 = 0
|
|
||||||
}
|
|
||||||
if bx2 > srcBuf.Width {
|
|
||||||
bx2 = srcBuf.Width
|
|
||||||
}
|
|
||||||
if by2 > srcBuf.Height {
|
|
||||||
by2 = srcBuf.Height
|
|
||||||
}
|
|
||||||
|
|
||||||
w, h := bx2-bx1+1, by2-by1+1
|
w, h := bx2-bx1+1, by2-by1+1
|
||||||
if r.shiftHeld && w != h {
|
if r.shiftHeld && w != h {
|
||||||
@@ -215,7 +224,23 @@ func (r *RegionSelector) finishSelection() {
|
|||||||
h = 1
|
h = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cropped buffer and copy pixels directly
|
return os, bx1, by1, w, h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) finishSelection() {
|
||||||
|
os, bx1, by1, w, h := r.selectionDeviceRect()
|
||||||
|
if os == nil {
|
||||||
|
r.running = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.screenshoter != nil && r.screenshoter.config.Mode == ModeScroll {
|
||||||
|
r.enterScrollPhase(os, bx1, by1, w, h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srcBuf := r.getSourceBuffer(os)
|
||||||
|
|
||||||
cropped, err := CreateShmBuffer(w, h, w*4)
|
cropped, err := CreateShmBuffer(w, h, w*4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.running = false
|
r.running = false
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
w, h := renderBuf.Width, renderBuf.Height
|
w, h := renderBuf.Width, renderBuf.Height
|
||||||
format := os.screenFormat
|
format := os.screenFormat
|
||||||
|
|
||||||
// Dim the entire buffer
|
// dim, forcing alpha: the X-format source's padding byte is undefined
|
||||||
for y := 0; y < h; y++ {
|
for y := 0; y < h; y++ {
|
||||||
off := y * stride
|
off := y * stride
|
||||||
for x := 0; x < w; x++ {
|
for x := 0; x < w; x++ {
|
||||||
@@ -68,6 +68,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
|
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
|
||||||
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
|
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
|
||||||
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
|
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
|
||||||
|
data[i+3] = 255
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
data[di+0] = srcData[si+0]
|
data[di+0] = srcData[si+0]
|
||||||
data[di+1] = srcData[si+1]
|
data[di+1] = srcData[si+1]
|
||||||
data[di+2] = srcData[si+2]
|
data[di+2] = srcData[si+2]
|
||||||
data[di+3] = srcData[si+3]
|
data[di+3] = 255
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +127,81 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
|||||||
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
|
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||||
|
data := renderBuf.Data()
|
||||||
|
stride := renderBuf.Stride
|
||||||
|
w, h := renderBuf.Width, renderBuf.Height
|
||||||
|
|
||||||
|
// 40% premultiplied scrim
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
off := y * stride
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
i := off + x*4
|
||||||
|
if i+3 >= len(data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 102
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := r.scroll
|
||||||
|
if s == nil || r.selection.surface != os {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// hole oversized 2px so overlay pixels never land in captured frames
|
||||||
|
holeX := s.holeX - 2
|
||||||
|
holeY := s.holeY - 2
|
||||||
|
holeW := s.holeW + 4
|
||||||
|
holeH := s.holeH + 4
|
||||||
|
|
||||||
|
x1 := clamp(holeX, 0, w)
|
||||||
|
y1 := clamp(holeY, 0, h)
|
||||||
|
x2 := clamp(holeX+holeW, 0, w)
|
||||||
|
y2 := clamp(holeY+holeH, 0, h)
|
||||||
|
|
||||||
|
for y := y1; y < y2; y++ {
|
||||||
|
off := y * stride
|
||||||
|
for x := x1; x < x2; x++ {
|
||||||
|
i := off + x*4
|
||||||
|
if i+3 >= len(data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
|
||||||
|
r.drawScrollBar(data, stride, w, h, os.screenFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
||||||
|
s := r.scroll
|
||||||
|
style := LoadOverlayStyle()
|
||||||
|
const charH = 12
|
||||||
|
|
||||||
|
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
||||||
|
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
|
||||||
|
|
||||||
|
labelY := s.doneY + (s.btnH-charH)/2
|
||||||
|
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
|
||||||
|
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
||||||
|
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
||||||
|
|
||||||
|
r.fillRect(data, stride, bufW, bufH, s.cancelX, s.cancelY, s.cancelW, s.btnH,
|
||||||
|
70, 70, 70, 255, format)
|
||||||
|
r.drawText(data, stride, bufW, bufH, s.cancelX+12, labelY, "cancel",
|
||||||
|
style.TextR, style.TextG, style.TextB, format)
|
||||||
|
|
||||||
|
rows := 0
|
||||||
|
if s.st != nil {
|
||||||
|
rows = s.st.rows()
|
||||||
|
}
|
||||||
|
counter := fmt.Sprintf("%d shots %dpx", s.kept, rows)
|
||||||
|
r.drawText(data, stride, bufW, bufH, s.cancelX+s.cancelW+16, labelY, counter,
|
||||||
|
style.TextR, style.TextG, style.TextB, format)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
||||||
if r.selection.dragging {
|
if r.selection.dragging {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
|
|||||||
switch s.config.Mode {
|
switch s.config.Mode {
|
||||||
case ModeLastRegion:
|
case ModeLastRegion:
|
||||||
return s.captureLastRegion()
|
return s.captureLastRegion()
|
||||||
case ModeRegion:
|
case ModeRegion, ModeScroll:
|
||||||
return s.captureRegion()
|
return s.captureRegion()
|
||||||
case ModeWindow:
|
case ModeWindow:
|
||||||
return s.captureWindow()
|
return s.captureWindow()
|
||||||
@@ -145,6 +145,10 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||||
|
if DetectCompositor() == CompositorNiri {
|
||||||
|
return s.captureNiriWindow()
|
||||||
|
}
|
||||||
|
|
||||||
geom, err := GetActiveWindow()
|
geom, err := GetActiveWindow()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
type selectorPhase int
|
||||||
|
|
||||||
|
const (
|
||||||
|
phaseSelect selectorPhase = iota
|
||||||
|
phaseScroll
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scrollMaxFailures = 5
|
||||||
|
scrollSeamTicks = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type scrollSession struct {
|
||||||
|
output *WaylandOutput
|
||||||
|
// wire coords for CaptureOutputRegion (logical or device px per compositor)
|
||||||
|
capX, capY, capW, capH int32
|
||||||
|
// device-pixel rect in the overlay buffer, for hole/border drawing
|
||||||
|
holeX, holeY, holeW, holeH int
|
||||||
|
|
||||||
|
interval time.Duration
|
||||||
|
nextTick time.Time
|
||||||
|
inFlight bool
|
||||||
|
failures int
|
||||||
|
kept int
|
||||||
|
abortErr error
|
||||||
|
|
||||||
|
buf *ShmBuffer
|
||||||
|
pool *client.ShmPool
|
||||||
|
wlBuf *client.Buffer
|
||||||
|
frame *wlr_screencopy.ZwlrScreencopyFrameV1
|
||||||
|
format PixelFormat
|
||||||
|
frameW, frameH int
|
||||||
|
yInverted bool
|
||||||
|
|
||||||
|
prevSig []float32
|
||||||
|
prevPlaced bool
|
||||||
|
unmatched bool
|
||||||
|
unmatchedTicks int
|
||||||
|
|
||||||
|
// control bar geometry in overlay buffer pixels
|
||||||
|
barX, barY, barW, barH int
|
||||||
|
doneX, doneY, doneW int
|
||||||
|
cancelX, cancelY int
|
||||||
|
cancelW int
|
||||||
|
btnH int
|
||||||
|
|
||||||
|
sigCh chan os.Signal
|
||||||
|
keysBound bool
|
||||||
|
|
||||||
|
st *stitcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) dispatchOrTick() error {
|
||||||
|
timeout := -1
|
||||||
|
if s := r.scroll; r.phase == phaseScroll && s != nil && s.sigCh != nil {
|
||||||
|
select {
|
||||||
|
case sig := <-s.sigCh:
|
||||||
|
switch sig {
|
||||||
|
case unix.SIGUSR2:
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
default:
|
||||||
|
r.finishScroll()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := r.scroll; r.phase == phaseScroll && s.abortErr == nil && (s.st == nil || !s.st.full) {
|
||||||
|
timeout = max(int(time.Until(s.nextTick).Milliseconds()), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fds := []unix.PollFd{{Fd: int32(r.ctx.Fd()), Events: unix.POLLIN}}
|
||||||
|
n, err := unix.Poll(fds, timeout)
|
||||||
|
switch {
|
||||||
|
case err == unix.EINTR:
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case n > 0:
|
||||||
|
return r.ctx.Dispatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.scrollTick()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) scrollTick() {
|
||||||
|
s := r.scroll
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.inFlight || (s.st != nil && s.st.full) {
|
||||||
|
s.nextTick = time.Now().Add(s.interval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.startScrollCapture()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) {
|
||||||
|
switch {
|
||||||
|
case os.output.transform != TransformNormal:
|
||||||
|
r.abortScroll(fmt.Errorf("scroll capture does not support rotated outputs"))
|
||||||
|
return
|
||||||
|
case w < 1 || h < 1:
|
||||||
|
r.abortScroll(fmt.Errorf("empty scroll capture region"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := 45
|
||||||
|
if r.screenshoter != nil && r.screenshoter.config.IntervalMs > 0 {
|
||||||
|
interval = r.screenshoter.config.IntervalMs
|
||||||
|
}
|
||||||
|
|
||||||
|
capX, capY, capW, capH := x, y, w, h
|
||||||
|
switch DetectCompositor() {
|
||||||
|
case CompositorHyprland, CompositorMango:
|
||||||
|
// both take device pixels, deviating from spec (observed)
|
||||||
|
default:
|
||||||
|
// spec: logical coordinates, scaled by the compositor
|
||||||
|
// https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region
|
||||||
|
if scale := os.output.fractionalScale; scale > 1 {
|
||||||
|
capX = int(float64(x)/scale + 0.5)
|
||||||
|
capY = int(float64(y)/scale + 0.5)
|
||||||
|
capW = int(float64(w)/scale + 0.5)
|
||||||
|
capH = int(float64(h)/scale + 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.scroll = &scrollSession{
|
||||||
|
output: os.output,
|
||||||
|
capX: int32(capX),
|
||||||
|
capY: int32(capY),
|
||||||
|
capW: int32(capW),
|
||||||
|
capH: int32(capH),
|
||||||
|
holeX: x,
|
||||||
|
holeY: y,
|
||||||
|
holeW: w,
|
||||||
|
holeH: h,
|
||||||
|
interval: time.Duration(interval) * time.Millisecond,
|
||||||
|
nextTick: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
r.layoutScrollBar(os)
|
||||||
|
|
||||||
|
for _, surf := range r.surfaces {
|
||||||
|
r.setInputPassthrough(surf, surf == os)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland routes all pointer input to exclusive-keyboard layers
|
||||||
|
// (https://github.com/hyprwm/Hyprland/discussions/14136), so the keyboard
|
||||||
|
// is released there and Enter/Esc come back via temporary global binds
|
||||||
|
if DetectCompositor() == CompositorHyprland {
|
||||||
|
r.enterHyprlandScrollInput(os)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.phase = phaseScroll
|
||||||
|
for _, surf := range r.surfaces {
|
||||||
|
r.redrawSurface(surf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sized for the worst-case counter so the input region is set once
|
||||||
|
func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
|
||||||
|
s := r.scroll
|
||||||
|
const charAdv, pad, gap = 9, 12, 16
|
||||||
|
|
||||||
|
s.btnH = 24
|
||||||
|
s.doneW = len("done")*charAdv + 24
|
||||||
|
s.cancelW = len("cancel")*charAdv + 24
|
||||||
|
counterW := len("99999 shots 999999px") * charAdv
|
||||||
|
s.barW = pad + s.doneW + gap + s.cancelW + gap + counterW + pad
|
||||||
|
s.barH = s.btnH + 24
|
||||||
|
|
||||||
|
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
|
||||||
|
s.barX = (bufW - s.barW) / 2
|
||||||
|
s.barY = bufH - s.barH - 24
|
||||||
|
|
||||||
|
borderX1, borderY1 := s.holeX-3, s.holeY-3
|
||||||
|
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
|
||||||
|
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
|
||||||
|
s.barY < borderY2 && s.barY+s.barH > borderY1
|
||||||
|
if overlaps {
|
||||||
|
s.barY = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
s.doneX = s.barX + pad
|
||||||
|
s.doneY = s.barY + (s.barH-s.btnH)/2
|
||||||
|
s.cancelX = s.doneX + s.doneW + gap
|
||||||
|
s.cancelY = s.doneY
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
|
||||||
|
reg, err := r.compositor.CreateRegion()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if withBar && os.screenBuf != nil && os.logicalW > 0 {
|
||||||
|
s := r.scroll
|
||||||
|
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
|
||||||
|
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
|
||||||
|
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
|
||||||
|
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
|
||||||
|
}
|
||||||
|
_ = os.wlSurface.SetInputRegion(reg)
|
||||||
|
_ = reg.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) {
|
||||||
|
for _, surf := range r.surfaces {
|
||||||
|
_ = surf.layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityNone))
|
||||||
|
}
|
||||||
|
if r.shortcutsInhibitor != nil {
|
||||||
|
_ = r.shortcutsInhibitor.Destroy()
|
||||||
|
r.shortcutsInhibitor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := r.scroll
|
||||||
|
scale := osurf.output.fractionalScale
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = 1
|
||||||
|
}
|
||||||
|
cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale)
|
||||||
|
cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale)
|
||||||
|
hyprlandFocusWindowAt(cx, cy)
|
||||||
|
|
||||||
|
s.sigCh = make(chan os.Signal, 2)
|
||||||
|
signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2)
|
||||||
|
s.keysBound = hyprlandBindScrollKeys(os.Getpid())
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandFocusWindowAt(x, y int) {
|
||||||
|
out, err := exec.Command("hyprctl", "-j", "clients").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var clients []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
At [2]int `json:"at"`
|
||||||
|
Size [2]int `json:"size"`
|
||||||
|
Mapped bool `json:"mapped"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
FocusHistoryID int `json:"focusHistoryID"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &clients) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
best := -1
|
||||||
|
for i, c := range clients {
|
||||||
|
if !c.Mapped || c.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if x < c.At[0] || x >= c.At[0]+c.Size[0] || y < c.At[1] || y >= c.At[1]+c.Size[1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best < 0 || c.FocusHistoryID < clients[best].FocusHistoryID {
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandBindScrollKeys(pid int) bool {
|
||||||
|
batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)
|
||||||
|
return exec.Command("hyprctl", "--batch", batch).Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandUnbindScrollKeys() {
|
||||||
|
_ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) scrollBarHit(x, y float64) string {
|
||||||
|
s := r.scroll
|
||||||
|
os := r.selection.surface
|
||||||
|
if s == nil || os == nil || os.screenBuf == nil || os.logicalW == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
bx := int(x * float64(os.screenBuf.Width) / float64(os.logicalW))
|
||||||
|
by := int(y * float64(os.screenBuf.Height) / float64(os.logicalH))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bx >= s.doneX && bx < s.doneX+s.doneW && by >= s.doneY && by < s.doneY+s.btnH:
|
||||||
|
return "done"
|
||||||
|
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
|
||||||
|
return "cancel"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alphaFormat(format uint32) uint32 {
|
||||||
|
switch format {
|
||||||
|
case uint32(FormatXRGB8888):
|
||||||
|
return uint32(FormatARGB8888)
|
||||||
|
case uint32(FormatXBGR8888):
|
||||||
|
return uint32(FormatABGR8888)
|
||||||
|
default:
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) startScrollCapture() {
|
||||||
|
s := r.scroll
|
||||||
|
frame, err := r.screencopy.CaptureOutputRegion(0, s.output.wlOutput, s.capX, s.capY, s.capW, s.capH)
|
||||||
|
if err != nil {
|
||||||
|
r.abortScroll(fmt.Errorf("scroll capture: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.inFlight = true
|
||||||
|
s.frame = frame
|
||||||
|
s.nextTick = time.Now().Add(s.interval)
|
||||||
|
|
||||||
|
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
|
||||||
|
if err := s.ensureCaptureBuffer(r, e); err != nil {
|
||||||
|
r.abortScroll(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := frame.Copy(s.wlBuf); err != nil {
|
||||||
|
log.Error("scroll frame copy failed", "err", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
|
||||||
|
s.yInverted = (e.Flags & 1) != 0
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
|
||||||
|
frame.Destroy()
|
||||||
|
s.frame = nil
|
||||||
|
s.inFlight = false
|
||||||
|
s.failures = 0
|
||||||
|
s.nextTick = time.Now().Add(s.interval)
|
||||||
|
r.handleScrollFrame()
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
|
||||||
|
frame.Destroy()
|
||||||
|
s.frame = nil
|
||||||
|
s.inFlight = false
|
||||||
|
s.failures++
|
||||||
|
s.nextTick = time.Now().Add(s.interval)
|
||||||
|
if s.failures >= scrollMaxFailures {
|
||||||
|
r.abortScroll(fmt.Errorf("screencopy failed %d consecutive times", s.failures))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scrollSession) ensureCaptureBuffer(r *RegionSelector, e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) error {
|
||||||
|
if s.buf != nil {
|
||||||
|
if int(e.Width) != s.frameW || int(e.Height) != s.frameH || PixelFormat(e.Format) != s.format {
|
||||||
|
return fmt.Errorf("output changed during scroll capture")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
format := PixelFormat(e.Format)
|
||||||
|
if int(e.Stride) < int(e.Width)*format.BytesPerPixel() {
|
||||||
|
return fmt.Errorf("invalid stride from compositor: %d for width %d", e.Stride, e.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create scroll buffer: %w", err)
|
||||||
|
}
|
||||||
|
buf.Format = format
|
||||||
|
|
||||||
|
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
||||||
|
if err != nil {
|
||||||
|
buf.Close()
|
||||||
|
return fmt.Errorf("create scroll pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
|
||||||
|
if err != nil {
|
||||||
|
pool.Destroy()
|
||||||
|
buf.Close()
|
||||||
|
return fmt.Errorf("create scroll wl_buffer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.buf = buf
|
||||||
|
s.pool = pool
|
||||||
|
s.wlBuf = wlBuf
|
||||||
|
s.format = format
|
||||||
|
s.frameW = int(e.Width)
|
||||||
|
s.frameH = int(e.Height)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) handleScrollFrame() {
|
||||||
|
s := r.scroll
|
||||||
|
if s == nil || s.buf == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.extractRows()
|
||||||
|
if err != nil {
|
||||||
|
r.abortScroll(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.st == nil {
|
||||||
|
s.st = newStitcher(s.frameW * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := s.st.rowSamples(rows)
|
||||||
|
sig := s.st.frameSig(rows)
|
||||||
|
dup := duplicateFrame(sig, s.prevSig)
|
||||||
|
s.prevSig = sig
|
||||||
|
|
||||||
|
// moving content: recapture at compositor speed, the timer paces idle only
|
||||||
|
if !dup {
|
||||||
|
s.nextTick = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
var added int
|
||||||
|
switch {
|
||||||
|
case dup && s.unmatched:
|
||||||
|
// settled somewhere unreachable: seam a new segment after a few ticks
|
||||||
|
s.unmatchedTicks++
|
||||||
|
if s.unmatchedTicks < scrollSeamTicks {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var placed bool
|
||||||
|
added, placed = s.st.pushFrame(rows, cols)
|
||||||
|
if !placed {
|
||||||
|
added = s.st.seamAppend(rows, cols)
|
||||||
|
}
|
||||||
|
s.prevPlaced = true
|
||||||
|
s.unmatched = false
|
||||||
|
s.unmatchedTicks = 0
|
||||||
|
case dup && s.prevPlaced:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
var placed bool
|
||||||
|
added, placed = s.st.pushFrame(rows, cols)
|
||||||
|
s.prevPlaced = placed
|
||||||
|
s.unmatched = !placed
|
||||||
|
s.unmatchedTicks = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if scrollDebug {
|
||||||
|
log.Error("scroll frame", "dup", dup, "unmatched", s.unmatched,
|
||||||
|
"placed", s.prevPlaced, "added", added, "canvas", s.st.rows(), "kept", s.kept)
|
||||||
|
}
|
||||||
|
if added == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.kept++
|
||||||
|
if r.selection.surface != nil {
|
||||||
|
r.redrawSurface(r.selection.surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollDebug = os.Getenv("DMS_SCROLL_DEBUG") != ""
|
||||||
|
|
||||||
|
func (s *scrollSession) extractRows() ([]byte, error) {
|
||||||
|
src := s.buf
|
||||||
|
format := s.format
|
||||||
|
|
||||||
|
if format.Is24Bit() {
|
||||||
|
converted, newFormat, err := src.ConvertTo32Bit(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("convert scroll frame: %w", err)
|
||||||
|
}
|
||||||
|
defer converted.Close()
|
||||||
|
src = converted
|
||||||
|
s.format = newFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]byte, s.frameW*4*s.frameH)
|
||||||
|
data := src.Data()
|
||||||
|
for y := 0; y < s.frameH; y++ {
|
||||||
|
srcY := y
|
||||||
|
if s.yInverted {
|
||||||
|
srcY = s.frameH - 1 - y
|
||||||
|
}
|
||||||
|
srcOff := srcY * src.Stride
|
||||||
|
dstOff := y * s.frameW * 4
|
||||||
|
if srcOff+s.frameW*4 > len(data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copy(rows[dstOff:dstOff+s.frameW*4], data[srcOff:srcOff+s.frameW*4])
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) finishScroll() {
|
||||||
|
s := r.scroll
|
||||||
|
if s == nil || s.st == nil || s.st.rows() == 0 {
|
||||||
|
r.cancelled = true
|
||||||
|
r.running = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := CreateShmBuffer(s.frameW, s.st.rows(), s.frameW*4)
|
||||||
|
if err != nil {
|
||||||
|
r.abortScroll(fmt.Errorf("create stitched buffer: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copy(buf.Data(), s.st.canvas)
|
||||||
|
buf.Format = s.format
|
||||||
|
|
||||||
|
r.capturedBuffer = buf
|
||||||
|
r.capturedRegion = Region{
|
||||||
|
X: int32(s.holeX),
|
||||||
|
Y: int32(s.holeY),
|
||||||
|
Width: int32(s.holeW),
|
||||||
|
Height: int32(s.holeH),
|
||||||
|
Output: s.output.name,
|
||||||
|
}
|
||||||
|
// same convention as finishSelection or preselect breaks on scaled outputs
|
||||||
|
r.result = Region{
|
||||||
|
X: int32(s.holeX) + s.output.x,
|
||||||
|
Y: int32(s.holeY) + s.output.y,
|
||||||
|
Width: int32(s.holeW),
|
||||||
|
Height: int32(s.holeH),
|
||||||
|
Output: s.output.name,
|
||||||
|
}
|
||||||
|
r.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) abortScroll(err error) {
|
||||||
|
if r.scroll == nil {
|
||||||
|
r.scroll = &scrollSession{}
|
||||||
|
}
|
||||||
|
r.scroll.abortErr = err
|
||||||
|
r.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegionSelector) cleanupScroll() {
|
||||||
|
s := r.scroll
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.keysBound {
|
||||||
|
hyprlandUnbindScrollKeys()
|
||||||
|
}
|
||||||
|
if s.sigCh != nil {
|
||||||
|
signal.Stop(s.sigCh)
|
||||||
|
}
|
||||||
|
if s.frame != nil {
|
||||||
|
s.frame.Destroy()
|
||||||
|
}
|
||||||
|
if s.wlBuf != nil {
|
||||||
|
s.wlBuf.Destroy()
|
||||||
|
}
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Destroy()
|
||||||
|
}
|
||||||
|
if s.buf != nil {
|
||||||
|
s.buf.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mirrors handleScrollFrame's stitch logic so glides run without a compositor
|
||||||
|
type simSession struct {
|
||||||
|
prevSig []float32
|
||||||
|
prevPlaced bool
|
||||||
|
unmatched bool
|
||||||
|
unmatchedTicks int
|
||||||
|
st *stitcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simSession) observe(rows []byte) {
|
||||||
|
cols := s.st.rowSamples(rows)
|
||||||
|
sig := s.st.frameSig(rows)
|
||||||
|
dup := duplicateFrame(sig, s.prevSig)
|
||||||
|
s.prevSig = sig
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case dup && s.unmatched:
|
||||||
|
s.unmatchedTicks++
|
||||||
|
if s.unmatchedTicks < scrollSeamTicks {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, placed := s.st.pushFrame(rows, cols); !placed {
|
||||||
|
s.st.seamAppend(rows, cols)
|
||||||
|
}
|
||||||
|
s.prevPlaced = true
|
||||||
|
s.unmatched = false
|
||||||
|
s.unmatchedTicks = 0
|
||||||
|
case dup && s.prevPlaced:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_, placed := s.st.pushFrame(rows, cols)
|
||||||
|
s.prevPlaced = placed
|
||||||
|
s.unmatched = !placed
|
||||||
|
s.unmatchedTicks = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the page at a fractional scroll offset, as a compositor renders mid-glide
|
||||||
|
func fractionalFrame(page []byte, stride, frameH int, offset float64) []byte {
|
||||||
|
top := int(offset)
|
||||||
|
frac := offset - float64(top)
|
||||||
|
out := make([]byte, frameH*stride)
|
||||||
|
for y := 0; y < frameH; y++ {
|
||||||
|
a := page[(top+y)*stride : (top+y+1)*stride]
|
||||||
|
b := page[(top+y+1)*stride : (top+y+2)*stride]
|
||||||
|
row := out[y*stride : (y+1)*stride]
|
||||||
|
for x := range row {
|
||||||
|
row[x] = byte(float64(a[x])*(1-frac) + float64(b[x])*frac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// blank gaps between paragraphs plus identical card blocks repeated around
|
||||||
|
func webbyPage(rng *rand.Rand, stride, rows int) []byte {
|
||||||
|
page := make([]byte, rows*stride)
|
||||||
|
card := make([]byte, 40*stride)
|
||||||
|
rng.Read(card)
|
||||||
|
|
||||||
|
row := 0
|
||||||
|
for row < rows {
|
||||||
|
switch rng.Intn(4) {
|
||||||
|
case 0: // blank gap
|
||||||
|
row += 10 + rng.Intn(20)
|
||||||
|
case 1: // repeated card block
|
||||||
|
n := copy(page[row*stride:], card)
|
||||||
|
row += n / stride
|
||||||
|
default: // paragraph of distinct rows
|
||||||
|
n := (8 + rng.Intn(22)) * stride
|
||||||
|
if row*stride+n > len(page) {
|
||||||
|
n = len(page) - row*stride
|
||||||
|
}
|
||||||
|
rng.Read(page[row*stride : row*stride+n])
|
||||||
|
row += n / stride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen-fixed sidebar in the unsampled outer 8% plus per-frame hover noise
|
||||||
|
func addFixedChrome(rng *rand.Rand, frame []byte, stride, frameH int, sidebar []byte) {
|
||||||
|
sbw := len(sidebar) / frameH
|
||||||
|
for y := 0; y < frameH; y++ {
|
||||||
|
copy(frame[y*stride:y*stride+sbw], sidebar[y*sbw:(y+1)*sbw])
|
||||||
|
}
|
||||||
|
hoverTop := 40 + rng.Intn(frameH-80)
|
||||||
|
for y := hoverTop; y < hoverTop+24; y++ {
|
||||||
|
off := y*stride + stride/3
|
||||||
|
for x := 0; x < 60; x++ {
|
||||||
|
frame[off+x] ^= 0x08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// starting at the page bottom and scrolling up must prepend, never stall
|
||||||
|
func TestScrollSimulationBottomUp(t *testing.T) {
|
||||||
|
const stride = 2048
|
||||||
|
const frameH = 240
|
||||||
|
rng := rand.New(rand.NewSource(99))
|
||||||
|
page := webbyPage(rng, stride, 4000)
|
||||||
|
|
||||||
|
st := newStitcher(stride)
|
||||||
|
sidebar := make([]byte, frameH*140)
|
||||||
|
rng.Read(sidebar)
|
||||||
|
|
||||||
|
sim := &simSession{st: st}
|
||||||
|
|
||||||
|
pos := 3700.0
|
||||||
|
capture := func() []byte {
|
||||||
|
f := fractionalFrame(page, stride, frameH, pos)
|
||||||
|
addFixedChrome(rng, f, stride, frameH, sidebar)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
glide := func(target float64) {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
step := (target - pos) * 0.45
|
||||||
|
if step > -1 && step < 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos += step
|
||||||
|
if i%4 != 3 {
|
||||||
|
pos = float64(int(pos))
|
||||||
|
}
|
||||||
|
sim.observe(capture())
|
||||||
|
}
|
||||||
|
pos = target
|
||||||
|
sim.observe(capture())
|
||||||
|
sim.observe(capture())
|
||||||
|
}
|
||||||
|
|
||||||
|
sim.observe(capture())
|
||||||
|
for _, target := range []float64{3640, 3560, 3460, 3340, 3240} {
|
||||||
|
glide(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRows := (3700 + frameH) - 3240
|
||||||
|
got := sim.st.rows()
|
||||||
|
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
||||||
|
t.Fatalf("canvas has %d rows, want ~%d (upward scrolling must prepend)", got, wantRows)
|
||||||
|
}
|
||||||
|
topPage := 3240 + (wantRows - got)
|
||||||
|
for _, cr := range []int{0, 100, 300} {
|
||||||
|
if !rowMatchesPage(sim.st.canvas, page, stride, cr, topPage+cr) {
|
||||||
|
t.Fatalf("canvas row %d does not map onto page row %d", cr, topPage+cr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exact page row or a blend of neighbors, allowing a one-row offset
|
||||||
|
func rowMatchesPage(canvas, page []byte, stride, canvasRow, pageRow int) bool {
|
||||||
|
for x := 200; x < stride-1400; x++ {
|
||||||
|
c := int(canvas[canvasRow*stride+x])
|
||||||
|
lo, hi := 255, 0
|
||||||
|
for k := pageRow - 1; k <= pageRow+1; k++ {
|
||||||
|
v := int(page[k*stride+x])
|
||||||
|
lo, hi = min(lo, v), max(hi, v)
|
||||||
|
}
|
||||||
|
if c < lo-1 || c > hi+1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// a fling past a full frame height must seam a new segment, not go dead
|
||||||
|
func TestScrollSimulationFastFlingRecovers(t *testing.T) {
|
||||||
|
const stride = 2048
|
||||||
|
const frameH = 240
|
||||||
|
rng := rand.New(rand.NewSource(7))
|
||||||
|
page := webbyPage(rng, stride, 4000)
|
||||||
|
|
||||||
|
sim := &simSession{st: newStitcher(stride)}
|
||||||
|
frame := func(top int) []byte {
|
||||||
|
return slices.Clone(page[top*stride : (top+frameH)*stride])
|
||||||
|
}
|
||||||
|
rest := func(top int) {
|
||||||
|
for range scrollSeamTicks + 2 {
|
||||||
|
sim.observe(frame(top))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rest(0)
|
||||||
|
sim.observe(frame(60))
|
||||||
|
sim.observe(frame(130))
|
||||||
|
rest(130)
|
||||||
|
firstRange := 130 + frameH
|
||||||
|
|
||||||
|
sim.observe(frame(900))
|
||||||
|
sim.observe(frame(1400))
|
||||||
|
rest(1800)
|
||||||
|
|
||||||
|
sim.observe(frame(1860))
|
||||||
|
sim.observe(frame(1930))
|
||||||
|
rest(1930)
|
||||||
|
|
||||||
|
wantRows := firstRange + (1930 - 1800) + frameH
|
||||||
|
if got := sim.st.rows(); got != wantRows {
|
||||||
|
t.Fatalf("canvas has %d rows, want %d (first range %d + new segment)", got, wantRows, firstRange)
|
||||||
|
}
|
||||||
|
seamStart := firstRange
|
||||||
|
if !slices.Equal(sim.st.canvas[seamStart*stride:], page[1800*stride:(1930+frameH)*stride]) {
|
||||||
|
t.Fatal("new segment content wrong after fling recovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eased glides with up/down scrubbing must cover the range exactly once
|
||||||
|
func TestScrollSimulationSmoothGlide(t *testing.T) {
|
||||||
|
const stride = 2048
|
||||||
|
const frameH = 240
|
||||||
|
rng := rand.New(rand.NewSource(99))
|
||||||
|
page := webbyPage(rng, stride, 4000)
|
||||||
|
|
||||||
|
st := newStitcher(stride)
|
||||||
|
sidebar := make([]byte, frameH*140)
|
||||||
|
rng.Read(sidebar)
|
||||||
|
|
||||||
|
sim := &simSession{st: st}
|
||||||
|
|
||||||
|
pos := 0.0
|
||||||
|
capture := func() []byte {
|
||||||
|
f := fractionalFrame(page, stride, frameH, pos)
|
||||||
|
addFixedChrome(rng, f, stride, frameH, sidebar)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
glide := func(target float64) {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
step := (target - pos) * 0.45
|
||||||
|
if step > -1 && step < 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos += step
|
||||||
|
// mostly snapped to device pixels, with the odd fractional frame
|
||||||
|
if i%4 != 3 {
|
||||||
|
pos = float64(int(pos))
|
||||||
|
}
|
||||||
|
sim.observe(capture())
|
||||||
|
}
|
||||||
|
pos = target
|
||||||
|
sim.observe(capture())
|
||||||
|
sim.observe(capture())
|
||||||
|
}
|
||||||
|
|
||||||
|
sim.observe(capture())
|
||||||
|
for _, target := range []float64{160, 330, 480, 650, 800, 960, 1100} {
|
||||||
|
glide(target)
|
||||||
|
}
|
||||||
|
for _, target := range []float64{700, 300, 900, 1100} {
|
||||||
|
glide(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRows := 1100 + frameH
|
||||||
|
got := sim.st.rows()
|
||||||
|
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
||||||
|
t.Fatalf("canvas has %d rows, want ~%d (more = duplicated bands, fewer = gaps)", got, wantRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverLo, hoverHi := stride/3, stride/3+60
|
||||||
|
mismatched := 0
|
||||||
|
for row := 0; row < min(got, wantRows); row += 7 {
|
||||||
|
off := row * stride
|
||||||
|
a1, b1 := sim.st.canvas[off+200:off+hoverLo], page[off+200:off+hoverLo]
|
||||||
|
a2, b2 := sim.st.canvas[off+hoverHi:off+stride], page[off+hoverHi:off+stride]
|
||||||
|
if !slices.Equal(a1, b1) || !slices.Equal(a2, b2) {
|
||||||
|
mismatched++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mismatched > (wantRows/7)/20 {
|
||||||
|
t.Fatalf("%d of %d sampled rows mismatch page content (mid-animation pixels baked in)", mismatched, wantRows/7)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
// Frame stitcher after mark-shot's column-sampling design
|
||||||
|
// (https://github.com/jswysnemc/mark-shot, src/scroll/stitcher_algorithm.cpp).
|
||||||
|
// Only rows overhanging the captured range are committed; frames that match
|
||||||
|
// nothing are dropped without touching state.
|
||||||
|
|
||||||
|
const (
|
||||||
|
stitchMaxCanvasBytes = 256 << 20
|
||||||
|
stitchMaxRowsCap = 30000
|
||||||
|
|
||||||
|
// mark-shot: StitchConfig{100, 9.0f, 15, 1.0f}
|
||||||
|
stitchAcceptDiff = 9.0
|
||||||
|
stitchApproxDiff = 1.0
|
||||||
|
stitchMinCompare = 50
|
||||||
|
stitchMinCanvas = 100
|
||||||
|
stitchMinAppend = 15
|
||||||
|
stitchCoarseStep = 8
|
||||||
|
stitchPredictWindow = 160
|
||||||
|
stitchBandSamples = 17
|
||||||
|
|
||||||
|
// mark-shot: kDuplicateAvgDiff=1.1f, kDuplicateMaxDiff=4, 18x24 grid
|
||||||
|
stitchDupAvgDiff = 1.1
|
||||||
|
stitchDupMaxDiff = 4.0
|
||||||
|
stitchSigCols = 18
|
||||||
|
stitchSigRows = 24
|
||||||
|
|
||||||
|
// blank rows agree at every offset and must not decide a match
|
||||||
|
stitchActivityMin = 2.0
|
||||||
|
stitchRowMatchTol = 4.0
|
||||||
|
stitchMinActive = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// mean luminance per band (8-32%, 34-66%, 68-92%); the outer 8% is chrome
|
||||||
|
type rowCols [3]float32
|
||||||
|
|
||||||
|
type stitcher struct {
|
||||||
|
stride int
|
||||||
|
sampleOffs [3][]int
|
||||||
|
|
||||||
|
canvas []byte
|
||||||
|
cols []rowCols
|
||||||
|
|
||||||
|
anchor int
|
||||||
|
last []rowCols
|
||||||
|
lastOffset int
|
||||||
|
|
||||||
|
maxRows int
|
||||||
|
full bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStitcher(stride int) *stitcher {
|
||||||
|
px := stride / 4
|
||||||
|
st := &stitcher{
|
||||||
|
stride: stride,
|
||||||
|
maxRows: min(stitchMaxCanvasBytes/stride, stitchMaxRowsCap),
|
||||||
|
}
|
||||||
|
bands := [3][2]float64{{0.08, 0.32}, {0.34, 0.66}, {0.68, 0.92}}
|
||||||
|
for b, band := range bands {
|
||||||
|
lo := int(float64(px) * band[0])
|
||||||
|
hi := max(int(float64(px)*band[1]), lo+1)
|
||||||
|
n := min(stitchBandSamples, hi-lo)
|
||||||
|
for s := range n {
|
||||||
|
st.sampleOffs[b] = append(st.sampleOffs[b], (lo+(hi-lo)*s/n)*4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) rowSamples(data []byte) []rowCols {
|
||||||
|
rows := len(data) / st.stride
|
||||||
|
cols := make([]rowCols, rows)
|
||||||
|
for y := range rows {
|
||||||
|
row := data[y*st.stride:]
|
||||||
|
for b := range 3 {
|
||||||
|
var sum float32
|
||||||
|
for _, off := range st.sampleOffs[b] {
|
||||||
|
sum += 0.114*float32(row[off]) + 0.587*float32(row[off+1]) + 0.299*float32(row[off+2])
|
||||||
|
}
|
||||||
|
cols[y][b] = sum / float32(len(st.sampleOffs[b]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) frameSig(data []byte) []float32 {
|
||||||
|
rows := len(data) / st.stride
|
||||||
|
px := st.stride / 4
|
||||||
|
sig := make([]float32, 0, stitchSigCols*stitchSigRows)
|
||||||
|
for gy := range stitchSigRows {
|
||||||
|
y := (2*gy + 1) * rows / (2 * stitchSigRows)
|
||||||
|
for gx := range stitchSigCols {
|
||||||
|
x := (2*gx + 1) * px / (2 * stitchSigCols)
|
||||||
|
off := y*st.stride + x*4
|
||||||
|
sig = append(sig, 0.114*float32(data[off])+0.587*float32(data[off+1])+0.299*float32(data[off+2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) rows() int {
|
||||||
|
return len(st.cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowColsDiff(a, b rowCols) float32 {
|
||||||
|
return (abs32(a[0]-b[0]) + abs32(a[1]-b[1]) + abs32(a[2]-b[2])) / 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateFrame(a, b []float32) bool {
|
||||||
|
if len(a) != len(b) || len(a) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var sum, maxDiff float32
|
||||||
|
for i := range a {
|
||||||
|
d := abs32(a[i] - b[i])
|
||||||
|
sum += d
|
||||||
|
maxDiff = max(maxDiff, d)
|
||||||
|
}
|
||||||
|
return sum/float32(len(a)) <= stitchDupAvgDiff && maxDiff <= stitchDupMaxDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
// sticky header/footer zones, per mark-shot: 10% top, 8% bottom, min 16px
|
||||||
|
func matchIgnores(h int) (top, bottom int) {
|
||||||
|
if h < 80 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return clamp(h/10, 16, h/4), clamp(h*8/100, 16, h/4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activity(f []rowCols) []bool {
|
||||||
|
active := make([]bool, len(f))
|
||||||
|
for i := 1; i < len(f); i++ {
|
||||||
|
active[i] = rowColsDiff(f[i], f[i-1]) > stitchActivityMin
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) pushFrame(frame []byte, f []rowCols) (int, bool) {
|
||||||
|
if st.full || len(f) == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
h := len(f)
|
||||||
|
if len(st.cols) == 0 {
|
||||||
|
n := st.appendRows(frame, f, 0)
|
||||||
|
st.anchor = 0
|
||||||
|
st.last = f
|
||||||
|
st.lastOffset = 0
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
pos, ok := st.locateFrame(f, activity(f))
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := pos - st.anchor
|
||||||
|
added := 0
|
||||||
|
if over := pos + h - len(st.cols); over >= stitchMinAppend {
|
||||||
|
added += st.appendRows(frame, f, h-over)
|
||||||
|
}
|
||||||
|
if over := -pos; over >= stitchMinAppend {
|
||||||
|
n := st.prependRows(frame, f, over)
|
||||||
|
added += n
|
||||||
|
pos += n
|
||||||
|
}
|
||||||
|
|
||||||
|
st.anchor = pos
|
||||||
|
st.last = f
|
||||||
|
st.lastOffset = delta
|
||||||
|
return added, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// seamAppend starts a new segment after a jump capture couldn't follow.
|
||||||
|
func (st *stitcher) seamAppend(frame []byte, f []rowCols) int {
|
||||||
|
if st.full || len(f) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pos := len(st.cols)
|
||||||
|
n := st.appendRows(frame, f, 0)
|
||||||
|
st.anchor = pos
|
||||||
|
st.last = f
|
||||||
|
st.lastOffset = 0
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) locateFrame(f []rowCols, active []bool) (int, bool) {
|
||||||
|
d, diff := st.adjacentOffset(f, active)
|
||||||
|
pred := st.anchor + d
|
||||||
|
|
||||||
|
if diff <= stitchAcceptDiff {
|
||||||
|
if _, ok := st.verifyAt(f, active, pred); ok {
|
||||||
|
return pred, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pos, _, ok := st.scanPositions(f, active, pred, true); ok {
|
||||||
|
return pos, true
|
||||||
|
}
|
||||||
|
pos, _, ok := st.scanPositions(f, active, pred, false)
|
||||||
|
return pos, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) verifyAt(f []rowCols, active []bool, pos int) (float32, bool) {
|
||||||
|
diff, count, activeMatches := st.canvasDiff(f, active, pos)
|
||||||
|
ok := count >= stitchMinCanvas && diff <= stitchAcceptDiff && activeMatches >= stitchMinActive
|
||||||
|
return diff, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// signed deltas searched outward from the previous one (mark-shot's
|
||||||
|
// predictOffsetIter), early-exiting once a diff beats approxDiff
|
||||||
|
func (st *stitcher) adjacentOffset(f []rowCols, active []bool) (int, float32) {
|
||||||
|
h := len(f)
|
||||||
|
if len(st.last) != h {
|
||||||
|
return 0, float32(1e9)
|
||||||
|
}
|
||||||
|
limit := max(h-stitchMinCompare-1, 0)
|
||||||
|
|
||||||
|
bestD, bestDiff := 0, float32(1e9)
|
||||||
|
countdown := -1
|
||||||
|
try := func(d int) bool {
|
||||||
|
if d < -limit || d > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
diff, activeMatches := st.pairDiff(f, active, d)
|
||||||
|
if activeMatches >= stitchMinActive && diff < bestDiff {
|
||||||
|
bestDiff, bestD = diff, d
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case bestDiff < stitchApproxDiff/4:
|
||||||
|
return true
|
||||||
|
case bestDiff < stitchApproxDiff && countdown < 0:
|
||||||
|
countdown = 10
|
||||||
|
}
|
||||||
|
if countdown > 0 {
|
||||||
|
countdown--
|
||||||
|
}
|
||||||
|
return countdown == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if try(st.lastOffset) {
|
||||||
|
return bestD, bestDiff
|
||||||
|
}
|
||||||
|
for k := 1; ; k++ {
|
||||||
|
lo, hi := st.lastOffset-k, st.lastOffset+k
|
||||||
|
if lo < -limit && hi > limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if try(hi) || try(lo) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestD, bestDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) pairDiff(f []rowCols, active []bool, d int) (float32, int) {
|
||||||
|
h := len(f)
|
||||||
|
top, bottom := matchIgnores(h)
|
||||||
|
lo := max(top, -d)
|
||||||
|
hi := min(h-bottom, h-d)
|
||||||
|
|
||||||
|
count := hi - lo
|
||||||
|
if count < stitchMinCompare {
|
||||||
|
return float32(1e9), 0
|
||||||
|
}
|
||||||
|
var sum float32
|
||||||
|
activeMatches := 0
|
||||||
|
for i := lo; i < hi; i++ {
|
||||||
|
rd := rowColsDiff(f[i], st.last[i+d])
|
||||||
|
sum += rd
|
||||||
|
if active[i] && rd <= stitchRowMatchTol {
|
||||||
|
activeMatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum / float32(count), activeMatches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) canvasDiff(f []rowCols, active []bool, pos int) (float32, int, int) {
|
||||||
|
h := len(f)
|
||||||
|
top, bottom := matchIgnores(h)
|
||||||
|
lo := max(top, -pos)
|
||||||
|
hi := min(h-bottom, len(st.cols)-pos)
|
||||||
|
|
||||||
|
count := hi - lo
|
||||||
|
if count < 1 {
|
||||||
|
return float32(1e9), 0, 0
|
||||||
|
}
|
||||||
|
var sum float32
|
||||||
|
activeMatches := 0
|
||||||
|
for i := lo; i < hi; i++ {
|
||||||
|
rd := rowColsDiff(f[i], st.cols[pos+i])
|
||||||
|
sum += rd
|
||||||
|
if active[i] && rd <= stitchRowMatchTol {
|
||||||
|
activeMatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum / float32(count), count, activeMatches
|
||||||
|
}
|
||||||
|
|
||||||
|
// mark-shot's findEdgePosition (nearOnly: edges + prediction window, 1px) and
|
||||||
|
// findKnownPosition (coarse sweep refined around the winner)
|
||||||
|
func (st *stitcher) scanPositions(f []rowCols, active []bool, pred int, nearOnly bool) (int, float32, bool) {
|
||||||
|
h := len(f)
|
||||||
|
C := len(st.cols)
|
||||||
|
minPos := stitchMinCanvas - h
|
||||||
|
maxPos := C - stitchMinCanvas
|
||||||
|
|
||||||
|
bestPos, bestDiff := 0, float32(1e9)
|
||||||
|
bestDist := 1 << 30
|
||||||
|
consider := func(pos int) {
|
||||||
|
if pos < minPos || pos > maxPos {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
diff, ok := st.verifyAt(f, active, pos)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dist := pos - pred
|
||||||
|
if dist < 0 {
|
||||||
|
dist = -dist
|
||||||
|
}
|
||||||
|
better := diff < bestDiff
|
||||||
|
if !nearOnly {
|
||||||
|
better = dist < bestDist || dist == bestDist && diff < bestDiff
|
||||||
|
}
|
||||||
|
if better {
|
||||||
|
bestPos, bestDiff, bestDist = pos, diff, dist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nearOnly {
|
||||||
|
for pos := pred - stitchPredictWindow; pos <= pred+stitchPredictWindow; pos++ {
|
||||||
|
consider(pos)
|
||||||
|
}
|
||||||
|
for pos := C - h; pos <= maxPos; pos++ {
|
||||||
|
consider(pos)
|
||||||
|
}
|
||||||
|
for pos := minPos; pos <= 0; pos++ {
|
||||||
|
consider(pos)
|
||||||
|
}
|
||||||
|
if bestDiff > stitchAcceptDiff {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return bestPos, bestDiff, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for pos := minPos; pos <= maxPos; pos += stitchCoarseStep {
|
||||||
|
consider(pos)
|
||||||
|
}
|
||||||
|
if bestDiff > stitchAcceptDiff {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
refined, refinedDiff := bestPos, bestDiff
|
||||||
|
for pos := bestPos - stitchCoarseStep + 1; pos < bestPos+stitchCoarseStep; pos++ {
|
||||||
|
if pos == bestPos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff, ok := st.verifyAt(f, active, pos); ok && diff < refinedDiff {
|
||||||
|
refined, refinedDiff = pos, diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refined, refinedDiff, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) appendRows(frame []byte, f []rowCols, from int) int {
|
||||||
|
n := len(f) - from
|
||||||
|
if room := st.maxRows - len(st.cols); n > room {
|
||||||
|
n = room
|
||||||
|
st.full = true
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
st.full = true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
st.canvas = append(st.canvas, frame[from*st.stride:(from+n)*st.stride]...)
|
||||||
|
st.cols = append(st.cols, f[from:from+n]...)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *stitcher) prependRows(frame []byte, f []rowCols, n int) int {
|
||||||
|
if room := st.maxRows - len(st.cols); n > room {
|
||||||
|
n = room
|
||||||
|
st.full = true
|
||||||
|
}
|
||||||
|
if n <= 0 {
|
||||||
|
st.full = true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := make([]byte, n*st.stride+len(st.canvas))
|
||||||
|
copy(canvas, frame[:n*st.stride])
|
||||||
|
copy(canvas[n*st.stride:], st.canvas)
|
||||||
|
st.canvas = canvas
|
||||||
|
|
||||||
|
cols := make([]rowCols, 0, n+len(st.cols))
|
||||||
|
cols = append(cols, f[:n]...)
|
||||||
|
st.cols = append(cols, st.cols...)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs32(f float32) float32 {
|
||||||
|
if f < 0 {
|
||||||
|
return -f
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package screenshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testStride = 512
|
||||||
|
testFrameH = 240
|
||||||
|
)
|
||||||
|
|
||||||
|
func makePage(t *testing.T, rows int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
page := make([]byte, rows*testStride)
|
||||||
|
rng.Read(page)
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameAt(page []byte, top int) []byte {
|
||||||
|
return page[top*testStride : (top+testFrameH)*testStride]
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushFrame(st *stitcher, frame []byte) int {
|
||||||
|
n, _ := st.pushFrame(frame, st.rowSamples(frame))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchSlidingWindows(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
|
||||||
|
for _, delta := range []int{20, 60, 110} {
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
lastTop := 0
|
||||||
|
for top := 0; top+testFrameH <= 900; top += delta {
|
||||||
|
lastTop = top
|
||||||
|
pushFrame(st, frameAt(page, top))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantRows := lastTop + testFrameH
|
||||||
|
if st.rows() != wantRows {
|
||||||
|
t.Fatalf("delta %d: got %d rows, want %d", delta, st.rows(), wantRows)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
||||||
|
t.Fatalf("delta %d: canvas does not match source rows", delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchDropsNoOverlap(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
|
||||||
|
pushFrame(st, frameAt(page, 0))
|
||||||
|
if appended := pushFrame(st, frameAt(page, testFrameH+50)); appended != 0 {
|
||||||
|
t.Fatalf("unmatched jump appended %d rows", appended)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(st.canvas, page[:testFrameH*testStride]) {
|
||||||
|
t.Fatal("canvas changed on unmatched frame")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchNoGrowthCases(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
blank := make([]byte, testFrameH*testStride)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
first, second []byte
|
||||||
|
}{
|
||||||
|
{"identical frame", frameAt(page, 0), frameAt(page, 0)},
|
||||||
|
{"jitter below min append", frameAt(page, 0), frameAt(page, stitchMinAppend-5)},
|
||||||
|
{"blank on blank", blank, blank},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
pushFrame(st, tc.first)
|
||||||
|
if appended := pushFrame(st, tc.second); appended != 0 {
|
||||||
|
t.Fatalf("%s: appended %d rows", tc.name, appended)
|
||||||
|
}
|
||||||
|
if st.rows() != testFrameH {
|
||||||
|
t.Fatalf("%s: got %d rows, want %d", tc.name, st.rows(), testFrameH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchRevisitNeverDuplicates(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
|
||||||
|
pushFrame(st, frameAt(page, 0))
|
||||||
|
pushFrame(st, frameAt(page, 100))
|
||||||
|
pushFrame(st, frameAt(page, 200))
|
||||||
|
|
||||||
|
for _, top := range []int{150, 60, 0, 80, 190} {
|
||||||
|
if appended := pushFrame(st, frameAt(page, top)); appended != 0 {
|
||||||
|
t.Fatalf("revisited frame at %d appended %d rows", top, appended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushFrame(st, frameAt(page, 300))
|
||||||
|
|
||||||
|
wantRows := 300 + testFrameH
|
||||||
|
if st.rows() != wantRows {
|
||||||
|
t.Fatalf("got %d rows, want %d", st.rows(), wantRows)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
||||||
|
t.Fatal("canvas corrupted by revisited frames")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchScrollUpPrepends(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
|
||||||
|
pushFrame(st, frameAt(page, 500))
|
||||||
|
if appended := pushFrame(st, frameAt(page, 420)); appended != 80 {
|
||||||
|
t.Fatalf("upward frame appended %d rows, want 80", appended)
|
||||||
|
}
|
||||||
|
pushFrame(st, frameAt(page, 560))
|
||||||
|
|
||||||
|
if !bytes.Equal(st.canvas, page[420*testStride:(560+testFrameH)*testStride]) {
|
||||||
|
t.Fatal("canvas does not match page range after prepend + append")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchNoisyChromeStillMatches(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
|
||||||
|
addChrome := func(frame []byte, seed byte) []byte {
|
||||||
|
f := slices.Clone(frame)
|
||||||
|
for y := range testFrameH {
|
||||||
|
for x := range 32 {
|
||||||
|
f[y*testStride+x] = seed + byte(y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y := 100; y < 124; y++ {
|
||||||
|
for x := testStride / 2; x < testStride/2+40; x++ {
|
||||||
|
f[y*testStride+x] ^= 0x08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
pushFrame(st, addChrome(frameAt(page, 0), 1))
|
||||||
|
if appended := pushFrame(st, addChrome(frameAt(page, 90), 2)); appended != 90 {
|
||||||
|
t.Fatalf("appended %d rows, want 90", appended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStitchMaxRowsCap(t *testing.T) {
|
||||||
|
page := makePage(t, 1000)
|
||||||
|
st := newStitcher(testStride)
|
||||||
|
st.maxRows = testFrameH + 10
|
||||||
|
|
||||||
|
pushFrame(st, frameAt(page, 0))
|
||||||
|
if appended := pushFrame(st, frameAt(page, 100)); appended != 10 {
|
||||||
|
t.Fatalf("appended %d rows past cap, want 10", appended)
|
||||||
|
}
|
||||||
|
if !st.full {
|
||||||
|
t.Fatal("stitcher not marked full at cap")
|
||||||
|
}
|
||||||
|
if pushFrame(st, frameAt(page, 300)) != 0 {
|
||||||
|
t.Fatal("push after full appended rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const (
|
|||||||
ModeAllScreens
|
ModeAllScreens
|
||||||
ModeOutput
|
ModeOutput
|
||||||
ModeLastRegion
|
ModeLastRegion
|
||||||
|
ModeScroll
|
||||||
)
|
)
|
||||||
|
|
||||||
type Format int
|
type Format int
|
||||||
@@ -62,6 +63,7 @@ type Config struct {
|
|||||||
SaveFile bool
|
SaveFile bool
|
||||||
Notify bool
|
Notify bool
|
||||||
Stdout bool
|
Stdout bool
|
||||||
|
IntervalMs int
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
|
|||||||
@@ -744,6 +744,32 @@ func (b *IWDBackend) DisconnectWiFi() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *IWDBackend) abortInFlightConnection(ssid string) {
|
||||||
|
b.stateMutex.Lock()
|
||||||
|
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
||||||
|
b.stateMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.state.IsConnecting = false
|
||||||
|
b.state.ConnectingSSID = ""
|
||||||
|
b.state.LastError = ""
|
||||||
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
|
b.attemptMutex.RLock()
|
||||||
|
att := b.curAttempt
|
||||||
|
b.attemptMutex.RUnlock()
|
||||||
|
|
||||||
|
if att != nil && att.ssid == ssid {
|
||||||
|
att.mu.Lock()
|
||||||
|
att.finalized = true
|
||||||
|
att.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.DisconnectWiFi(); err != nil {
|
||||||
|
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
||||||
b.stateMutex.RLock()
|
b.stateMutex.RLock()
|
||||||
currentSSID := b.state.WiFiSSID
|
currentSSID := b.state.WiFiSSID
|
||||||
@@ -812,6 +838,10 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
|
|||||||
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
|
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !autoconnect {
|
||||||
|
b.abortInFlightConnection(ssid)
|
||||||
|
}
|
||||||
|
|
||||||
b.updateState()
|
b.updateState()
|
||||||
|
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
|
|||||||
@@ -359,6 +359,24 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) abortInFlightConnection(ssid string) {
|
||||||
|
b.stateMutex.Lock()
|
||||||
|
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
||||||
|
b.stateMutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.state.IsConnecting = false
|
||||||
|
b.state.ConnectingSSID = ""
|
||||||
|
b.state.LastError = ""
|
||||||
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
|
b.clearCachedWiFiSecretBySSID(ssid)
|
||||||
|
|
||||||
|
if err := b.DisconnectWiFi(); err != nil {
|
||||||
|
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
||||||
conn, err := b.findConnection(ssid)
|
conn, err := b.findConnection(ssid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -963,6 +981,10 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
|||||||
return fmt.Errorf("failed to update connection: %w", err)
|
return fmt.Errorf("failed to update connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !autoconnect {
|
||||||
|
b.abortInFlightConnection(ssid)
|
||||||
|
}
|
||||||
|
|
||||||
b.updateWiFiNetworks()
|
b.updateWiFiNetworks()
|
||||||
|
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
|
|||||||
@@ -50,11 +50,42 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
|
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||||
if len(opts.Ignored) > 0 {
|
}
|
||||||
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
|
|
||||||
|
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||||
|
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||||
|
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||||
|
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||||
|
if len(ignored) == 0 {
|
||||||
|
return ignored
|
||||||
|
}
|
||||||
|
repoPending := make(map[string]bool, len(pending))
|
||||||
|
for _, p := range pending {
|
||||||
|
if p.Repo == RepoSystem {
|
||||||
|
repoPending[p.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ignored))
|
||||||
|
for _, name := range ignored {
|
||||||
|
if repoPending[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPacmanFamily(b Backend) bool {
|
||||||
|
if b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch b.ID() {
|
||||||
|
case "pacman", "paru", "yay":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return privilegedArgv(opts, argv...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type archHelperBackend struct {
|
type archHelperBackend struct {
|
||||||
@@ -112,7 +143,7 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
|
|||||||
}
|
}
|
||||||
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
||||||
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapInTerminal(term, title, shellCmd string) []string {
|
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||||
const appID = "com.danklinux.dms"
|
const appID = "com.danklinux.dms"
|
||||||
banner := fmt.Sprintf(
|
banner := fmt.Sprintf(
|
||||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
@@ -129,24 +129,25 @@ func wrapInTerminal(term, title, shellCmd string) []string {
|
|||||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
full := export + banner + "; " + shellCmd + "; " + closer
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
|
var argv []string
|
||||||
|
execFlag := "-e"
|
||||||
switch term {
|
switch term {
|
||||||
case "kitty":
|
case "kitty", "alacritty", "wezterm":
|
||||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
argv = []string{term, "--class", appID, "-T", title}
|
||||||
case "alacritty":
|
|
||||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
|
||||||
case "foot":
|
case "foot":
|
||||||
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
argv = []string{term, "--app-id=" + appID, "--title=" + title}
|
||||||
case "ghostty":
|
case "ghostty":
|
||||||
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
argv = []string{term, "--class=" + appID, "--title=" + title}
|
||||||
case "wezterm":
|
|
||||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
|
||||||
case "xterm":
|
case "xterm":
|
||||||
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
argv = []string{term, "-class", appID, "-T", title}
|
||||||
case "konsole":
|
case "konsole":
|
||||||
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
argv = []string{term, "-p", "tabtitle=" + title}
|
||||||
case "gnome-terminal":
|
case "gnome-terminal":
|
||||||
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
argv = []string{term, "--title=" + title}
|
||||||
|
execFlag = "--"
|
||||||
default:
|
default:
|
||||||
return []string{term, "-e", "sh", "-c", full}
|
argv = []string{term}
|
||||||
}
|
}
|
||||||
|
argv = append(argv, extraArgs...)
|
||||||
|
return append(argv, execFlag, "sh", "-c", full)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
DryRun: params.BoolOpt(req.Params, "dry", false),
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
TerminalArgs: stringSliceOpt(req.Params, "terminalArgs"),
|
||||||
Ignored: stringSliceOpt(req.Params, "ignored"),
|
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||||
}
|
}
|
||||||
if err := m.Upgrade(opts); err != nil {
|
if err := m.Upgrade(opts); err != nil {
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if opts.CustomCommand != "" {
|
if opts.CustomCommand != "" {
|
||||||
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
m.runCustomUpgrade(ctx, opts)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +345,9 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
if isPacmanFamily(m.selection.System) {
|
||||||
|
opts.Ignored = dropPacmanRepoIgnores(opts.Ignored, opts.Targets)
|
||||||
|
}
|
||||||
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||||
|
|
||||||
backends := upgradeBackends(m.selection, opts)
|
backends := upgradeBackends(m.selection, opts)
|
||||||
@@ -389,8 +392,8 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
m.finishSuccessfulUpgrade(true)
|
m.finishSuccessfulUpgrade(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||||
term := findTerminal(terminalOverride)
|
term := findTerminal(opts.Terminal)
|
||||||
if term == "" {
|
if term == "" {
|
||||||
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
return
|
return
|
||||||
@@ -407,7 +410,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
|
|||||||
m.markDirty()
|
m.markDirty()
|
||||||
|
|
||||||
onLine := func(line string) { m.appendLog(line) }
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
|
||||||
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||||
code := ErrCodeBackendFailed
|
code := ErrCodeBackendFailed
|
||||||
switch {
|
switch {
|
||||||
@@ -425,6 +428,7 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.finishSuccessfulUpgrade(false)
|
m.finishSuccessfulUpgrade(false)
|
||||||
|
m.runRefresh(context.Background(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type UpgradeOptions struct {
|
|||||||
AttachStdio bool
|
AttachStdio bool
|
||||||
CustomCommand string
|
CustomCommand string
|
||||||
Terminal string
|
Terminal string
|
||||||
|
TerminalArgs []string
|
||||||
Targets []Package
|
Targets []Package
|
||||||
Ignored []string
|
Ignored []string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pacman with ignored packages",
|
name: "pacman never passes --ignore",
|
||||||
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
||||||
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
|
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dnf with ignored packages",
|
name: "dnf with ignored packages",
|
||||||
@@ -106,6 +106,18 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDropPacmanRepoIgnoresKeepsAURHolds(t *testing.T) {
|
||||||
|
pending := []Package{
|
||||||
|
{Name: "linux", Repo: RepoSystem},
|
||||||
|
{Name: "librewolf", Repo: RepoAUR},
|
||||||
|
}
|
||||||
|
got := dropPacmanRepoIgnores([]string{"linux", "librewolf", "not-pending"}, pending)
|
||||||
|
want := []string{"librewolf", "not-pending"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("ignored = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
|
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
|
||||||
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
||||||
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
||||||
|
|||||||
Submodule
+1
Submodule dank-qml-common added at 5942bc1c60
@@ -70,7 +70,7 @@ override_dh_auto_install:
|
|||||||
|
|
||||||
mkdir -p debian/dms-git/usr/share/quickshell/dms debian/dms-git/usr/lib/systemd/user
|
mkdir -p debian/dms-git/usr/share/quickshell/dms debian/dms-git/usr/lib/systemd/user
|
||||||
if [ -d quickshell ]; then \
|
if [ -d quickshell ]; then \
|
||||||
cp -r quickshell/* debian/dms-git/usr/share/quickshell/dms/; \
|
cp -rL quickshell/* debian/dms-git/usr/share/quickshell/dms/; \
|
||||||
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
|
install -Dm644 assets/systemd/dms.service debian/dms-git/usr/lib/systemd/user/dms.service; \
|
||||||
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
|
install -Dm644 assets/dms-open.desktop debian/dms-git/usr/share/applications/dms-open.desktop; \
|
||||||
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
|
install -Dm644 assets/com.danklinux.dms.desktop debian/dms-git/usr/share/applications/com.danklinux.dms.desktop; \
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ override_dh_auto_install:
|
|||||||
fi; \
|
fi; \
|
||||||
if [ -n "$$SOURCE_DIR" ]; then \
|
if [ -n "$$SOURCE_DIR" ]; then \
|
||||||
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \
|
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter && \
|
||||||
( cd $$SOURCE_DIR && tar cf - --exclude=debian . ) | \
|
( cd $$SOURCE_DIR && tar chf - --exclude=debian . ) | \
|
||||||
( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \
|
( cd debian/dms-greeter/usr/share/quickshell/dms-greeter && tar xf - ) && \
|
||||||
install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \
|
install -Dm755 $$SOURCE_DIR/Modules/Greetd/assets/dms-greeter \
|
||||||
debian/dms-greeter/usr/bin/dms-greeter && \
|
debian/dms-greeter/usr/bin/dms-greeter && \
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<service name="download_url">
|
<service name="download_url">
|
||||||
<param name="protocol">https</param>
|
<param name="protocol">https</param>
|
||||||
<param name="host">github.com</param>
|
<param name="host">github.com</param>
|
||||||
<param name="path">/AvengeMedia/DankMaterialShell/archive/refs/tags/v1.2.3.tar.gz</param>
|
<param name="path">/AvengeMedia/DankMaterialShell/releases/download/v1.2.3/dms-source.tar.gz</param>
|
||||||
<param name="filename">dms-source.tar.gz</param>
|
<param name="filename">dms-source.tar.gz</param>
|
||||||
</service>
|
</service>
|
||||||
<!-- Download amd64 binary -->
|
<!-- Download amd64 binary -->
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ override_dh_auto_install:
|
|||||||
mv "$$SOURCE_DIR" DankMaterialShell-$(UPSTREAM_VERSION); \
|
mv "$$SOURCE_DIR" DankMaterialShell-$(UPSTREAM_VERSION); \
|
||||||
fi
|
fi
|
||||||
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
|
if [ -d DankMaterialShell-$(UPSTREAM_VERSION) ]; then \
|
||||||
cp -r DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \
|
cp -rL DankMaterialShell-$(UPSTREAM_VERSION)/quickshell/* debian/dms/usr/share/quickshell/dms/; \
|
||||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
|
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/systemd/dms.service debian/dms/usr/lib/systemd/user/dms.service; \
|
||||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
|
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/dms-open.desktop debian/dms/usr/share/applications/dms-open.desktop; \
|
||||||
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
|
install -Dm644 DankMaterialShell-$(UPSTREAM_VERSION)/assets/com.danklinux.dms.desktop debian/dms/usr/share/applications/com.danklinux.dms.desktop; \
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ in
|
|||||||
];
|
];
|
||||||
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
||||||
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
||||||
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
|
# Do not add pam_u2f or pam_fprintd here for security-key unlock, enable
|
||||||
|
# programs.dank-material-shell.lockscreen.securityKey.enable, which declares the
|
||||||
|
# dedicated dankshell-u2f service DMS drives on its own.
|
||||||
# security.pam.services.dankshell = {
|
# security.pam.services.dankshell = {
|
||||||
# # Example: add faillock
|
# # Example: add faillock
|
||||||
# faillock.enable = true;
|
# faillock.enable = true;
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ in
|
|||||||
description = "Systemd target to bind to.";
|
description = "Systemd target to bind to.";
|
||||||
default = "graphical-session.target";
|
default = "graphical-session.target";
|
||||||
};
|
};
|
||||||
|
options.programs.dank-material-shell.lockscreen.securityKey = {
|
||||||
|
enable = lib.mkEnableOption "FIDO2/U2F security key unlock for the DMS lock screen via a dedicated dankshell-u2f PAM service";
|
||||||
|
package = lib.mkPackageOption pkgs "pam_u2f" { };
|
||||||
|
moduleArgs = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "cue" ];
|
||||||
|
description = "Arguments passed to pam_u2f.so in the dankshell-u2f PAM service.";
|
||||||
|
};
|
||||||
|
};
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
systemd.user.services.dms = lib.mkIf cfg.systemd.enable {
|
||||||
description = "DankMaterialShell";
|
description = "DankMaterialShell";
|
||||||
@@ -46,6 +55,18 @@ in
|
|||||||
inherit value;
|
inherit value;
|
||||||
}) common.plugins;
|
}) common.plugins;
|
||||||
|
|
||||||
|
# DMS's bundled U2F fallback stack references pam_u2f.so by name, which NixOS's
|
||||||
|
# libpam cannot resolve; the dedicated service below uses the absolute store path
|
||||||
|
# and is picked up automatically by the lock screen when present.
|
||||||
|
security.pam.services."dankshell-u2f" = lib.mkIf cfg.lockscreen.securityKey.enable {
|
||||||
|
text = ''
|
||||||
|
auth required ${cfg.lockscreen.securityKey.package}/lib/security/pam_u2f.so ${lib.concatStringsSep " " cfg.lockscreen.securityKey.moduleArgs}
|
||||||
|
account required pam_permit.so
|
||||||
|
password required pam_deny.so
|
||||||
|
session required pam_permit.so
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
services.power-profiles-daemon.enable = lib.mkDefault true;
|
services.power-profiles-daemon.enable = lib.mkDefault true;
|
||||||
services.accounts-daemon.enable = lib.mkDefault true;
|
services.accounts-daemon.enable = lib.mkDefault true;
|
||||||
services.geoclue2.enable = lib.mkDefault true;
|
services.geoclue2.enable = lib.mkDefault true;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
programs.dank-material-shell = {
|
programs.dank-material-shell = {
|
||||||
enable = true;
|
enable = true;
|
||||||
systemd.enable = true;
|
systemd.enable = true;
|
||||||
|
lockscreen.securityKey.enable = true;
|
||||||
plugins = {
|
plugins = {
|
||||||
TestPlugin = {
|
TestPlugin = {
|
||||||
src = pkgs.emptyDirectory;
|
src = pkgs.emptyDirectory;
|
||||||
@@ -39,6 +40,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
machine.succeed("su -- danklinux -c 'dms --help >/dev/null'")
|
||||||
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
machine.succeed("test -d /etc/xdg/quickshell/dms-plugins")
|
||||||
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
machine.succeed("test -f /run/current-system/sw/lib/systemd/user/dms.service")
|
||||||
|
machine.succeed("grep -q 'lib/security/pam_u2f.so cue' /etc/pam.d/dankshell-u2f")
|
||||||
|
|
||||||
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
payload = json.loads(machine.succeed("su -- danklinux -c 'dms doctor --json'"))
|
||||||
t.assertIn("summary", payload)
|
t.assertIn("summary", payload)
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ update_debian_dms_service() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i "s|/archive/refs/tags/v[0-9][^\"]*\.tar\.gz|/archive/refs/tags/v${base_version}.tar.gz|" "$service_path"
|
sed -i "s|/releases/download/v[0-9][^\"]*/dms-source\.tar\.gz|/releases/download/v${base_version}/dms-source.tar.gz|" "$service_path"
|
||||||
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-amd64\.gz|/releases/download/v${base_version}/dms-distropkg-amd64.gz|" "$service_path"
|
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-amd64\.gz|/releases/download/v${base_version}/dms-distropkg-amd64.gz|" "$service_path"
|
||||||
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path"
|
sed -i "s|/releases/download/v[0-9][^\"]*/dms-distropkg-arm64\.gz|/releases/download/v${base_version}/dms-distropkg-arm64.gz|" "$service_path"
|
||||||
}
|
}
|
||||||
@@ -542,8 +542,8 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
|||||||
if [[ -n "$GIT_URL" ]]; then
|
if [[ -n "$GIT_URL" ]]; then
|
||||||
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
||||||
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
||||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
if git clone --depth 1 --recurse-submodules --shallow-submodules --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
||||||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
git clone --depth 1 --recurse-submodules --shallow-submodules "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
||||||
cd "$SOURCE_DIR"
|
cd "$SOURCE_DIR"
|
||||||
if [[ -n "$GIT_REVISION" ]]; then
|
if [[ -n "$GIT_REVISION" ]]; then
|
||||||
git checkout "$GIT_REVISION" 2>/dev/null || true
|
git checkout "$GIT_REVISION" 2>/dev/null || true
|
||||||
@@ -586,7 +586,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
|||||||
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
||||||
mkdir -p "$EXPECTED_DIR"
|
mkdir -p "$EXPECTED_DIR"
|
||||||
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
|
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
|
||||||
tar -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
rm -rf "$EXPECTED_DIR"
|
rm -rf "$EXPECTED_DIR"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
;;
|
;;
|
||||||
@@ -604,7 +604,7 @@ if [[ "$UPLOAD_OPENSUSE" == true ]] && [[ "$UPLOAD_DEBIAN" == false ]] && [[ -f
|
|||||||
|
|
||||||
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
||||||
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
||||||
tar -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
rm -rf "$EXPECTED_DIR"
|
rm -rf "$EXPECTED_DIR"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
|
|
||||||
@@ -656,8 +656,8 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
if [[ -n "$GIT_URL" ]]; then
|
if [[ -n "$GIT_URL" ]]; then
|
||||||
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
echo " Cloning git source from: $GIT_URL (revision: ${GIT_REVISION:-master})"
|
||||||
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
SOURCE_DIR="$TEMP_DIR/dms-git-source"
|
||||||
if git clone --depth 1 --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
if git clone --depth 1 --recurse-submodules --shallow-submodules --branch "${GIT_REVISION:-master}" "$GIT_URL" "$SOURCE_DIR" 2>/dev/null ||
|
||||||
git clone --depth 1 "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
git clone --depth 1 --recurse-submodules --shallow-submodules "$GIT_URL" "$SOURCE_DIR" 2>/dev/null; then
|
||||||
cd "$SOURCE_DIR"
|
cd "$SOURCE_DIR"
|
||||||
if [[ -n "$GIT_REVISION" ]]; then
|
if [[ -n "$GIT_REVISION" ]]; then
|
||||||
git checkout "$GIT_REVISION" 2>/dev/null || true
|
git checkout "$GIT_REVISION" 2>/dev/null || true
|
||||||
@@ -820,11 +820,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
mkdir -p "$EXPECTED_DIR"
|
mkdir -p "$EXPECTED_DIR"
|
||||||
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
|
cp -a "$SOURCE_DIR"/. "$EXPECTED_DIR/"
|
||||||
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
else
|
else
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
fi
|
fi
|
||||||
rm -rf "$EXPECTED_DIR"
|
rm -rf "$EXPECTED_DIR"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
@@ -835,11 +835,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
||||||
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
||||||
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
else
|
else
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
fi
|
fi
|
||||||
rm -rf "$EXPECTED_DIR"
|
rm -rf "$EXPECTED_DIR"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
@@ -849,11 +849,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
echo " Creating $SOURCE0 (directory: $EXPECTED_DIR)"
|
||||||
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
cp -r "$SOURCE_DIR" "$EXPECTED_DIR"
|
||||||
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
else
|
else
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$EXPECTED_DIR"
|
||||||
fi
|
fi
|
||||||
rm -rf "$EXPECTED_DIR"
|
rm -rf "$EXPECTED_DIR"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
@@ -863,11 +863,11 @@ if [[ "$UPLOAD_DEBIAN" == true ]] && [[ -d "distro/debian/$PACKAGE/debian" ]]; t
|
|||||||
echo " Creating $SOURCE0 (directory: $DIR_NAME)"
|
echo " Creating $SOURCE0 (directory: $DIR_NAME)"
|
||||||
cp -r "$SOURCE_DIR" "$DIR_NAME"
|
cp -r "$SOURCE_DIR" "$DIR_NAME"
|
||||||
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
if [[ "$SOURCE0" == *.tar.xz ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cJhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
||||||
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
elif [[ "$SOURCE0" == *.tar.bz2 ]]; then
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -cjhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
||||||
else
|
else
|
||||||
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
tar --sort=name --mtime='2000-01-01 00:00:00' --owner=0 --group=0 -czhf "$WORK_DIR/$SOURCE0" "$DIR_NAME"
|
||||||
fi
|
fi
|
||||||
rm -rf "$DIR_NAME"
|
rm -rf "$DIR_NAME"
|
||||||
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
echo " Created $SOURCE0 ($(stat -c%s "$WORK_DIR/$SOURCE0" 2>/dev/null || echo 0) bytes)"
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ EOF
|
|||||||
|
|
||||||
if [ ! -f "dms-source.tar.gz" ]; then
|
if [ ! -f "dms-source.tar.gz" ]; then
|
||||||
info "Downloading dms source for QML files..."
|
info "Downloading dms source for QML files..."
|
||||||
if wget -O dms-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${VERSION}.tar.gz"; then
|
if wget -O dms-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-source.tar.gz"; then
|
||||||
success "source tarball downloaded"
|
success "source tarball downloaded"
|
||||||
else
|
else
|
||||||
error "Failed to download dms-source.tar.gz"
|
error "Failed to download dms-source.tar.gz"
|
||||||
@@ -356,7 +356,7 @@ EOF
|
|||||||
info "Downloading source for dms-greeter..."
|
info "Downloading source for dms-greeter..."
|
||||||
if [ ! -f "dms-greeter-source.tar.gz" ]; then
|
if [ ! -f "dms-greeter-source.tar.gz" ]; then
|
||||||
info "Downloading dms-greeter source..."
|
info "Downloading dms-greeter source..."
|
||||||
if wget -O dms-greeter-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${VERSION}.tar.gz"; then
|
if wget -O dms-greeter-source.tar.gz "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-source.tar.gz"; then
|
||||||
success "source tarball downloaded"
|
success "source tarball downloaded"
|
||||||
else
|
else
|
||||||
error "Failed to download dms-greeter-source.tar.gz"
|
error "Failed to download dms-greeter-source.tar.gz"
|
||||||
@@ -386,7 +386,7 @@ if [ "$IS_GIT_PACKAGE" = true ] && [ -n "$GIT_REPO" ]; then
|
|||||||
|
|
||||||
info "Cloning $GIT_REPO from GitHub (getting latest commit info)..."
|
info "Cloning $GIT_REPO from GitHub (getting latest commit info)..."
|
||||||
TEMP_CLONE=$(mktemp -d "$TEMP_BASE/ppa_clone_XXXXXX")
|
TEMP_CLONE=$(mktemp -d "$TEMP_BASE/ppa_clone_XXXXXX")
|
||||||
if git clone "https://github.com/$GIT_REPO.git" "$TEMP_CLONE"; then
|
if git clone --recurse-submodules --shallow-submodules "https://github.com/$GIT_REPO.git" "$TEMP_CLONE"; then
|
||||||
GIT_COMMIT_HASH=$(cd "$TEMP_CLONE" && git rev-parse --short HEAD)
|
GIT_COMMIT_HASH=$(cd "$TEMP_CLONE" && git rev-parse --short HEAD)
|
||||||
GIT_COMMIT_COUNT=$(cd "$TEMP_CLONE" && git rev-list --count HEAD)
|
GIT_COMMIT_COUNT=$(cd "$TEMP_CLONE" && git rev-list --count HEAD)
|
||||||
UPSTREAM_VERSION=$(cd "$TEMP_CLONE" && git tag -l "v*" | sed 's/^v//' | sort -V | tail -1)
|
UPSTREAM_VERSION=$(cd "$TEMP_CLONE" && git tag -l "v*" | sed 's/^v//' | sort -V | tail -1)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ override_dh_auto_install:
|
|||||||
|
|
||||||
# Install QML files from git clone
|
# Install QML files from git clone
|
||||||
mkdir -p debian/dms-git/usr/share/quickshell/dms
|
mkdir -p debian/dms-git/usr/share/quickshell/dms
|
||||||
cp -r dms-git-repo/* debian/dms-git/usr/share/quickshell/dms/
|
cp -rL dms-git-repo/* debian/dms-git/usr/share/quickshell/dms/
|
||||||
|
|
||||||
# Remove unnecessary directories
|
# Remove unnecessary directories
|
||||||
rm -rf debian/dms-git/usr/share/quickshell/dms/core
|
rm -rf debian/dms-git/usr/share/quickshell/dms/core
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ override_dh_auto_build:
|
|||||||
override_dh_auto_install:
|
override_dh_auto_install:
|
||||||
# Install greeter files to shared data location
|
# Install greeter files to shared data location
|
||||||
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter
|
mkdir -p debian/dms-greeter/usr/share/quickshell/dms-greeter
|
||||||
cp -r DankMaterialShell-$(BASE_VERSION)/quickshell/* debian/dms-greeter/usr/share/quickshell/dms-greeter/
|
cp -rL DankMaterialShell-$(BASE_VERSION)/quickshell/* debian/dms-greeter/usr/share/quickshell/dms-greeter/
|
||||||
|
|
||||||
# Install launcher script
|
# Install launcher script
|
||||||
install -Dm755 DankMaterialShell-$(BASE_VERSION)/quickshell/Modules/Greetd/assets/dms-greeter \
|
install -Dm755 DankMaterialShell-$(BASE_VERSION)/quickshell/Modules/Greetd/assets/dms-greeter \
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ override_dh_auto_install:
|
|||||||
|
|
||||||
# Install QML files from source tarball
|
# Install QML files from source tarball
|
||||||
mkdir -p debian/dms/usr/share/quickshell/dms
|
mkdir -p debian/dms/usr/share/quickshell/dms
|
||||||
cp -r DankMaterialShell-$(BASE_VERSION)/* debian/dms/usr/share/quickshell/dms/
|
cp -rL DankMaterialShell-$(BASE_VERSION)/* debian/dms/usr/share/quickshell/dms/
|
||||||
|
|
||||||
# Remove unnecessary directories
|
# Remove unnecessary directories
|
||||||
rm -rf debian/dms/usr/share/quickshell/dms/core
|
rm -rf debian/dms/usr/share/quickshell/dms/core
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ short_desc="DankMaterialShell greeter for greetd"
|
|||||||
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
|
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
|
||||||
license="MIT"
|
license="MIT"
|
||||||
homepage="https://danklinux.com"
|
homepage="https://danklinux.com"
|
||||||
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
distfiles="https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${version}/dms-source.tar.gz"
|
||||||
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
||||||
|
|
||||||
depends="greetd quickshell acl-progs dbus elogind pam_rundir mesa-dri"
|
depends="greetd quickshell acl-progs dbus elogind pam_rundir mesa-dri"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
|
|||||||
license="MIT"
|
license="MIT"
|
||||||
homepage="https://danklinux.com"
|
homepage="https://danklinux.com"
|
||||||
changelog="https://github.com/AvengeMedia/DankMaterialShell/releases"
|
changelog="https://github.com/AvengeMedia/DankMaterialShell/releases"
|
||||||
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
distfiles="https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${version}/dms-source.tar.gz"
|
||||||
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
||||||
|
|
||||||
# Optional feature deps are listed in distro/void/README.md.
|
# Optional feature deps are listed in distro/void/README.md.
|
||||||
|
|||||||
Generated
+17
@@ -1,5 +1,21 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"dank-qml-common": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784332064,
|
||||||
|
"narHash": "sha256-n0LKLirPnNSXYxElK1IpnybEwU0nnyso4kpsEFX88U0=",
|
||||||
|
"owner": "AvengeMedia",
|
||||||
|
"repo": "dank-qml-common",
|
||||||
|
"rev": "5942bc1c60b4538940922b58112c8ae125880891",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "AvengeMedia",
|
||||||
|
"repo": "dank-qml-common",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-compat": {
|
"flake-compat": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
@@ -34,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"dank-qml-common": "dank-qml-common",
|
||||||
"flake-compat": "flake-compat",
|
"flake-compat": "flake-compat",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
url = "github:NixOS/flake-compat";
|
url = "github:NixOS/flake-compat";
|
||||||
flake = false;
|
flake = false;
|
||||||
};
|
};
|
||||||
|
dank-qml-common = {
|
||||||
|
url = "github:AvengeMedia/dank-qml-common";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
dank-qml-common,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -125,6 +130,10 @@
|
|||||||
mkdir -p $out/share/quickshell/dms
|
mkdir -p $out/share/quickshell/dms
|
||||||
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
||||||
|
|
||||||
|
rm -f $out/share/quickshell/dms/DankCommon
|
||||||
|
cp -r ${dank-qml-common}/DankCommon $out/share/quickshell/dms/DankCommon
|
||||||
|
chmod -R u+w $out/share/quickshell/dms/DankCommon
|
||||||
|
|
||||||
chmod u+w $out/share/quickshell/dms/VERSION
|
chmod u+w $out/share/quickshell/dms/VERSION
|
||||||
echo "${version}" > $out/share/quickshell/dms/VERSION
|
echo "${version}" > $out/share/quickshell/dms/VERSION
|
||||||
|
|
||||||
|
|||||||
@@ -141,13 +141,15 @@ DankMaterialShell/
|
|||||||
│ │ ├── ipp/ # Internet Printing Protocol
|
│ │ ├── ipp/ # Internet Printing Protocol
|
||||||
│ │ └── syncmap/ # Thread-safe map
|
│ │ └── syncmap/ # Thread-safe map
|
||||||
│ └── go.mod # Go module definition
|
│ └── go.mod # Go module definition
|
||||||
|
├── dank-qml-common/ # Shared widget library (git submodule)
|
||||||
├── quickshell/ # QML frontend (UI layer) - see "QML Frontend Architecture" below
|
├── quickshell/ # QML frontend (UI layer) - see "QML Frontend Architecture" below
|
||||||
│ ├── shell.qml # Main entry point
|
│ ├── shell.qml # Main entry point
|
||||||
│ ├── Services/ # IPC client wrappers
|
│ ├── Services/ # IPC client wrappers
|
||||||
│ ├── Modules/ # UI components
|
│ ├── Modules/ # UI components
|
||||||
│ ├── Widgets/ # Reusable controls
|
│ ├── Widgets/ # Reusable controls
|
||||||
│ ├── Modals/ # Full-screen overlays
|
│ ├── Modals/ # Full-screen overlays
|
||||||
│ └── Common/ # Shared resources
|
│ ├── Common/ # Shared resources
|
||||||
|
│ └── DankCommon/ # Symlink to ../dank-qml-common/DankCommon
|
||||||
├── distro/ # Distribution packaging
|
├── distro/ # Distribution packaging
|
||||||
│ ├── arch/ # AUR packages
|
│ ├── arch/ # AUR packages
|
||||||
│ ├── fedora/ # RPM specs
|
│ ├── fedora/ # RPM specs
|
||||||
@@ -490,6 +492,8 @@ import qs.Services // For service access
|
|||||||
import qs.Widgets // For reusable widgets (DankIcon, etc.)
|
import qs.Widgets // For reusable widgets (DankIcon, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Many widgets in `Widgets/`, `Common/`, and `Modals/FileBrowser/` are thin wrappers over the shared dank-qml-common library (`quickshell/DankCommon/`, a submodule symlink). Keep importing them through `qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` — the wrappers are the stable API for the shell and for plugins. Edit the implementations in `dank-qml-common/` (see CONTRIBUTING.md, "Shared widgets").
|
||||||
|
|
||||||
#### Go Import Order
|
#### Go Import Order
|
||||||
|
|
||||||
Follow standard Go conventions:
|
Follow standard Go conventions:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
The Wolverine
|
Marble Tabby
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Singleton {
|
|||||||
|
|
||||||
property var appUsageRanking: {}
|
property var appUsageRanking: {}
|
||||||
property bool _saving: false
|
property bool _saving: false
|
||||||
|
property bool _hasLoaded: false
|
||||||
|
property bool _parseError: false
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -21,15 +23,21 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSettings(content) {
|
function parseSettings(content) {
|
||||||
|
_parseError = false;
|
||||||
try {
|
try {
|
||||||
if (content && content.trim()) {
|
if (content && content.trim()) {
|
||||||
var settings = JSON.parse(content);
|
var settings = JSON.parse(content);
|
||||||
appUsageRanking = settings.appUsageRanking || {};
|
appUsageRanking = settings.appUsageRanking || {};
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
_hasLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
_parseError = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
|
if (_parseError || !_hasLoaded)
|
||||||
|
return;
|
||||||
settingsFile.setText(JSON.stringify({
|
settingsFile.setText(JSON.stringify({
|
||||||
"appUsageRanking": appUsageRanking
|
"appUsageRanking": appUsageRanking
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
|
|||||||
@@ -1,66 +1,11 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import qs.DankCommon.Common as DankCommon
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
readonly property var rounding: DankCommon.Appearance.rounding
|
||||||
|
readonly property var spacing: DankCommon.Appearance.spacing
|
||||||
readonly property Rounding rounding: Rounding {}
|
readonly property var fontSize: DankCommon.Appearance.fontSize
|
||||||
readonly property Spacing spacing: Spacing {}
|
readonly property var anim: DankCommon.Appearance.anim
|
||||||
readonly property FontSize fontSize: FontSize {}
|
|
||||||
readonly property Anim anim: Anim {}
|
|
||||||
|
|
||||||
component Rounding: QtObject {
|
|
||||||
readonly property int small: 8
|
|
||||||
readonly property int normal: 12
|
|
||||||
readonly property int large: 16
|
|
||||||
readonly property int extraLarge: 24
|
|
||||||
readonly property int full: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
component Spacing: QtObject {
|
|
||||||
readonly property int small: 4
|
|
||||||
readonly property int normal: 8
|
|
||||||
readonly property int large: 12
|
|
||||||
readonly property int extraLarge: 16
|
|
||||||
readonly property int huge: 24
|
|
||||||
}
|
|
||||||
|
|
||||||
component FontSize: QtObject {
|
|
||||||
readonly property int small: 12
|
|
||||||
readonly property int normal: 14
|
|
||||||
readonly property int large: 16
|
|
||||||
readonly property int extraLarge: 20
|
|
||||||
readonly property int huge: 24
|
|
||||||
}
|
|
||||||
|
|
||||||
component AnimCurves: QtObject {
|
|
||||||
readonly property list<real> standard: [0.2, 0, 0, 1, 1, 1]
|
|
||||||
readonly property list<real> standardAccel: [0.3, 0, 1, 1, 1, 1]
|
|
||||||
readonly property list<real> standardDecel: [0, 0, 0, 1, 1, 1]
|
|
||||||
readonly property list<real> emphasized: [0.05, 0, 2 / 15, 0.06, 1
|
|
||||||
/ 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1]
|
|
||||||
readonly property list<real> emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1]
|
|
||||||
readonly property list<real> emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1]
|
|
||||||
readonly property list<real> expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1]
|
|
||||||
readonly property list<real> expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
|
|
||||||
readonly property list<real> expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
component AnimDurations: QtObject {
|
|
||||||
readonly property int quick: 150
|
|
||||||
readonly property int normal: 300
|
|
||||||
readonly property int slow: 500
|
|
||||||
readonly property int extraSlow: 1000
|
|
||||||
readonly property int expressiveFastSpatial: 350
|
|
||||||
readonly property int expressiveDefaultSpatial: 500
|
|
||||||
readonly property int expressiveEffects: 200
|
|
||||||
}
|
|
||||||
|
|
||||||
component Anim: QtObject {
|
|
||||||
readonly property AnimCurves curves: AnimCurves {}
|
|
||||||
readonly property AnimDurations durations: AnimDurations {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Common as DankCommon
|
||||||
import qs.Common
|
|
||||||
|
|
||||||
// Reusable NumberAnimation wrapper
|
DankCommon.DankAnim {}
|
||||||
NumberAnimation {
|
|
||||||
duration: Theme.expressiveDurations.normal
|
|
||||||
easing.type: Easing.BezierSpline
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Common as DankCommon
|
||||||
import qs.Common
|
|
||||||
|
|
||||||
// Reusable ColorAnimation wrapper
|
DankCommon.DankColorAnim {}
|
||||||
ColorAnimation {
|
|
||||||
duration: Theme.expressiveDurations.normal
|
|
||||||
easing.type: Easing.BezierSpline
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,64 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Common as DankCommon
|
||||||
import Quickshell.Io
|
|
||||||
|
|
||||||
Item {
|
DankCommon.DankSocket {}
|
||||||
id: root
|
|
||||||
|
|
||||||
property alias path: socket.path
|
|
||||||
property alias parser: socket.parser
|
|
||||||
property bool connected: false
|
|
||||||
property bool linkUp: false
|
|
||||||
|
|
||||||
property int reconnectBaseMs: 400
|
|
||||||
property int reconnectMaxMs: 15000
|
|
||||||
|
|
||||||
property int _reconnectAttempt: 0
|
|
||||||
|
|
||||||
signal connectionStateChanged
|
|
||||||
|
|
||||||
onConnectedChanged: {
|
|
||||||
socket.connected = connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
Socket {
|
|
||||||
id: socket
|
|
||||||
|
|
||||||
onConnectionStateChanged: {
|
|
||||||
root.linkUp = connected;
|
|
||||||
root.connectionStateChanged();
|
|
||||||
if (connected) {
|
|
||||||
root._reconnectAttempt = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (root.connected) {
|
|
||||||
root._scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: reconnectTimer
|
|
||||||
interval: 0
|
|
||||||
repeat: false
|
|
||||||
onTriggered: {
|
|
||||||
socket.connected = false;
|
|
||||||
Qt.callLater(() => socket.connected = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(data) {
|
|
||||||
const json = typeof data === "string" ? data : JSON.stringify(data);
|
|
||||||
const message = json.endsWith("\n") ? json : json + "\n";
|
|
||||||
socket.write(message);
|
|
||||||
socket.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _scheduleReconnect() {
|
|
||||||
const pow = Math.min(_reconnectAttempt, 10);
|
|
||||||
const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs);
|
|
||||||
const jitter = Math.floor(Math.random() * Math.floor(base / 4));
|
|
||||||
reconnectTimer.interval = base + jitter;
|
|
||||||
reconnectTimer.restart();
|
|
||||||
_reconnectAttempt++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +1,3 @@
|
|||||||
pragma ComponentBehavior: Bound
|
import qs.DankCommon.Common as DankCommon
|
||||||
|
|
||||||
import QtQuick
|
DankCommon.ElevationShadow {}
|
||||||
import qs.Common
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
property var level: Theme.elevationLevel2
|
|
||||||
property string direction: Theme.elevationLightDirection
|
|
||||||
property real fallbackOffset: 4
|
|
||||||
|
|
||||||
property color targetColor: "white"
|
|
||||||
property real targetRadius: Theme.cornerRadius
|
|
||||||
property real topLeftRadius: targetRadius
|
|
||||||
property real topRightRadius: targetRadius
|
|
||||||
property real bottomLeftRadius: targetRadius
|
|
||||||
property real bottomRightRadius: targetRadius
|
|
||||||
property color borderColor: "transparent"
|
|
||||||
property real borderWidth: 0
|
|
||||||
|
|
||||||
property real sourceX: 0
|
|
||||||
property real sourceY: 0
|
|
||||||
property real sourceWidth: width
|
|
||||||
property real sourceHeight: height
|
|
||||||
|
|
||||||
property bool shadowEnabled: Theme.elevationEnabled
|
|
||||||
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
|
|
||||||
property real shadowSpreadPx: level && level.spreadPx !== undefined ? level.spreadPx : 0
|
|
||||||
property real shadowOffsetX: Theme.elevationOffsetXFor(level, direction, fallbackOffset)
|
|
||||||
property real shadowOffsetY: Theme.elevationOffsetYFor(level, direction, fallbackOffset)
|
|
||||||
property color shadowColor: Theme.elevationShadowColor(level)
|
|
||||||
property real shadowOpacity: 1
|
|
||||||
|
|
||||||
readonly property var _ambient: Theme.elevationAmbient(level)
|
|
||||||
readonly property real _pad: shadowEnabled ? Math.ceil(Math.max(shadowBlurPx + shadowSpreadPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)), _ambient.blurPx + _ambient.spreadPx) + 2) : 0
|
|
||||||
|
|
||||||
ShaderEffect {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: -root._pad
|
|
||||||
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/elevation_rect.frag.qsb")
|
|
||||||
|
|
||||||
property real widthPx: width
|
|
||||||
property real heightPx: height
|
|
||||||
property real borderWidth: root.borderWidth
|
|
||||||
property vector4d rectPx: Qt.vector4d(root._pad + root.sourceX, root._pad + root.sourceY, root.sourceWidth, root.sourceHeight)
|
|
||||||
property vector4d cornerRadius: Qt.vector4d(root.topLeftRadius, root.topRightRadius, root.bottomRightRadius, root.bottomLeftRadius)
|
|
||||||
property vector4d fillColor: Qt.vector4d(root.targetColor.r, root.targetColor.g, root.targetColor.b, root.targetColor.a)
|
|
||||||
property vector4d borderColor: Qt.vector4d(root.borderColor.r, root.borderColor.g, root.borderColor.b, root.borderColor.a)
|
|
||||||
property vector4d shadowColor: Qt.vector4d(root.shadowColor.r, root.shadowColor.g, root.shadowColor.b, root.shadowEnabled ? root.shadowColor.a * root.shadowOpacity : 0)
|
|
||||||
property vector4d shadowParam: Qt.vector4d(Math.max(0, root.shadowBlurPx), root.shadowSpreadPx, root.shadowOffsetX, root.shadowOffsetY)
|
|
||||||
property vector4d ambientParam: Qt.vector4d(root._ambient.blurPx, root._ambient.spreadPx, root.shadowEnabled ? root._ambient.alpha * root.shadowOpacity : 0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+78
-12
@@ -25,6 +25,7 @@ Singleton {
|
|||||||
readonly property bool isRtl: _rtlLanguages.includes(_lang)
|
readonly property bool isRtl: _rtlLanguages.includes(_lang)
|
||||||
|
|
||||||
readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports")
|
readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports")
|
||||||
|
readonly property url commonTranslationsFolder: Qt.resolvedUrl("../DankCommon/translations/poexports")
|
||||||
|
|
||||||
readonly property alias folder: dir.folder
|
readonly property alias folder: dir.folder
|
||||||
property var presentLocales: ({
|
property var presentLocales: ({
|
||||||
@@ -32,8 +33,11 @@ Singleton {
|
|||||||
})
|
})
|
||||||
property var translations: ({})
|
property var translations: ({})
|
||||||
property bool translationsLoaded: false
|
property bool translationsLoaded: false
|
||||||
|
property var commonTranslations: ({})
|
||||||
|
property bool commonTranslationsLoaded: false
|
||||||
|
|
||||||
property url _selectedPath: ""
|
property url _selectedPath: ""
|
||||||
|
property url _commonSelectedPath: ""
|
||||||
|
|
||||||
FolderListModel {
|
FolderListModel {
|
||||||
id: dir
|
id: dir
|
||||||
@@ -48,6 +52,18 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FolderListModel {
|
||||||
|
id: commonDir
|
||||||
|
folder: root.commonTranslationsFolder
|
||||||
|
nameFilters: ["*.json"]
|
||||||
|
showDirs: false
|
||||||
|
showDotAndDotDot: false
|
||||||
|
|
||||||
|
onStatusChanged: if (status === FolderListModel.Ready) {
|
||||||
|
root._pickCommonTranslation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: translationLoader
|
id: translationLoader
|
||||||
path: root._selectedPath
|
path: root._selectedPath
|
||||||
@@ -69,6 +85,22 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: commonTranslationLoader
|
||||||
|
path: root._commonSelectedPath
|
||||||
|
printErrors: false
|
||||||
|
|
||||||
|
onLoaded: {
|
||||||
|
try {
|
||||||
|
root.commonTranslations = JSON.parse(text());
|
||||||
|
root.commonTranslationsLoaded = true;
|
||||||
|
log.info(`I18n: Loaded DankCommon translations (${Object.keys(root.commonTranslations).length} contexts)`);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("I18n: Error parsing DankCommon translations:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function locale() {
|
function locale() {
|
||||||
if (SessionData.timeLocale)
|
if (SessionData.timeLocale)
|
||||||
return Qt.locale(SessionData.timeLocale);
|
return Qt.locale(SessionData.timeLocale);
|
||||||
@@ -117,24 +149,58 @@ Singleton {
|
|||||||
log.warn("Falling back to built-in English strings");
|
log.warn("Falling back to built-in English strings");
|
||||||
}
|
}
|
||||||
|
|
||||||
function tr(term, context) {
|
function _pickCommonTranslation() {
|
||||||
if (!translationsLoaded || !translations)
|
const present = {};
|
||||||
return term;
|
for (let i = 0; i < commonDir.count; i++) {
|
||||||
const ctx = context || term;
|
const name = commonDir.get(i, "fileName");
|
||||||
if (translations[ctx] && translations[ctx][term])
|
if (name && name.endsWith(".json"))
|
||||||
return translations[ctx][term];
|
present[name.slice(0, -5)] = true;
|
||||||
for (const c in translations) {
|
}
|
||||||
if (translations[c] && translations[c][term])
|
for (let i = 0; i < _candidates.length; i++) {
|
||||||
return translations[c][term];
|
if (!present[_candidates[i]])
|
||||||
|
continue;
|
||||||
|
_commonSelectedPath = commonTranslationsFolder + "/" + _candidates[i] + ".json";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lookup(table, term, context) {
|
||||||
|
if (!table)
|
||||||
|
return "";
|
||||||
|
if (context && table[context] && table[context][term])
|
||||||
|
return table[context][term];
|
||||||
|
if (table[term] && table[term][term])
|
||||||
|
return table[term][term];
|
||||||
|
for (const c in table) {
|
||||||
|
if (table[c] && table[c][term])
|
||||||
|
return table[c][term];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRealContext is consumed by translations/extract_translations.py only:
|
||||||
|
// pass a literal `true` (same line) to give (term, context) its own POEditor
|
||||||
|
// translation slot. Lookup ignores it -- a real context exists as a bucket
|
||||||
|
// in the export, a comment-only context does not.
|
||||||
|
function tr(term, context, isRealContext) {
|
||||||
|
if (translationsLoaded) {
|
||||||
|
const hit = _lookup(translations, term, context);
|
||||||
|
if (hit)
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
if (commonTranslationsLoaded) {
|
||||||
|
const hit = _lookup(commonTranslations, term, context);
|
||||||
|
if (hit)
|
||||||
|
return hit;
|
||||||
}
|
}
|
||||||
return term;
|
return term;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trContext(context, term) {
|
function trContext(context, term) {
|
||||||
if (!translationsLoaded || !translations)
|
if (translationsLoaded && translations[context] && translations[context][term])
|
||||||
return term;
|
|
||||||
if (translations[context] && translations[context][term])
|
|
||||||
return translations[context][term];
|
return translations[context][term];
|
||||||
|
if (commonTranslationsLoaded && commonTranslations[context] && commonTranslations[context][term])
|
||||||
|
return commonTranslations[context][term];
|
||||||
return term;
|
return term;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const DMS_ACTIONS = [
|
|||||||
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
|
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
|
||||||
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
|
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
|
||||||
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" },
|
{ id: "spawn dms ipc call lock lock", label: "Lock Screen" },
|
||||||
|
{ id: "spawn dms ipc call lock lockAndOutputsOff", label: "Lock Screen & Outputs Off" },
|
||||||
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
|
{ id: "spawn dms ipc call lock demo", label: "Lock Screen: Demo" },
|
||||||
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
|
{ id: "spawn dms ipc call inhibit toggle", label: "Idle Inhibit: Toggle" },
|
||||||
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
|
{ id: "spawn dms ipc call inhibit enable", label: "Idle Inhibit: Enable" },
|
||||||
|
|||||||
@@ -1,72 +1,13 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Common
|
import qs.DankCommon.Common as DankCommon
|
||||||
|
|
||||||
// Reusable ListView/GridView transitions
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
readonly property bool enabled: DankCommon.ListViewTransitions.enabled
|
||||||
|
readonly property Transition add: DankCommon.ListViewTransitions.add
|
||||||
// 0ms ViewTransitions break ListView delegate cleanup, so null the set when the shortest
|
readonly property Transition remove: DankCommon.ListViewTransitions.remove
|
||||||
// duration truncates to 0. Keep this gate - don't inline these back into add/remove/etc.
|
readonly property Transition displaced: DankCommon.ListViewTransitions.displaced
|
||||||
readonly property bool enabled: Math.floor(Theme.currentAnimationBaseDuration * 0.4) >= 1
|
readonly property Transition move: DankCommon.ListViewTransitions.move
|
||||||
|
|
||||||
readonly property Transition add: enabled ? _add : null
|
|
||||||
readonly property Transition remove: null
|
|
||||||
readonly property Transition displaced: enabled ? _displaced : null
|
|
||||||
readonly property Transition move: enabled ? _move : null
|
|
||||||
|
|
||||||
readonly property int _staggerMs: Math.round(Theme.currentAnimationBaseDuration * 0.03)
|
|
||||||
readonly property int _staggerCap: 8
|
|
||||||
|
|
||||||
readonly property Transition _add: Transition {
|
|
||||||
id: addTransition
|
|
||||||
|
|
||||||
SequentialAnimation {
|
|
||||||
PropertyAction {
|
|
||||||
property: "opacity"
|
|
||||||
value: 0
|
|
||||||
}
|
|
||||||
PauseAnimation {
|
|
||||||
duration: Math.max(0, Math.min(addTransition.ViewTransition.index - (addTransition.ViewTransition.targetIndexes[0] ?? 0), root._staggerCap)) * root._staggerMs
|
|
||||||
}
|
|
||||||
DankAnim {
|
|
||||||
property: "opacity"
|
|
||||||
from: 0
|
|
||||||
to: 1
|
|
||||||
duration: Theme.expressiveDurations.fast
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property Transition _displaced: Transition {
|
|
||||||
DankAnim {
|
|
||||||
property: "y"
|
|
||||||
duration: Theme.expressiveDurations.fast
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
DankAnim {
|
|
||||||
property: "opacity"
|
|
||||||
to: 1
|
|
||||||
duration: Theme.expressiveDurations.fast
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property Transition _move: Transition {
|
|
||||||
DankAnim {
|
|
||||||
property: "y"
|
|
||||||
duration: Theme.expressiveDurations.fast
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
DankAnim {
|
|
||||||
property: "opacity"
|
|
||||||
to: 1
|
|
||||||
duration: Theme.expressiveDurations.fast
|
|
||||||
easing.bezierCurve: Theme.expressiveCurves.standard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ Singleton {
|
|||||||
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
|
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trashPath(path: string, callback): void {
|
||||||
|
TrashService.trashPath(path, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPathToClipboard(path: string): void {
|
||||||
|
Quickshell.execDetached([Proc.dmsBin, "cl", "copy", path]);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveIconUrl(iconName: string): string {
|
function resolveIconUrl(iconName: string): string {
|
||||||
if (!iconName)
|
if (!iconName)
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
+4
-148
@@ -1,157 +1,13 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import qs.DankCommon.Common as DankCommon
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
readonly property int noTimeout: DankCommon.Proc.noTimeout
|
||||||
readonly property var log: Log.scoped("Proc")
|
readonly property string dmsBin: DankCommon.Proc.dmsBin
|
||||||
|
|
||||||
readonly property int noTimeout: -1
|
|
||||||
readonly property string dmsBin: Quickshell.env("DMS_EXECUTABLE") || "dms"
|
|
||||||
property int defaultDebounceMs: 50
|
|
||||||
property int defaultTimeoutMs: 10000
|
|
||||||
property var _procDebouncers: ({})
|
|
||||||
|
|
||||||
function runCommand(id, command, callback, debounceMs, timeoutMs) {
|
function runCommand(id, command, callback, debounceMs, timeoutMs) {
|
||||||
const wait = (typeof debounceMs === "number" && debounceMs >= 0) ? debounceMs : defaultDebounceMs;
|
DankCommon.Proc.runCommand(id, command, callback, debounceMs, timeoutMs);
|
||||||
const timeout = (typeof timeoutMs === "number") ? timeoutMs : defaultTimeoutMs;
|
|
||||||
let procId = id ? id : Math.random();
|
|
||||||
const isRandomId = !id;
|
|
||||||
|
|
||||||
if (!_procDebouncers[procId]) {
|
|
||||||
const t = debounceTimerComp.createObject(root);
|
|
||||||
t.triggered.connect(function () {
|
|
||||||
_launchProc(procId, isRandomId);
|
|
||||||
});
|
|
||||||
_procDebouncers[procId] = {
|
|
||||||
timer: t,
|
|
||||||
command: command,
|
|
||||||
callback: callback,
|
|
||||||
waitMs: wait,
|
|
||||||
timeoutMs: timeout,
|
|
||||||
isRandomId: isRandomId
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
_procDebouncers[procId].command = command;
|
|
||||||
_procDebouncers[procId].callback = callback;
|
|
||||||
_procDebouncers[procId].waitMs = wait;
|
|
||||||
_procDebouncers[procId].timeoutMs = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = _procDebouncers[procId];
|
|
||||||
entry.timer.interval = entry.waitMs;
|
|
||||||
entry.timer.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _launchProc(id, isRandomId) {
|
|
||||||
const entry = _procDebouncers[id];
|
|
||||||
if (!entry)
|
|
||||||
return;
|
|
||||||
const proc = procComp.createObject(root, {
|
|
||||||
command: entry.command
|
|
||||||
});
|
|
||||||
const timeoutTimer = debounceTimerComp.createObject(root);
|
|
||||||
|
|
||||||
let capturedOut = "";
|
|
||||||
let capturedErr = "";
|
|
||||||
let exitSeen = false;
|
|
||||||
let exitCodeValue = -1;
|
|
||||||
let outSeen = false;
|
|
||||||
let errSeen = false;
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
timeoutTimer.interval = entry.timeoutMs;
|
|
||||||
timeoutTimer.triggered.connect(function () {
|
|
||||||
if (!exitSeen) {
|
|
||||||
timedOut = true;
|
|
||||||
proc.running = false;
|
|
||||||
exitSeen = true;
|
|
||||||
exitCodeValue = 124;
|
|
||||||
maybeComplete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stdout.streamFinished.connect(function () {
|
|
||||||
try {
|
|
||||||
capturedOut = proc.stdout.text || "";
|
|
||||||
} catch (e) {
|
|
||||||
capturedOut = "";
|
|
||||||
}
|
|
||||||
outSeen = true;
|
|
||||||
maybeComplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.streamFinished.connect(function () {
|
|
||||||
try {
|
|
||||||
capturedErr = proc.stderr.text || "";
|
|
||||||
} catch (e) {
|
|
||||||
capturedErr = "";
|
|
||||||
}
|
|
||||||
errSeen = true;
|
|
||||||
maybeComplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.exited.connect(function (code) {
|
|
||||||
timeoutTimer.stop();
|
|
||||||
exitSeen = true;
|
|
||||||
exitCodeValue = code;
|
|
||||||
maybeComplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
function maybeComplete() {
|
|
||||||
if (!exitSeen || !outSeen || !errSeen)
|
|
||||||
return;
|
|
||||||
timeoutTimer.stop();
|
|
||||||
if (entry && entry.callback && typeof entry.callback === "function") {
|
|
||||||
try {
|
|
||||||
const safeOutput = capturedOut !== null && capturedOut !== undefined ? capturedOut : "";
|
|
||||||
const safeExitCode = exitCodeValue !== null && exitCodeValue !== undefined ? exitCodeValue : -1;
|
|
||||||
entry.callback(safeOutput, safeExitCode);
|
|
||||||
} catch (e) {
|
|
||||||
log.warn("runCommand callback error for command:", entry.command, "Error:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
proc.destroy();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
timeoutTimer.destroy();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
if (isRandomId || entry.isRandomId) {
|
|
||||||
Qt.callLater(function () {
|
|
||||||
if (_procDebouncers[id]) {
|
|
||||||
try {
|
|
||||||
_procDebouncers[id].timer.destroy();
|
|
||||||
} catch (_) {}
|
|
||||||
delete _procDebouncers[id];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.running = true;
|
|
||||||
if (entry.timeoutMs !== noTimeout)
|
|
||||||
timeoutTimer.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
Component {
|
|
||||||
id: debounceTimerComp
|
|
||||||
Timer {
|
|
||||||
repeat: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Component {
|
|
||||||
id: procComp
|
|
||||||
Process {
|
|
||||||
running: false
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
stderr: StdioCollector {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ Singleton {
|
|||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ Singleton {
|
|||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ Singleton {
|
|||||||
onBlurForegroundLayersChanged: saveSettings()
|
onBlurForegroundLayersChanged: saveSettings()
|
||||||
property real blurLayerOutlineOpacity: 0.12
|
property real blurLayerOutlineOpacity: 0.12
|
||||||
onBlurLayerOutlineOpacityChanged: saveSettings()
|
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||||
|
property bool blurBorderEnabled: true
|
||||||
|
onBlurBorderEnabledChanged: saveSettings()
|
||||||
property string blurBorderColor: "outline"
|
property string blurBorderColor: "outline"
|
||||||
onBlurBorderColorChanged: saveSettings()
|
onBlurBorderColorChanged: saveSettings()
|
||||||
property string blurBorderCustomColor: "#ffffff"
|
property string blurBorderCustomColor: "#ffffff"
|
||||||
@@ -758,6 +760,7 @@ Singleton {
|
|||||||
property int batteryLowNotificationType: 0
|
property int batteryLowNotificationType: 0
|
||||||
property int batteryCriticalNotificationType: 1
|
property int batteryCriticalNotificationType: 1
|
||||||
property bool batteryAutoPowerSaver: false
|
property bool batteryAutoPowerSaver: false
|
||||||
|
property bool lowerDisplayRefreshRateOnBattery: false
|
||||||
property bool showBatteryPercent: true
|
property bool showBatteryPercent: true
|
||||||
property bool showBatteryPercentOnlyOnBattery: false
|
property bool showBatteryPercentOnlyOnBattery: false
|
||||||
property bool showBatteryTime: false
|
property bool showBatteryTime: false
|
||||||
@@ -859,6 +862,7 @@ Singleton {
|
|||||||
property bool notificationOverlayEnabled: false
|
property bool notificationOverlayEnabled: false
|
||||||
property bool notificationPopupShadowEnabled: true
|
property bool notificationPopupShadowEnabled: true
|
||||||
property bool notificationPopupPrivacyMode: false
|
property bool notificationPopupPrivacyMode: false
|
||||||
|
property bool notificationForegroundLayers: true
|
||||||
property int overviewRows: 2
|
property int overviewRows: 2
|
||||||
property int overviewColumns: 5
|
property int overviewColumns: 5
|
||||||
property real overviewScale: 0.16
|
property real overviewScale: 0.16
|
||||||
@@ -898,6 +902,8 @@ Singleton {
|
|||||||
property string lockPamPath: ""
|
property string lockPamPath: ""
|
||||||
property bool lockPamInlineFprint: false
|
property bool lockPamInlineFprint: false
|
||||||
property bool lockPamInlineU2f: false
|
property bool lockPamInlineU2f: false
|
||||||
|
property bool lockPamExternallyManaged: false
|
||||||
|
property string lockU2fPamPath: ""
|
||||||
property bool greeterPamExternallyManaged: false
|
property bool greeterPamExternallyManaged: false
|
||||||
property string lockScreenInactiveColor: "#000000"
|
property string lockScreenInactiveColor: "#000000"
|
||||||
property int lockScreenNotificationMode: 0
|
property int lockScreenNotificationMode: 0
|
||||||
@@ -969,6 +975,8 @@ Singleton {
|
|||||||
property var hyprlandOutputSettings: ({})
|
property var hyprlandOutputSettings: ({})
|
||||||
property var displayProfiles: ({})
|
property var displayProfiles: ({})
|
||||||
property var activeDisplayProfile: ({})
|
property var activeDisplayProfile: ({})
|
||||||
|
property var activeDisplayProfileModes: ({})
|
||||||
|
property var displayPreviousRefreshModes: ({})
|
||||||
property bool displayProfileAutoSelect: false
|
property bool displayProfileAutoSelect: false
|
||||||
property bool displayShowDisconnected: false
|
property bool displayShowDisconnected: false
|
||||||
property bool displaySnapToEdge: true
|
property bool displaySnapToEdge: true
|
||||||
@@ -1791,7 +1799,7 @@ Singleton {
|
|||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to parse settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("settings.json"), msg));
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@@ -1889,7 +1897,7 @@ Singleton {
|
|||||||
_pluginParseError = true;
|
_pluginParseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to parse plugin_settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse plugin_settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("plugin_settings.json"), msg));
|
||||||
pluginSettings = {};
|
pluginSettings = {};
|
||||||
} finally {
|
} finally {
|
||||||
_pluginSettingsLoading = false;
|
_pluginSettingsLoading = false;
|
||||||
@@ -3556,6 +3564,27 @@ Singleton {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setActiveDisplayProfileModes(compositor, modes) {
|
||||||
|
if (JSON.stringify(activeDisplayProfileModes[compositor] || {}) === JSON.stringify(modes || {}))
|
||||||
|
return;
|
||||||
|
const updated = JSON.parse(JSON.stringify(activeDisplayProfileModes));
|
||||||
|
updated[compositor] = modes;
|
||||||
|
activeDisplayProfileModes = updated;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplayPreviousRefreshModes(compositor, modes) {
|
||||||
|
if (JSON.stringify(displayPreviousRefreshModes[compositor] || {}) === JSON.stringify(modes || {}))
|
||||||
|
return;
|
||||||
|
const updated = JSON.parse(JSON.stringify(displayPreviousRefreshModes));
|
||||||
|
if (Object.keys(modes || {}).length > 0)
|
||||||
|
updated[compositor] = modes;
|
||||||
|
else
|
||||||
|
delete updated[compositor];
|
||||||
|
displayPreviousRefreshModes = updated;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
id: leftWidgetsModel
|
id: leftWidgetsModel
|
||||||
}
|
}
|
||||||
@@ -3651,7 +3680,7 @@ Singleton {
|
|||||||
_parseError = true;
|
_parseError = true;
|
||||||
const msg = e.message;
|
const msg = e.message;
|
||||||
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
log.error("Failed to reload settings.json - file will not be overwritten. Error:", msg);
|
||||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse settings.json"), msg));
|
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("settings.json"), msg));
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Common
|
import qs.Common
|
||||||
|
import qs.DankCommon.Common as DankCommon
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Modules.Greetd
|
import qs.Modules.Greetd
|
||||||
import "StockThemes.js" as StockThemes
|
import "StockThemes.js" as StockThemes
|
||||||
@@ -580,11 +581,15 @@ Singleton {
|
|||||||
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
||||||
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
|
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
|
||||||
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
|
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
|
||||||
|
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
|
||||||
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
||||||
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||||
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||||
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||||
readonly property color nestedSurface: floatingSurfaceHigh
|
readonly property color nestedSurface: floatingSurfaceHigh
|
||||||
|
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||||
|
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||||
|
readonly property color notificationNestedSurface: notificationFloatingSurfaceHigh
|
||||||
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
||||||
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
|
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
|
||||||
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
||||||
@@ -1226,16 +1231,28 @@ Singleton {
|
|||||||
|
|
||||||
property string fontFamily: {
|
property string fontFamily: {
|
||||||
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
|
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
|
||||||
return GreetdSettings.getEffectiveFontFamily();
|
return resolvedFontFamily(GreetdSettings.getEffectiveFontFamily());
|
||||||
}
|
}
|
||||||
return typeof SettingsData !== "undefined" ? SettingsData.fontFamily : "Inter Variable";
|
return typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DankCommon.Fonts.sans;
|
||||||
}
|
}
|
||||||
|
|
||||||
property string monoFontFamily: {
|
property string monoFontFamily: {
|
||||||
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
|
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") {
|
||||||
return GreetdSettings.monoFontFamily;
|
return resolvedMonoFontFamily(GreetdSettings.monoFontFamily);
|
||||||
}
|
}
|
||||||
return typeof SettingsData !== "undefined" ? SettingsData.monoFontFamily : "Fira Code";
|
return typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DankCommon.Fonts.mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedFontFamily(family) {
|
||||||
|
if (family === defaultFontFamily)
|
||||||
|
return DankCommon.Fonts.sans;
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedMonoFontFamily(family) {
|
||||||
|
if (family === defaultMonoFontFamily)
|
||||||
|
return DankCommon.Fonts.mono;
|
||||||
|
return family;
|
||||||
}
|
}
|
||||||
|
|
||||||
property int fontWeight: {
|
property int fontWeight: {
|
||||||
@@ -1956,7 +1973,7 @@ Singleton {
|
|||||||
function applyGtkColors() {
|
function applyGtkColors() {
|
||||||
if (!matugenAvailable) {
|
if (!matugenAvailable) {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply GTK colors"));
|
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("GTK"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1969,7 +1986,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("Failed to apply GTK colors"));
|
ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("GTK"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1978,7 +1995,7 @@ Singleton {
|
|||||||
function applyQtColors() {
|
function applyQtColors() {
|
||||||
if (!matugenAvailable) {
|
if (!matugenAvailable) {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply Qt colors"));
|
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("Qt"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1990,7 +2007,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("Failed to apply Qt colors"));
|
ToastService.showError(I18n.tr("Failed to apply %1 colors").arg("Qt"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ Singleton {
|
|||||||
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
|
readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : ""
|
||||||
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
|
readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== ""
|
||||||
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
|
readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f")
|
||||||
|
readonly property bool lockU2fCustomSourceDetected: (settingsRoot?.lockU2fPamPath || "") !== "" && customU2fPamWatcher.loaded
|
||||||
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
|
readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd")
|
||||||
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f")
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ Singleton {
|
|||||||
readonly property bool lockU2fReady: {
|
readonly property bool lockU2fReady: {
|
||||||
if (forcedU2fAvailable !== null)
|
if (forcedU2fAvailable !== null)
|
||||||
return forcedU2fAvailable;
|
return forcedU2fAvailable;
|
||||||
return lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property bool lockU2fCanEnable: {
|
readonly property bool lockU2fCanEnable: {
|
||||||
@@ -246,6 +247,10 @@ Singleton {
|
|||||||
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
readonly property var _pamProbeCommand: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"]
|
||||||
|
|
||||||
function detectAuthCapabilities() {
|
function detectAuthCapabilities() {
|
||||||
|
// FileView cannot watch paths that do not exist yet, so reload the U2F PAM
|
||||||
|
dankshellU2fPamWatcher.reload();
|
||||||
|
u2fKeysWatcher.reload();
|
||||||
|
|
||||||
if (forcedFprintAvailable === null) {
|
if (forcedFprintAvailable === null) {
|
||||||
fingerprintProbeFinalized = false;
|
fingerprintProbeFinalized = false;
|
||||||
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
Proc.runCommand("fprint-probe", _fprintProbeCommand, (output, exitCode) => {
|
||||||
@@ -599,7 +604,7 @@ Singleton {
|
|||||||
let details = out;
|
let details = out;
|
||||||
if (err !== "")
|
if (err !== "")
|
||||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||||
ToastService.showInfo(I18n.tr("Authentication changes applied."), details, "", "auth-sync");
|
ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
|
||||||
root.detectAuthCapabilities();
|
root.detectAuthCapabilities();
|
||||||
root.finishAuthApply();
|
root.finishAuthApply();
|
||||||
return;
|
return;
|
||||||
@@ -645,7 +650,7 @@ Singleton {
|
|||||||
|
|
||||||
onExited: exitCode => {
|
onExited: exitCode => {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done.");
|
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
|
||||||
ToastService.showInfo(message, "", "", "auth-sync");
|
ToastService.showInfo(message, "", "", "auth-sync");
|
||||||
} else {
|
} else {
|
||||||
let details = (root.authApplyTerminalFallbackStderr || "").trim();
|
let details = (root.authApplyTerminalFallbackStderr || "").trim();
|
||||||
@@ -722,14 +727,22 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: dankshellU2fPamWatcher
|
id: dankshellU2fPamWatcher
|
||||||
path: "/etc/pam.d/dankshell-u2f"
|
path: "/etc/pam.d/dankshell-u2f"
|
||||||
|
watchChanges: true
|
||||||
printErrors: false
|
printErrors: false
|
||||||
onLoaded: root.dankshellU2fPamText = text()
|
onLoaded: root.dankshellU2fPamText = text()
|
||||||
onLoadFailed: root.dankshellU2fPamText = ""
|
onLoadFailed: root.dankshellU2fPamText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: customU2fPamWatcher
|
||||||
|
path: root.settingsRoot?.lockU2fPamPath || ""
|
||||||
|
printErrors: false
|
||||||
|
}
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: u2fKeysWatcher
|
id: u2fKeysWatcher
|
||||||
path: root.u2fKeysPath
|
path: root.u2fKeysPath
|
||||||
|
watchChanges: true
|
||||||
printErrors: false
|
printErrors: false
|
||||||
onLoaded: root.u2fKeysText = text()
|
onLoaded: root.u2fKeysText = text()
|
||||||
onLoadFailed: root.u2fKeysText = ""
|
onLoadFailed: root.u2fKeysText = ""
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ var SPEC = {
|
|||||||
blurEnabled: { def: false },
|
blurEnabled: { def: false },
|
||||||
blurForegroundLayers: { def: true },
|
blurForegroundLayers: { def: true },
|
||||||
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||||
|
blurBorderEnabled: { def: true },
|
||||||
blurBorderColor: { def: "outline" },
|
blurBorderColor: { def: "outline" },
|
||||||
blurBorderCustomColor: { def: "#ffffff" },
|
blurBorderCustomColor: { def: "#ffffff" },
|
||||||
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
blurBorderOpacity: { def: 0.35, coerce: percentToUnit },
|
||||||
@@ -346,6 +347,7 @@ var SPEC = {
|
|||||||
batteryLowNotificationType: { def: 0 },
|
batteryLowNotificationType: { def: 0 },
|
||||||
batteryCriticalNotificationType: { def: 1 },
|
batteryCriticalNotificationType: { def: 1 },
|
||||||
batteryAutoPowerSaver: { def: false },
|
batteryAutoPowerSaver: { def: false },
|
||||||
|
lowerDisplayRefreshRateOnBattery: { def: false },
|
||||||
lockBeforeSuspend: { def: false },
|
lockBeforeSuspend: { def: false },
|
||||||
loginctlLockIntegration: { def: true },
|
loginctlLockIntegration: { def: true },
|
||||||
fadeToLockEnabled: { def: true },
|
fadeToLockEnabled: { def: true },
|
||||||
@@ -437,6 +439,7 @@ var SPEC = {
|
|||||||
notificationOverlayEnabled: { def: false },
|
notificationOverlayEnabled: { def: false },
|
||||||
notificationPopupShadowEnabled: { def: true },
|
notificationPopupShadowEnabled: { def: true },
|
||||||
notificationPopupPrivacyMode: { def: false },
|
notificationPopupPrivacyMode: { def: false },
|
||||||
|
notificationForegroundLayers: { def: true },
|
||||||
overviewRows: { def: 2, persist: false },
|
overviewRows: { def: 2, persist: false },
|
||||||
overviewColumns: { def: 5, persist: false },
|
overviewColumns: { def: 5, persist: false },
|
||||||
overviewScale: { def: 0.16, persist: false },
|
overviewScale: { def: 0.16, persist: false },
|
||||||
@@ -459,6 +462,8 @@ var SPEC = {
|
|||||||
lockPamPath: { def: "" },
|
lockPamPath: { def: "" },
|
||||||
lockPamInlineFprint: { def: false },
|
lockPamInlineFprint: { def: false },
|
||||||
lockPamInlineU2f: { def: false },
|
lockPamInlineU2f: { def: false },
|
||||||
|
lockPamExternallyManaged: { def: false },
|
||||||
|
lockU2fPamPath: { def: "" },
|
||||||
lockScreenInactiveColor: { def: "#000000" },
|
lockScreenInactiveColor: { def: "#000000" },
|
||||||
lockScreenNotificationMode: { def: 0 },
|
lockScreenNotificationMode: { def: 0 },
|
||||||
lockScreenVideoEnabled: { def: false },
|
lockScreenVideoEnabled: { def: false },
|
||||||
@@ -528,6 +533,8 @@ var SPEC = {
|
|||||||
hyprlandOutputSettings: { def: {} },
|
hyprlandOutputSettings: { def: {} },
|
||||||
displayProfiles: { def: {} },
|
displayProfiles: { def: {} },
|
||||||
activeDisplayProfile: { def: {} },
|
activeDisplayProfile: { def: {} },
|
||||||
|
activeDisplayProfileModes: { def: {} },
|
||||||
|
displayPreviousRefreshModes: { def: {} },
|
||||||
displayProfileAutoSelect: { def: false },
|
displayProfileAutoSelect: { def: false },
|
||||||
displayShowDisconnected: { def: false },
|
displayShowDisconnected: { def: false },
|
||||||
displaySnapToEdge: { def: true },
|
displaySnapToEdge: { def: true },
|
||||||
|
|||||||
+54
-67
@@ -1,3 +1,5 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Common
|
import qs.Common
|
||||||
@@ -53,6 +55,7 @@ Item {
|
|||||||
delegate: Loader {
|
delegate: Loader {
|
||||||
id: fadeWindowLoader
|
id: fadeWindowLoader
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
readonly property FadeToLockWindow loadedWindow: item as FadeToLockWindow
|
||||||
active: SettingsData.fadeToLockEnabled
|
active: SettingsData.fadeToLockEnabled
|
||||||
asynchronous: false
|
asynchronous: false
|
||||||
|
|
||||||
@@ -64,29 +67,29 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
root.log.debug("Fade to lock cancelled by user on screen:", fadeWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: IdleService
|
target: IdleService
|
||||||
enabled: fadeWindowLoader.item !== null
|
enabled: fadeWindowLoader.loadedWindow !== null
|
||||||
|
|
||||||
function onFadeToLockRequested() {
|
function onFadeToLockRequested() {
|
||||||
if (fadeWindowLoader.item) {
|
if (fadeWindowLoader.loadedWindow) {
|
||||||
fadeWindowLoader.item.startFade();
|
fadeWindowLoader.loadedWindow.startFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancelFadeToLock() {
|
function onCancelFadeToLock() {
|
||||||
if (fadeWindowLoader.item) {
|
if (fadeWindowLoader.loadedWindow) {
|
||||||
fadeWindowLoader.item.cancelFade();
|
fadeWindowLoader.loadedWindow.cancelFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDismissFadeToLock() {
|
function onDismissFadeToLock() {
|
||||||
if (fadeWindowLoader.item) {
|
if (fadeWindowLoader.loadedWindow) {
|
||||||
fadeWindowLoader.item.dismiss();
|
fadeWindowLoader.loadedWindow.dismiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,7 @@ Item {
|
|||||||
delegate: Loader {
|
delegate: Loader {
|
||||||
id: fadeDpmsWindowLoader
|
id: fadeDpmsWindowLoader
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
readonly property FadeToDpmsWindow loadedWindow: item as FadeToDpmsWindow
|
||||||
active: SettingsData.fadeToDpmsEnabled
|
active: SettingsData.fadeToDpmsEnabled
|
||||||
asynchronous: false
|
asynchronous: false
|
||||||
|
|
||||||
@@ -110,30 +114,30 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFadeCancelled: {
|
onFadeCancelled: {
|
||||||
log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
root.log.debug("Fade to DPMS cancelled by user on screen:", fadeDpmsWindowLoader.modelData.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: IdleService
|
target: IdleService
|
||||||
enabled: fadeDpmsWindowLoader.item !== null
|
enabled: fadeDpmsWindowLoader.loadedWindow !== null
|
||||||
|
|
||||||
function onFadeToDpmsRequested() {
|
function onFadeToDpmsRequested() {
|
||||||
if (fadeDpmsWindowLoader.item) {
|
if (fadeDpmsWindowLoader.loadedWindow) {
|
||||||
fadeDpmsWindowLoader.item.startFade();
|
fadeDpmsWindowLoader.loadedWindow.startFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancelFadeToDpms() {
|
function onCancelFadeToDpms() {
|
||||||
if (fadeDpmsWindowLoader.item) {
|
if (fadeDpmsWindowLoader.loadedWindow) {
|
||||||
fadeDpmsWindowLoader.item.cancelFade();
|
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRequestMonitorOn() {
|
function onRequestMonitorOn() {
|
||||||
if (!fadeDpmsWindowLoader.item)
|
if (!fadeDpmsWindowLoader.loadedWindow)
|
||||||
return;
|
return;
|
||||||
fadeDpmsWindowLoader.item.cancelFade();
|
fadeDpmsWindowLoader.loadedWindow.cancelFade();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +323,7 @@ Item {
|
|||||||
target: TrashService
|
target: TrashService
|
||||||
function onEmptyTrashConfirmRequested(itemCount) {
|
function onEmptyTrashConfirmRequested(itemCount) {
|
||||||
emptyTrashConfirm.showWithOptions({
|
emptyTrashConfirm.showWithOptions({
|
||||||
title: I18n.tr("Empty Trash?"),
|
title: I18n.tr("Empty Trash"),
|
||||||
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
|
message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount),
|
||||||
confirmText: I18n.tr("Empty"),
|
confirmText: I18n.tr("Empty"),
|
||||||
cancelText: I18n.tr("Cancel"),
|
cancelText: I18n.tr("Cancel"),
|
||||||
@@ -351,9 +355,7 @@ Item {
|
|||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
|
model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications")
|
||||||
|
|
||||||
delegate: NotificationPopupManager {
|
delegate: NotificationPopupManager {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
@@ -387,6 +389,7 @@ Item {
|
|||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: wifiPasswordModalLoader
|
id: wifiPasswordModalLoader
|
||||||
active: false
|
active: false
|
||||||
|
readonly property WifiPasswordModal loadedModal: item as WifiPasswordModal
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
|
PopoutService.wifiPasswordModalLoader = wifiPasswordModalLoader;
|
||||||
@@ -421,6 +424,7 @@ Item {
|
|||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: polkitAuthModalLoader
|
id: polkitAuthModalLoader
|
||||||
active: false
|
active: false
|
||||||
|
readonly property PolkitAuthModal loadedModal: item as PolkitAuthModal
|
||||||
|
|
||||||
PolkitAuthModal {
|
PolkitAuthModal {
|
||||||
id: polkitAuthModal
|
id: polkitAuthModal
|
||||||
@@ -439,8 +443,8 @@ Item {
|
|||||||
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
if (PopoutService.systemUpdatePopout?.shouldBeVisible)
|
||||||
return;
|
return;
|
||||||
polkitAuthModalLoader.active = true;
|
polkitAuthModalLoader.active = true;
|
||||||
if (polkitAuthModalLoader.item)
|
if (polkitAuthModalLoader.loadedModal)
|
||||||
polkitAuthModalLoader.item.show();
|
polkitAuthModalLoader.loadedModal.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,20 +463,20 @@ Item {
|
|||||||
target: NetworkService
|
target: NetworkService
|
||||||
|
|
||||||
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
|
function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) {
|
||||||
const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible;
|
const alreadyShown = wifiPasswordModalLoader.loadedModal && wifiPasswordModalLoader.loadedModal.shouldBeVisible;
|
||||||
if (alreadyShown && token === lastCredentialsToken)
|
if (alreadyShown && token === root.lastCredentialsToken)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
wifiPasswordModalLoader.active = true;
|
wifiPasswordModalLoader.active = true;
|
||||||
if (!wifiPasswordModalLoader.item)
|
if (!wifiPasswordModalLoader.loadedModal)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token)
|
if (alreadyShown && root.lastCredentialsToken !== "" && root.lastCredentialsToken !== token)
|
||||||
NetworkService.cancelCredentials(lastCredentialsToken);
|
NetworkService.cancelCredentials(root.lastCredentialsToken);
|
||||||
|
|
||||||
lastCredentialsToken = token;
|
root.lastCredentialsToken = token;
|
||||||
lastCredentialsTime = Date.now();
|
root.lastCredentialsTime = Date.now();
|
||||||
wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
wifiPasswordModalLoader.loadedModal.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,10 +773,10 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onAppPickerRequested(data) {
|
function onAppPickerRequested(data) {
|
||||||
log.debug("App picker requested with data:", JSON.stringify(data));
|
root.log.debug("App picker requested with data:", JSON.stringify(data));
|
||||||
|
|
||||||
if (!data || !data.target) {
|
if (!data || !data.target) {
|
||||||
log.warn("Invalid app picker request data");
|
root.log.warn("Invalid app picker request data");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,7 +893,6 @@ Item {
|
|||||||
|
|
||||||
delegate: DankSlideout {
|
delegate: DankSlideout {
|
||||||
id: notepadSlideout
|
id: notepadSlideout
|
||||||
modelData: item
|
|
||||||
title: I18n.tr("Notepad")
|
title: I18n.tr("Notepad")
|
||||||
slideoutWidth: 480
|
slideoutWidth: 480
|
||||||
expandable: true
|
expandable: true
|
||||||
@@ -985,8 +988,8 @@ Item {
|
|||||||
onSwitchUserRequested: {
|
onSwitchUserRequested: {
|
||||||
switchUserModalLoader.active = true;
|
switchUserModalLoader.active = true;
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (switchUserModalLoader.item)
|
if (switchUserModalLoader.loadedModal)
|
||||||
switchUserModalLoader.item.showFromPowerMenu();
|
switchUserModalLoader.loadedModal.showFromPowerMenu();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,6 +1003,7 @@ Item {
|
|||||||
id: switchUserModalLoader
|
id: switchUserModalLoader
|
||||||
|
|
||||||
active: false
|
active: false
|
||||||
|
readonly property SwitchUserModal loadedModal: item as SwitchUserModal
|
||||||
|
|
||||||
SwitchUserModal {
|
SwitchUserModal {
|
||||||
id: switchUserModal
|
id: switchUserModal
|
||||||
@@ -1055,7 +1059,6 @@ Item {
|
|||||||
model: SettingsData.getFilteredScreens("toast")
|
model: SettingsData.getFilteredScreens("toast")
|
||||||
|
|
||||||
delegate: Toast {
|
delegate: Toast {
|
||||||
modelData: item
|
|
||||||
visible: ToastService.toastVisible
|
visible: ToastService.toastVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,73 +1073,55 @@ Item {
|
|||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: VolumeOSD {
|
delegate: VolumeOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MediaVolumeOSD {
|
delegate: MediaVolumeOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MediaPlaybackOSD {
|
delegate: MediaPlaybackOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: MicVolumeOSD {
|
delegate: MicVolumeOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: BrightnessOSD {
|
delegate: BrightnessOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: IdleInhibitorOSD {
|
delegate: IdleInhibitorOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : []
|
||||||
|
|
||||||
delegate: PowerProfileOSD {
|
delegate: PowerProfileOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: CapsLockOSD {
|
delegate: CapsLockOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: SettingsData.getFilteredScreens("osd")
|
model: SettingsData.getFilteredScreens("osd")
|
||||||
|
|
||||||
delegate: AudioOutputOSD {
|
delegate: AudioOutputOSD {}
|
||||||
modelData: item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1153,6 +1138,7 @@ Item {
|
|||||||
Loader {
|
Loader {
|
||||||
id: greeterLoader
|
id: greeterLoader
|
||||||
active: false
|
active: false
|
||||||
|
readonly property GreeterModal loadedModal: item as GreeterModal
|
||||||
sourceComponent: GreeterModal {
|
sourceComponent: GreeterModal {
|
||||||
onGreeterCompleted: greeterLoader.active = false
|
onGreeterCompleted: greeterLoader.active = false
|
||||||
Component.onCompleted: show()
|
Component.onCompleted: show()
|
||||||
@@ -1161,8 +1147,8 @@ Item {
|
|||||||
Connections {
|
Connections {
|
||||||
target: FirstLaunchService
|
target: FirstLaunchService
|
||||||
function onGreeterRequested() {
|
function onGreeterRequested() {
|
||||||
if (greeterLoader.active && greeterLoader.item) {
|
if (greeterLoader.active && greeterLoader.loadedModal) {
|
||||||
greeterLoader.item.show();
|
greeterLoader.loadedModal.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
greeterLoader.active = true;
|
greeterLoader.active = true;
|
||||||
@@ -1173,6 +1159,7 @@ Item {
|
|||||||
Loader {
|
Loader {
|
||||||
id: changelogLoader
|
id: changelogLoader
|
||||||
active: false
|
active: false
|
||||||
|
readonly property ChangelogModal loadedModal: item as ChangelogModal
|
||||||
sourceComponent: ChangelogModal {
|
sourceComponent: ChangelogModal {
|
||||||
onChangelogDismissed: changelogLoader.active = false
|
onChangelogDismissed: changelogLoader.active = false
|
||||||
Component.onCompleted: show()
|
Component.onCompleted: show()
|
||||||
@@ -1181,8 +1168,8 @@ Item {
|
|||||||
Connections {
|
Connections {
|
||||||
target: ChangelogService
|
target: ChangelogService
|
||||||
function onChangelogRequested() {
|
function onChangelogRequested() {
|
||||||
if (changelogLoader.active && changelogLoader.item) {
|
if (changelogLoader.active && changelogLoader.loadedModal) {
|
||||||
changelogLoader.item.show();
|
changelogLoader.loadedModal.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changelogLoader.active = true;
|
changelogLoader.active = true;
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../dank-qml-common/DankCommon
|
||||||
@@ -408,14 +408,14 @@ FocusScope {
|
|||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "↵ " + I18n.tr("open")
|
text: "↵ " + I18n.tr("Open")
|
||||||
font.pixelSize: Theme.fontSizeSmall - 1
|
font.pixelSize: Theme.fontSizeSmall - 1
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Tab " + I18n.tr("actions")
|
text: "Tab " + I18n.tr("Actions")
|
||||||
font.pixelSize: Theme.fontSizeSmall - 1
|
font.pixelSize: Theme.fontSizeSmall - 1
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
visible: actionPanel.hasActions
|
visible: actionPanel.hasActions
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ Item {
|
|||||||
Item {
|
Item {
|
||||||
id: listClip
|
id: listClip
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.topMargin: BlurService.enabled && stickyHeader.visible ? 32 : 0
|
anchors.topMargin: stickyHeader.visible ? 32 : 0
|
||||||
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
|
anchors.bottomMargin: bottomSectionHeader.visible ? bottomSectionHeader.height : 0
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ Item {
|
|||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
height: 32
|
height: 32
|
||||||
z: 101
|
z: 101
|
||||||
color: Theme.floatingSurface
|
color: "transparent"
|
||||||
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
|
visible: !root._bottomSectionHeaderActive && stickyHeaderSection !== null
|
||||||
|
|
||||||
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
readonly property int versionTrigger: root.controller?.viewModeVersion ?? 0
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ Item {
|
|||||||
|
|
||||||
function statusTitle() {
|
function statusTitle() {
|
||||||
if (root.controller?.isSearching || root.controller?.isFileSearching)
|
if (root.controller?.isSearching || root.controller?.isFileSearching)
|
||||||
return I18n.tr("Searching");
|
return I18n.tr("Searching...");
|
||||||
if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable)
|
if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable)
|
||||||
return I18n.tr("File search unavailable");
|
return I18n.tr("File search unavailable");
|
||||||
if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2)
|
if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2)
|
||||||
|
|||||||
@@ -1,935 +1,3 @@
|
|||||||
import Qt.labs.folderlistmodel
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import QtCore
|
|
||||||
import QtQuick
|
|
||||||
import QtQuick.Controls
|
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
FocusScope {
|
DankCommon.FileBrowserContent {}
|
||||||
id: root
|
|
||||||
|
|
||||||
LayoutMirroring.enabled: I18n.isRtl
|
|
||||||
LayoutMirroring.childrenInherit: true
|
|
||||||
|
|
||||||
property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
|
|
||||||
property string docsDir: StandardPaths.writableLocation(StandardPaths.DocumentsLocation)
|
|
||||||
property string musicDir: StandardPaths.writableLocation(StandardPaths.MusicLocation)
|
|
||||||
property string videosDir: StandardPaths.writableLocation(StandardPaths.MoviesLocation)
|
|
||||||
property string picsDir: StandardPaths.writableLocation(StandardPaths.PicturesLocation)
|
|
||||||
property string downloadDir: StandardPaths.writableLocation(StandardPaths.DownloadLocation)
|
|
||||||
property string desktopDir: StandardPaths.writableLocation(StandardPaths.DesktopLocation)
|
|
||||||
property string currentPath: ""
|
|
||||||
property var fileExtensions: ["*.*"]
|
|
||||||
property alias filterExtensions: root.fileExtensions
|
|
||||||
property string browserTitle: "Select File"
|
|
||||||
property string browserIcon: "folder_open"
|
|
||||||
property string browserType: "generic"
|
|
||||||
property bool showHiddenFiles: false
|
|
||||||
property int selectedIndex: -1
|
|
||||||
property bool keyboardNavigationActive: false
|
|
||||||
property bool backButtonFocused: false
|
|
||||||
property bool saveMode: false
|
|
||||||
property string defaultFileName: ""
|
|
||||||
property int keyboardSelectionIndex: -1
|
|
||||||
property bool keyboardSelectionRequested: false
|
|
||||||
property bool showKeyboardHints: false
|
|
||||||
property bool showFileInfo: false
|
|
||||||
property string selectedFilePath: ""
|
|
||||||
property string selectedFileName: ""
|
|
||||||
property bool selectedFileIsDir: false
|
|
||||||
property bool showOverwriteConfirmation: false
|
|
||||||
property string pendingFilePath: ""
|
|
||||||
property bool showSidebar: true
|
|
||||||
property string viewMode: "grid"
|
|
||||||
property string sortBy: "name"
|
|
||||||
property bool sortAscending: true
|
|
||||||
property int iconSizeIndex: 1
|
|
||||||
property var iconSizes: [80, 120, 160, 200]
|
|
||||||
property bool pathEditMode: false
|
|
||||||
property bool pathInputHasFocus: false
|
|
||||||
property int actualGridColumns: 5
|
|
||||||
property bool _initialized: false
|
|
||||||
property bool closeOnEscape: true
|
|
||||||
property var windowControls: null
|
|
||||||
|
|
||||||
signal fileSelected(string path)
|
|
||||||
signal closeRequested
|
|
||||||
|
|
||||||
function encodeFileUrl(path) {
|
|
||||||
if (!path)
|
|
||||||
return "";
|
|
||||||
return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialize() {
|
|
||||||
loadSettings();
|
|
||||||
currentPath = getLastPath();
|
|
||||||
_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
currentPath = getLastPath();
|
|
||||||
selectedIndex = -1;
|
|
||||||
keyboardNavigationActive = false;
|
|
||||||
backButtonFocused = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSettings() {
|
|
||||||
const type = browserType || "default";
|
|
||||||
const settings = CacheData.fileBrowserSettings[type];
|
|
||||||
const isImageBrowser = ["wallpaper", "profile"].includes(browserType);
|
|
||||||
|
|
||||||
if (settings) {
|
|
||||||
viewMode = settings.viewMode || (isImageBrowser ? "grid" : "list");
|
|
||||||
sortBy = settings.sortBy || "name";
|
|
||||||
sortAscending = settings.sortAscending !== undefined ? settings.sortAscending : true;
|
|
||||||
iconSizeIndex = settings.iconSizeIndex !== undefined ? settings.iconSizeIndex : 1;
|
|
||||||
showSidebar = settings.showSidebar !== undefined ? settings.showSidebar : true;
|
|
||||||
} else {
|
|
||||||
viewMode = isImageBrowser ? "grid" : "list";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings() {
|
|
||||||
if (!_initialized)
|
|
||||||
return;
|
|
||||||
const type = browserType || "default";
|
|
||||||
let settings = CacheData.fileBrowserSettings;
|
|
||||||
if (!settings[type]) {
|
|
||||||
settings[type] = {};
|
|
||||||
}
|
|
||||||
settings[type].viewMode = viewMode;
|
|
||||||
settings[type].sortBy = sortBy;
|
|
||||||
settings[type].sortAscending = sortAscending;
|
|
||||||
settings[type].iconSizeIndex = iconSizeIndex;
|
|
||||||
settings[type].showSidebar = showSidebar;
|
|
||||||
settings[type].lastPath = currentPath;
|
|
||||||
CacheData.fileBrowserSettings = settings;
|
|
||||||
|
|
||||||
if (browserType === "wallpaper") {
|
|
||||||
CacheData.wallpaperLastPath = currentPath;
|
|
||||||
} else if (browserType === "profile") {
|
|
||||||
CacheData.profileLastPath = currentPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
CacheData.saveCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
onViewModeChanged: saveSettings()
|
|
||||||
onSortByChanged: saveSettings()
|
|
||||||
onSortAscendingChanged: saveSettings()
|
|
||||||
onIconSizeIndexChanged: saveSettings()
|
|
||||||
onShowSidebarChanged: saveSettings()
|
|
||||||
|
|
||||||
function isImageFile(fileName) {
|
|
||||||
if (!fileName)
|
|
||||||
return false;
|
|
||||||
const ext = fileName.toLowerCase().split('.').pop();
|
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastPath() {
|
|
||||||
const type = browserType || "default";
|
|
||||||
const settings = CacheData.fileBrowserSettings[type];
|
|
||||||
const lastPath = settings?.lastPath || "";
|
|
||||||
return (lastPath && lastPath !== "") ? lastPath : homeDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveLastPath(path) {
|
|
||||||
const type = browserType || "default";
|
|
||||||
let settings = CacheData.fileBrowserSettings;
|
|
||||||
if (!settings[type]) {
|
|
||||||
settings[type] = {};
|
|
||||||
}
|
|
||||||
settings[type].lastPath = path;
|
|
||||||
CacheData.fileBrowserSettings = settings;
|
|
||||||
CacheData.saveCache();
|
|
||||||
|
|
||||||
if (browserType === "wallpaper") {
|
|
||||||
CacheData.wallpaperLastPath = path;
|
|
||||||
} else if (browserType === "profile") {
|
|
||||||
CacheData.profileLastPath = path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectedFileData(path, name, isDir) {
|
|
||||||
selectedFilePath = path;
|
|
||||||
selectedFileName = name;
|
|
||||||
selectedFileIsDir = isDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openItemContextMenu(sender, localX, localY, path, name, isDir) {
|
|
||||||
if (!sender)
|
|
||||||
return;
|
|
||||||
const pos = sender.mapToItem(root, localX, localY);
|
|
||||||
itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateUp() {
|
|
||||||
const path = currentPath;
|
|
||||||
if (path === homeDir)
|
|
||||||
return;
|
|
||||||
const lastSlash = path.lastIndexOf('/');
|
|
||||||
if (lastSlash <= 0)
|
|
||||||
return;
|
|
||||||
const newPath = path.substring(0, lastSlash);
|
|
||||||
if (newPath.length < homeDir.length) {
|
|
||||||
currentPath = homeDir;
|
|
||||||
saveLastPath(homeDir);
|
|
||||||
} else {
|
|
||||||
currentPath = newPath;
|
|
||||||
saveLastPath(newPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path) {
|
|
||||||
currentPath = path;
|
|
||||||
saveLastPath(path);
|
|
||||||
selectedIndex = -1;
|
|
||||||
backButtonFocused = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyboardFileSelection(index) {
|
|
||||||
if (index < 0)
|
|
||||||
return;
|
|
||||||
keyboardSelectionTimer.targetIndex = index;
|
|
||||||
keyboardSelectionTimer.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeKeyboardSelection(index) {
|
|
||||||
keyboardSelectionIndex = index;
|
|
||||||
keyboardSelectionRequested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateFile(path, name, isDir) {
|
|
||||||
if (isDir) {
|
|
||||||
navigateTo(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (saveMode) {
|
|
||||||
saveRow.fileName = name;
|
|
||||||
pendingFilePath = path;
|
|
||||||
showOverwriteConfirmation = true;
|
|
||||||
} else {
|
|
||||||
fileSelected(path);
|
|
||||||
closeRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveFile(filePath) {
|
|
||||||
var normalizedPath = filePath;
|
|
||||||
if (!normalizedPath.startsWith("file://")) {
|
|
||||||
normalizedPath = encodeFileUrl(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
var exists = false;
|
|
||||||
var fileName = filePath.split('/').pop();
|
|
||||||
|
|
||||||
for (var i = 0; i < folderModel.count; i++) {
|
|
||||||
if (folderModel.get(i, "fileName") === fileName && !folderModel.get(i, "fileIsDir")) {
|
|
||||||
exists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
pendingFilePath = normalizedPath;
|
|
||||||
showOverwriteConfirmation = true;
|
|
||||||
} else {
|
|
||||||
fileSelected(normalizedPath);
|
|
||||||
closeRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCurrentPathChanged: {
|
|
||||||
selectedFilePath = "";
|
|
||||||
selectedFileName = "";
|
|
||||||
selectedFileIsDir = false;
|
|
||||||
saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectedIndexChanged: {
|
|
||||||
if (selectedIndex >= 0 && folderModel && selectedIndex < folderModel.count) {
|
|
||||||
selectedFilePath = "";
|
|
||||||
selectedFileName = "";
|
|
||||||
selectedFileIsDir = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
property var steamPaths: [StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.steam/steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.local/share/Steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/workshop/content/431960", StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/snap/steam/common/.local/share/Steam/steamapps/workshop/content/431960"]
|
|
||||||
|
|
||||||
property var quickAccessLocations: [
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Home"),
|
|
||||||
"path": homeDir,
|
|
||||||
"icon": "home"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Documents"),
|
|
||||||
"path": docsDir,
|
|
||||||
"icon": "description"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Downloads"),
|
|
||||||
"path": downloadDir,
|
|
||||||
"icon": "download"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Pictures"),
|
|
||||||
"path": picsDir,
|
|
||||||
"icon": "image"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Music"),
|
|
||||||
"path": musicDir,
|
|
||||||
"icon": "music_note"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Videos"),
|
|
||||||
"path": videosDir,
|
|
||||||
"icon": "movie"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": I18n.tr("Desktop"),
|
|
||||||
"path": desktopDir,
|
|
||||||
"icon": "computer"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
FolderListModel {
|
|
||||||
id: folderModel
|
|
||||||
|
|
||||||
showDirsFirst: true
|
|
||||||
showDotAndDotDot: false
|
|
||||||
showHidden: root.showHiddenFiles
|
|
||||||
caseSensitive: false
|
|
||||||
nameFilters: fileExtensions
|
|
||||||
showFiles: true
|
|
||||||
showDirs: true
|
|
||||||
folder: encodeFileUrl(currentPath || homeDir)
|
|
||||||
sortField: {
|
|
||||||
switch (sortBy) {
|
|
||||||
case "name":
|
|
||||||
return FolderListModel.Name;
|
|
||||||
case "size":
|
|
||||||
return FolderListModel.Size;
|
|
||||||
case "modified":
|
|
||||||
return FolderListModel.Time;
|
|
||||||
case "type":
|
|
||||||
return FolderListModel.Type;
|
|
||||||
default:
|
|
||||||
return FolderListModel.Name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortReversed: !sortAscending
|
|
||||||
}
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: keyboardController
|
|
||||||
|
|
||||||
property int totalItems: folderModel.count
|
|
||||||
property int gridColumns: viewMode === "list" ? 1 : Math.max(1, actualGridColumns)
|
|
||||||
|
|
||||||
function handleKey(event) {
|
|
||||||
if (event.key === Qt.Key_Escape && root.closeOnEscape) {
|
|
||||||
closeRequested();
|
|
||||||
event.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === Qt.Key_F10) {
|
|
||||||
showKeyboardHints = !showKeyboardHints;
|
|
||||||
event.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === Qt.Key_F1 || event.key === Qt.Key_I) {
|
|
||||||
showFileInfo = !showFileInfo;
|
|
||||||
event.accepted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((event.modifiers & Qt.AltModifier && event.key === Qt.Key_Left) || event.key === Qt.Key_Backspace) {
|
|
||||||
if (currentPath !== homeDir) {
|
|
||||||
navigateUp();
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!keyboardNavigationActive) {
|
|
||||||
const isInitKey = event.key === Qt.Key_Tab || event.key === Qt.Key_Down || event.key === Qt.Key_Right || (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) || (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) || (event.key === Qt.Key_L && event.modifiers & Qt.ControlModifier);
|
|
||||||
|
|
||||||
if (isInitKey) {
|
|
||||||
keyboardNavigationActive = true;
|
|
||||||
if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
} else {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (event.key) {
|
|
||||||
case Qt.Key_Tab:
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
} else {
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_Backtab:
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = totalItems - 1;
|
|
||||||
} else if (selectedIndex > 0) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
} else {
|
|
||||||
selectedIndex = totalItems - 1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_N:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_P:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (selectedIndex > 0) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_J:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_K:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (selectedIndex > 0) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_H:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (!backButtonFocused && selectedIndex > 0) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_L:
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_Left:
|
|
||||||
if (pathInputHasFocus)
|
|
||||||
return;
|
|
||||||
if (backButtonFocused)
|
|
||||||
return;
|
|
||||||
if (selectedIndex > 0) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_Right:
|
|
||||||
if (pathInputHasFocus)
|
|
||||||
return;
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_Up:
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
if (gridColumns === 1) {
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else {
|
|
||||||
var col = selectedIndex % gridColumns;
|
|
||||||
selectedIndex = Math.min(col, totalItems - 1);
|
|
||||||
}
|
|
||||||
} else if (selectedIndex >= gridColumns) {
|
|
||||||
selectedIndex -= gridColumns;
|
|
||||||
} else if (selectedIndex > 0 && gridColumns === 1) {
|
|
||||||
selectedIndex--;
|
|
||||||
} else if (currentPath !== homeDir) {
|
|
||||||
backButtonFocused = true;
|
|
||||||
selectedIndex = -1;
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_Down:
|
|
||||||
if (backButtonFocused) {
|
|
||||||
backButtonFocused = false;
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else if (gridColumns === 1) {
|
|
||||||
if (selectedIndex < totalItems - 1) {
|
|
||||||
selectedIndex++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var newIndex = selectedIndex + gridColumns;
|
|
||||||
if (newIndex < totalItems) {
|
|
||||||
selectedIndex = newIndex;
|
|
||||||
} else {
|
|
||||||
var lastRowStart = Math.floor((totalItems - 1) / gridColumns) * gridColumns;
|
|
||||||
var col = selectedIndex % gridColumns;
|
|
||||||
var targetIndex = lastRowStart + col;
|
|
||||||
if (targetIndex < totalItems && targetIndex > selectedIndex) {
|
|
||||||
selectedIndex = targetIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
case Qt.Key_Return:
|
|
||||||
case Qt.Key_Enter:
|
|
||||||
case Qt.Key_Space:
|
|
||||||
if (backButtonFocused) {
|
|
||||||
navigateUp();
|
|
||||||
} else if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
|
||||||
root.keyboardFileSelection(selectedIndex);
|
|
||||||
}
|
|
||||||
event.accepted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: keyboardSelectionTimer
|
|
||||||
|
|
||||||
property int targetIndex: -1
|
|
||||||
|
|
||||||
interval: 1
|
|
||||||
onTriggered: {
|
|
||||||
executeKeyboardSelection(targetIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focus: true
|
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
|
||||||
keyboardController.handleKey(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.fill: parent
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: parent.width
|
|
||||||
height: 48
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
onPressed: if (windowControls)
|
|
||||||
windowControls.tryStartMove()
|
|
||||||
onDoubleClicked: if (windowControls)
|
|
||||||
windowControls.tryToggleMaximize()
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingL
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: browserIcon
|
|
||||||
size: Theme.iconSizeLarge
|
|
||||||
color: Theme.primary
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: browserTitle
|
|
||||||
font.pixelSize: Theme.fontSizeXLarge
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingM
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: showHiddenFiles ? "visibility_off" : "visibility"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: showHiddenFiles ? Theme.primary : Theme.surfaceText
|
|
||||||
onClicked: showHiddenFiles = !showHiddenFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: viewMode === "grid" ? "view_list" : "grid_view"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
onClicked: viewMode = viewMode === "grid" ? "list" : "grid"
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: iconSizeIndex === 0 ? "photo_size_select_small" : iconSizeIndex === 1 ? "photo_size_select_large" : iconSizeIndex === 2 ? "photo_size_select_actual" : "zoom_in"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
visible: viewMode === "grid"
|
|
||||||
onClicked: iconSizeIndex = (iconSizeIndex + 1) % iconSizes.length
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: "info"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
onClicked: root.showKeyboardHints = !root.showKeyboardHints
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
visible: windowControls?.supported ?? false
|
|
||||||
circular: false
|
|
||||||
iconName: windowControls?.targetWindow?.maximized ? "fullscreen_exit" : "fullscreen"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
onClicked: if (windowControls)
|
|
||||||
windowControls.tryToggleMaximize()
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: "close"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
onClicked: root.closeRequested()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Theme.outline
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: parent.width
|
|
||||||
height: parent.height - 49
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.bottomMargin: root.saveMode ? 40 + Theme.spacingL * 2 : 0
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
Row {
|
|
||||||
width: showSidebar ? 201 : 0
|
|
||||||
height: parent.height
|
|
||||||
spacing: 0
|
|
||||||
visible: showSidebar
|
|
||||||
|
|
||||||
FileBrowserSidebar {
|
|
||||||
height: parent.height
|
|
||||||
quickAccessLocations: root.quickAccessLocations
|
|
||||||
currentPath: root.currentPath
|
|
||||||
onLocationSelected: path => navigateTo(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: 1
|
|
||||||
height: parent.height
|
|
||||||
color: Theme.outline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
width: parent.width - (showSidebar ? 201 : 0)
|
|
||||||
height: parent.height
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
FileBrowserNavigation {
|
|
||||||
width: parent.width
|
|
||||||
currentPath: root.currentPath
|
|
||||||
homeDir: root.homeDir
|
|
||||||
backButtonFocused: root.backButtonFocused
|
|
||||||
keyboardNavigationActive: root.keyboardNavigationActive
|
|
||||||
showSidebar: root.showSidebar
|
|
||||||
pathEditMode: root.pathEditMode
|
|
||||||
onNavigateUp: root.navigateUp()
|
|
||||||
onNavigateTo: path => root.navigateTo(path)
|
|
||||||
onPathInputFocusChanged: hasFocus => {
|
|
||||||
root.pathInputHasFocus = hasFocus;
|
|
||||||
if (hasFocus) {
|
|
||||||
root.pathEditMode = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Theme.outline
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: gridContainer
|
|
||||||
width: parent.width
|
|
||||||
height: parent.height - 41
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
property real gridCellWidth: iconSizes[iconSizeIndex] + 24
|
|
||||||
property real gridCellHeight: iconSizes[iconSizeIndex] + 56
|
|
||||||
property real availableGridWidth: width - Theme.spacingM * 2
|
|
||||||
property int gridColumns: Math.max(1, Math.floor(availableGridWidth / gridCellWidth))
|
|
||||||
property real gridLeftMargin: Theme.spacingM + Math.max(0, (availableGridWidth - (gridColumns * gridCellWidth)) / 2)
|
|
||||||
|
|
||||||
onGridColumnsChanged: {
|
|
||||||
root.actualGridColumns = gridColumns;
|
|
||||||
}
|
|
||||||
Component.onCompleted: {
|
|
||||||
root.actualGridColumns = gridColumns;
|
|
||||||
}
|
|
||||||
|
|
||||||
DankGridView {
|
|
||||||
id: fileGrid
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: gridContainer.gridLeftMargin
|
|
||||||
anchors.rightMargin: Theme.spacingM
|
|
||||||
anchors.topMargin: Theme.spacingS
|
|
||||||
anchors.bottomMargin: Theme.spacingS
|
|
||||||
visible: viewMode === "grid"
|
|
||||||
cellWidth: gridContainer.gridCellWidth
|
|
||||||
cellHeight: gridContainer.gridCellHeight
|
|
||||||
cacheBuffer: 260
|
|
||||||
model: folderModel
|
|
||||||
currentIndex: selectedIndex
|
|
||||||
onCurrentIndexChanged: {
|
|
||||||
if (keyboardNavigationActive && currentIndex >= 0)
|
|
||||||
positionViewAtIndex(currentIndex, GridView.Contain);
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.vertical: DankScrollbar {
|
|
||||||
id: gridScrollbar
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.horizontal: DankScrollbar {
|
|
||||||
policy: ScrollBar.AlwaysOff
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: FileBrowserGridDelegate {
|
|
||||||
iconSizes: root.iconSizes
|
|
||||||
iconSizeIndex: root.iconSizeIndex
|
|
||||||
selectedIndex: root.selectedIndex
|
|
||||||
keyboardNavigationActive: root.keyboardNavigationActive
|
|
||||||
onItemClicked: (index, path, name, isDir) => {
|
|
||||||
selectedIndex = index;
|
|
||||||
setSelectedFileData(path, name, isDir);
|
|
||||||
root.activateFile(path, name, isDir);
|
|
||||||
}
|
|
||||||
onItemSelected: (index, path, name, isDir) => {
|
|
||||||
setSelectedFileData(path, name, isDir);
|
|
||||||
}
|
|
||||||
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
|
|
||||||
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
function onKeyboardSelectionRequestedChanged() {
|
|
||||||
if (root.keyboardSelectionRequested && root.keyboardSelectionIndex === index) {
|
|
||||||
root.keyboardSelectionRequested = false;
|
|
||||||
selectedIndex = index;
|
|
||||||
setSelectedFileData(filePath, fileName, fileIsDir);
|
|
||||||
root.activateFile(filePath, fileName, fileIsDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
target: root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankListView {
|
|
||||||
id: fileList
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingM
|
|
||||||
anchors.rightMargin: Theme.spacingM
|
|
||||||
anchors.topMargin: Theme.spacingS
|
|
||||||
anchors.bottomMargin: Theme.spacingS
|
|
||||||
visible: viewMode === "list"
|
|
||||||
spacing: Theme.spacingXXS
|
|
||||||
model: folderModel
|
|
||||||
currentIndex: selectedIndex
|
|
||||||
onCurrentIndexChanged: {
|
|
||||||
if (keyboardNavigationActive && currentIndex >= 0)
|
|
||||||
positionViewAtIndex(currentIndex, ListView.Contain);
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.vertical: DankScrollbar {
|
|
||||||
id: listScrollbar
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: FileBrowserListDelegate {
|
|
||||||
width: fileList.width
|
|
||||||
selectedIndex: root.selectedIndex
|
|
||||||
keyboardNavigationActive: root.keyboardNavigationActive
|
|
||||||
onItemClicked: (index, path, name, isDir) => {
|
|
||||||
selectedIndex = index;
|
|
||||||
setSelectedFileData(path, name, isDir);
|
|
||||||
root.activateFile(path, name, isDir);
|
|
||||||
}
|
|
||||||
onItemSelected: (index, path, name, isDir) => {
|
|
||||||
setSelectedFileData(path, name, isDir);
|
|
||||||
}
|
|
||||||
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
|
|
||||||
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
|
||||||
function onKeyboardSelectionRequestedChanged() {
|
|
||||||
if (root.keyboardSelectionRequested && root.keyboardSelectionIndex === index) {
|
|
||||||
root.keyboardSelectionRequested = false;
|
|
||||||
selectedIndex = index;
|
|
||||||
setSelectedFileData(filePath, fileName, fileIsDir);
|
|
||||||
root.activateFile(filePath, fileName, fileIsDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
target: root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileBrowserSaveRow {
|
|
||||||
id: saveRow
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingL
|
|
||||||
saveMode: root.saveMode
|
|
||||||
defaultFileName: root.defaultFileName
|
|
||||||
currentPath: root.currentPath
|
|
||||||
onSaveRequested: filePath => handleSaveFile(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyboardHints {
|
|
||||||
id: keyboardHints
|
|
||||||
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingL
|
|
||||||
showHints: root.showKeyboardHints
|
|
||||||
}
|
|
||||||
|
|
||||||
FileInfo {
|
|
||||||
id: fileInfo
|
|
||||||
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingL
|
|
||||||
width: 300
|
|
||||||
showFileInfo: root.showFileInfo
|
|
||||||
selectedIndex: root.selectedIndex
|
|
||||||
sourceFolderModel: folderModel
|
|
||||||
currentPath: root.currentPath
|
|
||||||
currentFileName: root.selectedFileName
|
|
||||||
currentFileIsDir: root.selectedFileIsDir
|
|
||||||
currentFileExtension: {
|
|
||||||
if (root.selectedFileIsDir || !root.selectedFileName)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
var lastDot = root.selectedFileName.lastIndexOf('.');
|
|
||||||
return lastDot > 0 ? root.selectedFileName.substring(lastDot + 1).toLowerCase() : "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileBrowserSortMenu {
|
|
||||||
id: sortMenu
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.topMargin: 120
|
|
||||||
anchors.rightMargin: Theme.spacingL
|
|
||||||
sortBy: root.sortBy
|
|
||||||
sortAscending: root.sortAscending
|
|
||||||
onSortBySelected: value => {
|
|
||||||
root.sortBy = value;
|
|
||||||
}
|
|
||||||
onSortOrderSelected: ascending => {
|
|
||||||
root.sortAscending = ascending;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileBrowserOverwriteDialog {
|
|
||||||
anchors.fill: parent
|
|
||||||
showDialog: showOverwriteConfirmation
|
|
||||||
pendingFilePath: root.pendingFilePath
|
|
||||||
onConfirmed: filePath => {
|
|
||||||
showOverwriteConfirmation = false;
|
|
||||||
fileSelected(filePath);
|
|
||||||
pendingFilePath = "";
|
|
||||||
Qt.callLater(() => root.closeRequested());
|
|
||||||
}
|
|
||||||
onCancelled: {
|
|
||||||
showOverwriteConfirmation = false;
|
|
||||||
pendingFilePath = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileBrowserItemContextMenu {
|
|
||||||
id: itemContextMenu
|
|
||||||
parentFocusItem: root
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,252 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import Quickshell.Widgets
|
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
StyledRect {
|
DankCommon.FileBrowserGridDelegate {}
|
||||||
id: delegateRoot
|
|
||||||
|
|
||||||
required property bool fileIsDir
|
|
||||||
required property string filePath
|
|
||||||
required property string fileName
|
|
||||||
required property int index
|
|
||||||
|
|
||||||
property bool weMode: false
|
|
||||||
property var iconSizes: [80, 120, 160, 200]
|
|
||||||
property int iconSizeIndex: 1
|
|
||||||
property int selectedIndex: -1
|
|
||||||
property bool keyboardNavigationActive: false
|
|
||||||
|
|
||||||
signal itemClicked(int index, string path, string name, bool isDir)
|
|
||||||
signal itemSelected(int index, string path, string name, bool isDir)
|
|
||||||
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
|
|
||||||
|
|
||||||
function getFileExtension(fileName) {
|
|
||||||
const parts = fileName.split('.');
|
|
||||||
if (parts.length > 1) {
|
|
||||||
return parts[parts.length - 1].toLowerCase();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function determineFileType(fileName) {
|
|
||||||
const ext = getFileExtension(fileName);
|
|
||||||
|
|
||||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "jxl", "avif", "heif", "exr"];
|
|
||||||
if (imageExts.includes(ext)) {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
|
|
||||||
if (videoExts.includes(ext)) {
|
|
||||||
return "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
|
|
||||||
if (audioExts.includes(ext)) {
|
|
||||||
return "audio";
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
|
|
||||||
if (codeExts.includes(ext)) {
|
|
||||||
return "code";
|
|
||||||
}
|
|
||||||
|
|
||||||
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
|
|
||||||
if (docExts.includes(ext)) {
|
|
||||||
return "document";
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
|
|
||||||
if (archiveExts.includes(ext)) {
|
|
||||||
return "archive";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ext || fileName.indexOf('.') === -1) {
|
|
||||||
return "binary";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "file";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageFile(fileName) {
|
|
||||||
if (!fileName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return determineFileType(fileName) === "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoFile(fileName) {
|
|
||||||
if (!fileName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return determineFileType(fileName) === "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
property bool isImage: isImageFile(delegateRoot.fileName)
|
|
||||||
property bool isVideo: isVideoFile(delegateRoot.fileName)
|
|
||||||
|
|
||||||
property string _xdgCacheHome: Paths.strip(Paths.xdgCache)
|
|
||||||
property string _thumbnailSize: iconSizeIndex >= 2 ? "x-large" : "large"
|
|
||||||
property int _thumbnailPx: iconSizeIndex >= 2 ? 512 : 256
|
|
||||||
property string videoThumbnailPath: {
|
|
||||||
if (!delegateRoot.fileIsDir && isVideo) {
|
|
||||||
const hash = Qt.md5("file://" + delegateRoot.filePath);
|
|
||||||
return _xdgCacheHome + "/thumbnails/" + _thumbnailSize + "/" + hash + ".png";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
property string _videoThumb: ""
|
|
||||||
property bool _thumbGenAttempted: false
|
|
||||||
|
|
||||||
// Probe the thumbnail optimistically; Image.Error triggers generation
|
|
||||||
onVideoThumbnailPathChanged: {
|
|
||||||
_thumbGenAttempted = false;
|
|
||||||
_videoThumb = videoThumbnailPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateVideoThumbnail() {
|
|
||||||
if (_thumbGenAttempted)
|
|
||||||
return;
|
|
||||||
_thumbGenAttempted = true;
|
|
||||||
_videoThumb = "";
|
|
||||||
const thumbPath = videoThumbnailPath;
|
|
||||||
const thumbDir = _xdgCacheHome + "/thumbnails/" + _thumbnailSize;
|
|
||||||
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s " + _thumbnailPx + " -f";
|
|
||||||
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, delegateRoot.filePath, thumbPath], function (output, exitCode) {
|
|
||||||
if (exitCode === 0)
|
|
||||||
_videoThumb = thumbPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIconForFile(fileName) {
|
|
||||||
const lowerName = fileName.toLowerCase();
|
|
||||||
if (lowerName.startsWith("dockerfile")) {
|
|
||||||
return "docker";
|
|
||||||
}
|
|
||||||
const ext = fileName.split('.').pop();
|
|
||||||
return ext || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
width: weMode ? 245 : iconSizes[iconSizeIndex] + 16
|
|
||||||
height: weMode ? 205 : iconSizes[iconSizeIndex] + 48
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: {
|
|
||||||
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
|
|
||||||
return Theme.surfacePressed;
|
|
||||||
|
|
||||||
return mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0);
|
|
||||||
}
|
|
||||||
border.color: keyboardNavigationActive && delegateRoot.index === selectedIndex ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
|
|
||||||
border.width: (keyboardNavigationActive && delegateRoot.index === selectedIndex) ? 2 : 0
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
|
|
||||||
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectedIndexChanged: {
|
|
||||||
if (keyboardNavigationActive && selectedIndex === delegateRoot.index)
|
|
||||||
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: weMode ? 225 : (iconSizes[iconSizeIndex] - 8)
|
|
||||||
height: weMode ? 165 : (iconSizes[iconSizeIndex] - 8)
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
|
|
||||||
ClippingRectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 2
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: "transparent"
|
|
||||||
|
|
||||||
Image {
|
|
||||||
id: gridPreviewImage
|
|
||||||
anchors.fill: parent
|
|
||||||
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"]
|
|
||||||
property int weExtIndex: 0
|
|
||||||
property string imagePath: {
|
|
||||||
if (weMode && delegateRoot.fileIsDir)
|
|
||||||
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
|
|
||||||
if (_videoThumb)
|
|
||||||
return _videoThumb;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
|
|
||||||
onStatusChanged: {
|
|
||||||
if (status !== Image.Error)
|
|
||||||
return;
|
|
||||||
if (weMode && delegateRoot.fileIsDir) {
|
|
||||||
if (weExtIndex < weExtensions.length - 1) {
|
|
||||||
weExtIndex++;
|
|
||||||
} else {
|
|
||||||
imagePath = "";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_videoThumb)
|
|
||||||
generateVideoThumbnail();
|
|
||||||
}
|
|
||||||
fillMode: Image.PreserveAspectCrop
|
|
||||||
sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex]
|
|
||||||
sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex]
|
|
||||||
asynchronous: true
|
|
||||||
visible: status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
CachingImage {
|
|
||||||
anchors.fill: parent
|
|
||||||
imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : ""
|
|
||||||
maxCacheSize: 256
|
|
||||||
animate: false
|
|
||||||
visible: !delegateRoot.fileIsDir && isImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankNFIcon {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
name: delegateRoot.fileIsDir ? "folder" : getIconForFile(delegateRoot.fileName)
|
|
||||||
size: iconSizes[iconSizeIndex] * 0.45
|
|
||||||
color: delegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText
|
|
||||||
visible: (!delegateRoot.fileIsDir && !isImage && !(isVideo && gridPreviewImage.status === Image.Ready)) || (delegateRoot.fileIsDir && !weMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: delegateRoot.fileName || ""
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
width: delegateRoot.width - Theme.spacingM
|
|
||||||
elide: Text.ElideRight
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
maximumLineCount: 2
|
|
||||||
wrapMode: Text.Wrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: mouseArea
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
||||||
onClicked: mouse => {
|
|
||||||
switch (mouse.button) {
|
|
||||||
case Qt.LeftButton:
|
|
||||||
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
|
||||||
break;
|
|
||||||
case Qt.RightButton:
|
|
||||||
itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,153 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import QtQuick.Controls
|
|
||||||
import Quickshell
|
|
||||||
import qs.Common
|
|
||||||
import qs.Services
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Popup {
|
DankCommon.FileBrowserItemContextMenu {}
|
||||||
id: root
|
|
||||||
|
|
||||||
property string filePath: ""
|
|
||||||
property string fileName: ""
|
|
||||||
property bool fileIsDir: false
|
|
||||||
property var parentFocusItem: null
|
|
||||||
|
|
||||||
signal trashed
|
|
||||||
signal menuClosed
|
|
||||||
|
|
||||||
readonly property var menuItems: [
|
|
||||||
{
|
|
||||||
text: I18n.tr("Move to Trash"),
|
|
||||||
icon: "delete",
|
|
||||||
action: trashItem,
|
|
||||||
enabled: filePath.length > 0,
|
|
||||||
dangerous: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: I18n.tr("Copy path"),
|
|
||||||
icon: "content_copy",
|
|
||||||
action: copyPath,
|
|
||||||
enabled: filePath.length > 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
function showAt(parentItem, localX, localY, path, name, isDir) {
|
|
||||||
if (!parentItem)
|
|
||||||
return;
|
|
||||||
parent = parentItem;
|
|
||||||
filePath = path || "";
|
|
||||||
fileName = name || "";
|
|
||||||
fileIsDir = !!isDir;
|
|
||||||
x = Math.max(0, Math.min(parentItem.width - width, localX));
|
|
||||||
y = Math.max(0, Math.min(parentItem.height - height, localY));
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
|
|
||||||
function trashItem() {
|
|
||||||
if (!filePath)
|
|
||||||
return;
|
|
||||||
TrashService.trashPath(filePath, ok => {
|
|
||||||
if (ok)
|
|
||||||
root.trashed();
|
|
||||||
});
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyPath() {
|
|
||||||
if (!filePath)
|
|
||||||
return;
|
|
||||||
Quickshell.execDetached(["dms", "cl", "copy", filePath]);
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 220
|
|
||||||
height: menuColumn.implicitHeight + Theme.spacingS * 2
|
|
||||||
padding: 0
|
|
||||||
modal: false
|
|
||||||
closePolicy: Popup.CloseOnEscape
|
|
||||||
|
|
||||||
onClosed: {
|
|
||||||
closePolicy = Popup.CloseOnEscape;
|
|
||||||
menuClosed();
|
|
||||||
if (parentFocusItem)
|
|
||||||
Qt.callLater(() => parentFocusItem.forceActiveFocus());
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpened: outsideClickTimer.start()
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: outsideClickTimer
|
|
||||||
interval: 100
|
|
||||||
onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
color: "transparent"
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Rectangle {
|
|
||||||
color: Theme.floatingSurface
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
border.color: BlurService.borderColor
|
|
||||||
border.width: BlurService.borderWidth
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: menuColumn
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingS
|
|
||||||
spacing: 1
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.menuItems
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
opacity: modelData.enabled ? 1 : 0.5
|
|
||||||
color: {
|
|
||||||
if (!modelData.enabled || !area.containsMouse)
|
|
||||||
return "transparent";
|
|
||||||
if (modelData.dangerous)
|
|
||||||
return Theme.errorHover;
|
|
||||||
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: modelData.icon
|
|
||||||
size: 16
|
|
||||||
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: modelData.text
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: area
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
enabled: modelData.enabled
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: modelData.action()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,256 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import Quickshell.Widgets
|
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
StyledRect {
|
DankCommon.FileBrowserListDelegate {}
|
||||||
id: listDelegateRoot
|
|
||||||
|
|
||||||
required property bool fileIsDir
|
|
||||||
required property string filePath
|
|
||||||
required property string fileName
|
|
||||||
required property int index
|
|
||||||
required property var fileModified
|
|
||||||
required property int fileSize
|
|
||||||
|
|
||||||
property int selectedIndex: -1
|
|
||||||
property bool keyboardNavigationActive: false
|
|
||||||
|
|
||||||
signal itemClicked(int index, string path, string name, bool isDir)
|
|
||||||
signal itemSelected(int index, string path, string name, bool isDir)
|
|
||||||
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
|
|
||||||
|
|
||||||
function getFileExtension(fileName) {
|
|
||||||
const parts = fileName.split('.');
|
|
||||||
if (parts.length > 1) {
|
|
||||||
return parts[parts.length - 1].toLowerCase();
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function determineFileType(fileName) {
|
|
||||||
const ext = getFileExtension(fileName);
|
|
||||||
|
|
||||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "jxl", "avif", "heif", "exr"];
|
|
||||||
if (imageExts.includes(ext)) {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
|
|
||||||
if (videoExts.includes(ext)) {
|
|
||||||
return "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
|
|
||||||
if (audioExts.includes(ext)) {
|
|
||||||
return "audio";
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
|
|
||||||
if (codeExts.includes(ext)) {
|
|
||||||
return "code";
|
|
||||||
}
|
|
||||||
|
|
||||||
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
|
|
||||||
if (docExts.includes(ext)) {
|
|
||||||
return "document";
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
|
|
||||||
if (archiveExts.includes(ext)) {
|
|
||||||
return "archive";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ext || fileName.indexOf('.') === -1) {
|
|
||||||
return "binary";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "file";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImageFile(fileName) {
|
|
||||||
if (!fileName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return determineFileType(fileName) === "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoFile(fileName) {
|
|
||||||
if (!fileName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return determineFileType(fileName) === "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
property bool isImage: isImageFile(listDelegateRoot.fileName)
|
|
||||||
property bool isVideo: isVideoFile(listDelegateRoot.fileName)
|
|
||||||
|
|
||||||
property string _xdgCacheHome: Paths.strip(Paths.xdgCache)
|
|
||||||
property string videoThumbnailPath: {
|
|
||||||
if (!listDelegateRoot.fileIsDir && isVideo) {
|
|
||||||
const hash = Qt.md5("file://" + listDelegateRoot.filePath);
|
|
||||||
return _xdgCacheHome + "/thumbnails/normal/" + hash + ".png";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
property string _videoThumb: ""
|
|
||||||
property bool _thumbGenAttempted: false
|
|
||||||
|
|
||||||
// Probe the thumbnail optimistically; Image.Error triggers generation
|
|
||||||
onVideoThumbnailPathChanged: {
|
|
||||||
_thumbGenAttempted = false;
|
|
||||||
_videoThumb = videoThumbnailPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateVideoThumbnail() {
|
|
||||||
if (_thumbGenAttempted)
|
|
||||||
return;
|
|
||||||
_thumbGenAttempted = true;
|
|
||||||
_videoThumb = "";
|
|
||||||
const thumbPath = videoThumbnailPath;
|
|
||||||
const thumbDir = _xdgCacheHome + "/thumbnails/normal";
|
|
||||||
const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s 128 -f";
|
|
||||||
Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, listDelegateRoot.filePath, thumbPath], function (output, exitCode) {
|
|
||||||
if (exitCode === 0)
|
|
||||||
_videoThumb = thumbPath;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIconForFile(fileName) {
|
|
||||||
const lowerName = fileName.toLowerCase();
|
|
||||||
if (lowerName.startsWith("dockerfile")) {
|
|
||||||
return "docker";
|
|
||||||
}
|
|
||||||
const ext = fileName.split('.').pop();
|
|
||||||
return ext || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(size) {
|
|
||||||
if (size < 1024)
|
|
||||||
return size + " B";
|
|
||||||
if (size < 1024 * 1024)
|
|
||||||
return (size / 1024).toFixed(1) + " KB";
|
|
||||||
if (size < 1024 * 1024 * 1024)
|
|
||||||
return (size / (1024 * 1024)).toFixed(1) + " MB";
|
|
||||||
return (size / (1024 * 1024 * 1024)).toFixed(1) + " GB";
|
|
||||||
}
|
|
||||||
|
|
||||||
height: 44
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: {
|
|
||||||
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
|
|
||||||
return Theme.surfacePressed;
|
|
||||||
return listMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainerHigh, 0);
|
|
||||||
}
|
|
||||||
border.color: keyboardNavigationActive && listDelegateRoot.index === selectedIndex ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
|
|
||||||
border.width: (keyboardNavigationActive && listDelegateRoot.index === selectedIndex) ? 2 : 0
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
|
|
||||||
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectedIndexChanged: {
|
|
||||||
if (keyboardNavigationActive && selectedIndex === listDelegateRoot.index)
|
|
||||||
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: 28
|
|
||||||
height: 28
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
|
|
||||||
ClippingRectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: "transparent"
|
|
||||||
|
|
||||||
Image {
|
|
||||||
id: listPreviewImage
|
|
||||||
anchors.fill: parent
|
|
||||||
property string imagePath: _videoThumb
|
|
||||||
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
|
|
||||||
fillMode: Image.PreserveAspectCrop
|
|
||||||
sourceSize.width: 32
|
|
||||||
sourceSize.height: 32
|
|
||||||
asynchronous: true
|
|
||||||
visible: status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo
|
|
||||||
onStatusChanged: {
|
|
||||||
if (status === Image.Error && _videoThumb)
|
|
||||||
generateVideoThumbnail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CachingImage {
|
|
||||||
anchors.fill: parent
|
|
||||||
imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : ""
|
|
||||||
maxCacheSize: 256
|
|
||||||
animate: false
|
|
||||||
visible: !listDelegateRoot.fileIsDir && isImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankNFIcon {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
name: listDelegateRoot.fileIsDir ? "folder" : getIconForFile(listDelegateRoot.fileName)
|
|
||||||
size: Theme.iconSize - 2
|
|
||||||
color: listDelegateRoot.fileIsDir ? Theme.primary : Theme.surfaceText
|
|
||||||
visible: listDelegateRoot.fileIsDir || (!isImage && !(isVideo && listPreviewImage.status === Image.Ready))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: listDelegateRoot.fileName || ""
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
width: parent.width - 280
|
|
||||||
elide: Text.ElideRight
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
maximumLineCount: 1
|
|
||||||
clip: true
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: listDelegateRoot.fileIsDir ? "" : formatFileSize(listDelegateRoot.fileSize)
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: 70
|
|
||||||
horizontalAlignment: Text.AlignRight
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: Qt.formatDateTime(listDelegateRoot.fileModified, "MMM d, yyyy h:mm AP")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: 140
|
|
||||||
horizontalAlignment: Text.AlignRight
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: listMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
||||||
onClicked: mouse => {
|
|
||||||
switch (mouse.button) {
|
|
||||||
case Qt.LeftButton:
|
|
||||||
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
|
||||||
break;
|
|
||||||
case Qt.RightButton:
|
|
||||||
itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,100 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import Quickshell
|
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
FloatingWindow {
|
DankCommon.FileBrowserModal {}
|
||||||
id: fileBrowserModal
|
|
||||||
|
|
||||||
property bool disablePopupTransparency: true
|
|
||||||
property string browserTitle: "Select File"
|
|
||||||
property string browserIcon: "folder_open"
|
|
||||||
property string browserType: "generic"
|
|
||||||
property var fileExtensions: ["*.*"]
|
|
||||||
property alias filterExtensions: fileBrowserModal.fileExtensions
|
|
||||||
property bool showHiddenFiles: false
|
|
||||||
property bool saveMode: false
|
|
||||||
property string defaultFileName: ""
|
|
||||||
property var parentModal: null
|
|
||||||
parentWindow: parentModal
|
|
||||||
property bool shouldHaveFocus: visible
|
|
||||||
property bool allowFocusOverride: false
|
|
||||||
property bool shouldBeVisible: visible
|
|
||||||
property bool allowStacking: true
|
|
||||||
|
|
||||||
signal fileSelected(string path)
|
|
||||||
signal dialogClosed
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
objectName: "fileBrowserModal"
|
|
||||||
title: "Files - " + browserTitle
|
|
||||||
minimumSize: Qt.size(500, 400)
|
|
||||||
implicitWidth: 800
|
|
||||||
implicitHeight: 600
|
|
||||||
color: Theme.surfaceContainer
|
|
||||||
visible: false
|
|
||||||
|
|
||||||
onClosed: close()
|
|
||||||
|
|
||||||
onVisibleChanged: {
|
|
||||||
if (visible) {
|
|
||||||
if (parentModal && "shouldHaveFocus" in parentModal) {
|
|
||||||
parentModal.shouldHaveFocus = false;
|
|
||||||
parentModal.allowFocusOverride = true;
|
|
||||||
}
|
|
||||||
Qt.callLater(() => {
|
|
||||||
if (content) {
|
|
||||||
content.reset();
|
|
||||||
content.forceActiveFocus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (parentModal && "allowFocusOverride" in parentModal) {
|
|
||||||
parentModal.allowFocusOverride = false;
|
|
||||||
parentModal.shouldHaveFocus = Qt.binding(() => parentModal.shouldBeVisible);
|
|
||||||
}
|
|
||||||
dialogClosed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
id: contentLoader
|
|
||||||
anchors.fill: parent
|
|
||||||
active: fileBrowserModal.visible
|
|
||||||
sourceComponent: FileBrowserContent {
|
|
||||||
id: content
|
|
||||||
anchors.fill: parent
|
|
||||||
focus: true
|
|
||||||
closeOnEscape: false
|
|
||||||
windowControls: fileBrowserModal.windowControlsRef
|
|
||||||
|
|
||||||
browserTitle: fileBrowserModal.browserTitle
|
|
||||||
browserIcon: fileBrowserModal.browserIcon
|
|
||||||
browserType: fileBrowserModal.browserType
|
|
||||||
fileExtensions: fileBrowserModal.fileExtensions
|
|
||||||
showHiddenFiles: fileBrowserModal.showHiddenFiles
|
|
||||||
saveMode: fileBrowserModal.saveMode
|
|
||||||
defaultFileName: fileBrowserModal.defaultFileName
|
|
||||||
|
|
||||||
Component.onCompleted: initialize()
|
|
||||||
|
|
||||||
onFileSelected: path => fileBrowserModal.fileSelected(path)
|
|
||||||
onCloseRequested: fileBrowserModal.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
property alias content: contentLoader.item
|
|
||||||
property alias windowControlsRef: windowControls
|
|
||||||
|
|
||||||
FloatingWindowControls {
|
|
||||||
id: windowControls
|
|
||||||
targetWindow: fileBrowserModal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,130 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Row {
|
DankCommon.FileBrowserNavigation {}
|
||||||
id: navigation
|
|
||||||
|
|
||||||
property string currentPath: ""
|
|
||||||
property string homeDir: ""
|
|
||||||
property bool backButtonFocused: false
|
|
||||||
property bool keyboardNavigationActive: false
|
|
||||||
property bool showSidebar: true
|
|
||||||
property bool pathEditMode: false
|
|
||||||
property bool pathInputHasFocus: false
|
|
||||||
|
|
||||||
signal navigateUp
|
|
||||||
signal navigateTo(string path)
|
|
||||||
signal pathInputFocusChanged(bool hasFocus)
|
|
||||||
|
|
||||||
height: 40
|
|
||||||
leftPadding: Theme.spacingM
|
|
||||||
rightPadding: Theme.spacingM
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: 32
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: (backButtonMouseArea.containsMouse || (backButtonFocused && keyboardNavigationActive)) && currentPath !== homeDir ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
|
|
||||||
opacity: currentPath !== homeDir ? 1 : 0
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
name: "arrow_back"
|
|
||||||
size: Theme.iconSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: backButtonMouseArea
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: currentPath !== homeDir
|
|
||||||
cursorShape: currentPath !== homeDir ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
||||||
enabled: currentPath !== homeDir
|
|
||||||
onClicked: navigation.navigateUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: Math.max(0, (parent?.width ?? 0) - 40 - Theme.spacingS - (showSidebar ? 0 : 80))
|
|
||||||
height: 32
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
anchors.fill: parent
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: pathEditMode ? Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
|
||||||
border.color: pathEditMode ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
|
|
||||||
border.width: pathEditMode ? 1 : 0
|
|
||||||
visible: !pathEditMode
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
id: pathDisplay
|
|
||||||
text: currentPath.replace("file://", "")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
maximumLineCount: 1
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.IBeamCursor
|
|
||||||
onClicked: {
|
|
||||||
pathEditMode = true;
|
|
||||||
pathInput.text = currentPath.replace("file://", "");
|
|
||||||
Qt.callLater(() => pathInput.forceActiveFocus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankTextField {
|
|
||||||
id: pathInput
|
|
||||||
anchors.fill: parent
|
|
||||||
visible: pathEditMode
|
|
||||||
topPadding: Theme.spacingXS
|
|
||||||
bottomPadding: Theme.spacingXS
|
|
||||||
onAccepted: {
|
|
||||||
const newPath = text.trim();
|
|
||||||
if (newPath !== "") {
|
|
||||||
navigation.navigateTo(newPath);
|
|
||||||
}
|
|
||||||
pathEditMode = false;
|
|
||||||
}
|
|
||||||
Keys.onEscapePressed: {
|
|
||||||
pathEditMode = false;
|
|
||||||
}
|
|
||||||
Keys.onDownPressed: {
|
|
||||||
pathEditMode = false;
|
|
||||||
}
|
|
||||||
onActiveFocusChanged: {
|
|
||||||
navigation.pathInputFocusChanged(activeFocus);
|
|
||||||
if (!activeFocus && pathEditMode) {
|
|
||||||
pathEditMode = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
visible: !showSidebar
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
circular: false
|
|
||||||
iconName: "sort"
|
|
||||||
iconSize: Theme.iconSize - 6
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,127 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Item {
|
DankCommon.FileBrowserOverwriteDialog {}
|
||||||
id: overwriteDialog
|
|
||||||
|
|
||||||
property bool showDialog: false
|
|
||||||
property string pendingFilePath: ""
|
|
||||||
|
|
||||||
signal confirmed(string filePath)
|
|
||||||
signal cancelled
|
|
||||||
|
|
||||||
visible: showDialog
|
|
||||||
focus: showDialog
|
|
||||||
|
|
||||||
Keys.onEscapePressed: {
|
|
||||||
cancelled();
|
|
||||||
}
|
|
||||||
|
|
||||||
Keys.onReturnPressed: {
|
|
||||||
confirmed(pendingFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
color: Theme.shadowStrong
|
|
||||||
opacity: 0.8
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
onClicked: {
|
|
||||||
cancelled();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
width: 400
|
|
||||||
height: 160
|
|
||||||
color: Theme.surfaceContainer
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
border.color: Theme.outlineMedium
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
width: parent.width - Theme.spacingL * 2
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("File Already Exists")
|
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
|
||||||
font.weight: Font.Medium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("A file with this name already exists. Do you want to overwrite it?")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: parent.width
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: 80
|
|
||||||
height: 36
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: cancelArea.containsMouse ? Qt.lighter(Theme.surfaceVariant, 1.2) : Theme.surfaceVariant
|
|
||||||
border.color: Theme.outline
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: I18n.tr("Cancel")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: cancelArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
cancelled();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: 90
|
|
||||||
height: 36
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: overwriteArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: I18n.tr("Overwrite")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.background
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: overwriteArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
confirmed(pendingFilePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,75 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Row {
|
DankCommon.FileBrowserSaveRow {}
|
||||||
id: saveRow
|
|
||||||
|
|
||||||
property bool saveMode: false
|
|
||||||
property string defaultFileName: ""
|
|
||||||
property string currentPath: ""
|
|
||||||
property alias fileName: fileNameInput.text
|
|
||||||
|
|
||||||
signal saveRequested(string filePath)
|
|
||||||
|
|
||||||
height: saveMode ? 40 : 0
|
|
||||||
visible: saveMode
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
|
|
||||||
DankTextField {
|
|
||||||
id: fileNameInput
|
|
||||||
|
|
||||||
width: parent.width - saveButton.width - Theme.spacingM
|
|
||||||
height: 40
|
|
||||||
text: defaultFileName
|
|
||||||
placeholderText: I18n.tr("Enter filename...")
|
|
||||||
ignoreLeftRightKeys: false
|
|
||||||
focus: saveMode
|
|
||||||
topPadding: Theme.spacingS
|
|
||||||
bottomPadding: Theme.spacingS
|
|
||||||
Component.onCompleted: {
|
|
||||||
if (saveMode)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
forceActiveFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onAccepted: {
|
|
||||||
if (text.trim() !== "") {
|
|
||||||
var basePath = currentPath.replace(/^file:\/\//, '');
|
|
||||||
var fullPath = basePath + "/" + text.trim();
|
|
||||||
fullPath = fullPath.replace(/\/+/g, '/');
|
|
||||||
saveRequested(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
id: saveButton
|
|
||||||
|
|
||||||
width: 80
|
|
||||||
height: 40
|
|
||||||
color: fileNameInput.text.trim() !== "" ? Theme.primary : Theme.surfaceVariant
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: I18n.tr("Save")
|
|
||||||
color: fileNameInput.text.trim() !== "" ? Theme.primaryText : Theme.surfaceVariantText
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
}
|
|
||||||
|
|
||||||
StateLayer {
|
|
||||||
stateColor: Theme.primary
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
enabled: fileNameInput.text.trim() !== ""
|
|
||||||
onClicked: {
|
|
||||||
if (fileNameInput.text.trim() !== "") {
|
|
||||||
var basePath = currentPath.replace(/^file:\/\//, '');
|
|
||||||
var fullPath = basePath + "/" + fileNameInput.text.trim();
|
|
||||||
fullPath = fullPath.replace(/\/+/g, '/');
|
|
||||||
saveRequested(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,70 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
StyledRect {
|
DankCommon.FileBrowserSidebar {}
|
||||||
id: sidebar
|
|
||||||
|
|
||||||
property var quickAccessLocations: []
|
|
||||||
property string currentPath: ""
|
|
||||||
signal locationSelected(string path)
|
|
||||||
|
|
||||||
width: 200
|
|
||||||
color: Theme.nestedSurface
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingS
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("Quick Access")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
font.weight: Font.Medium
|
|
||||||
leftPadding: Theme.spacingS
|
|
||||||
bottomPadding: Theme.spacingXS
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: quickAccessLocations
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: parent?.width ?? 0
|
|
||||||
height: 38
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: quickAccessMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : (currentPath === modelData?.path ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingM
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: modelData?.icon ?? ""
|
|
||||||
size: Theme.iconSize - 2
|
|
||||||
color: currentPath === modelData?.path ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: modelData?.name ?? ""
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: currentPath === modelData?.path ? Theme.primary : Theme.surfaceText
|
|
||||||
font.weight: currentPath === modelData?.path ? Font.Medium : Font.Normal
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: quickAccessMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: locationSelected(modelData?.path ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,189 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
StyledRect {
|
DankCommon.FileBrowserSortMenu {}
|
||||||
id: sortMenu
|
|
||||||
|
|
||||||
property string sortBy: "name"
|
|
||||||
property bool sortAscending: true
|
|
||||||
property color surfaceColor: Theme.surfaceContainer
|
|
||||||
|
|
||||||
signal sortBySelected(string value)
|
|
||||||
signal sortOrderSelected(bool ascending)
|
|
||||||
|
|
||||||
width: 200
|
|
||||||
height: sortColumn.height + Theme.spacingM * 2
|
|
||||||
color: surfaceColor
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
border.color: Theme.outlineMedium
|
|
||||||
border.width: 1
|
|
||||||
visible: false
|
|
||||||
z: 100
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: sortColumn
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: "Sort By"
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: [
|
|
||||||
{
|
|
||||||
"name": "Name",
|
|
||||||
"value": "name"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Size",
|
|
||||||
"value": "size"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Modified",
|
|
||||||
"value": "modified"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Type",
|
|
||||||
"value": "type"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: sortColumn?.width ?? 0
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: sortMouseArea.containsMouse ? Theme.surfaceVariant : (sortBy === modelData?.value ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: sortBy === modelData?.value ? "check" : ""
|
|
||||||
size: Theme.iconSizeSmall
|
|
||||||
color: Theme.primary
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
visible: sortBy === modelData?.value
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: modelData?.name ?? ""
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: sortBy === modelData?.value ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: sortMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
sortMenu.sortBySelected(modelData?.value ?? "name");
|
|
||||||
sortMenu.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: sortColumn.width
|
|
||||||
height: 1
|
|
||||||
color: Theme.outline
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: "Order"
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
font.weight: Font.Medium
|
|
||||||
topPadding: Theme.spacingXS
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: sortColumn?.width ?? 0
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: ascMouseArea.containsMouse ? Theme.surfaceVariant : (sortAscending ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: "arrow_upward"
|
|
||||||
size: Theme.iconSizeSmall
|
|
||||||
color: sortAscending ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: "Ascending"
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: sortAscending ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: ascMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
sortMenu.sortOrderSelected(true);
|
|
||||||
sortMenu.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledRect {
|
|
||||||
width: sortColumn?.width ?? 0
|
|
||||||
height: 32
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: descMouseArea.containsMouse ? Theme.surfaceVariant : (!sortAscending ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: "arrow_downward"
|
|
||||||
size: Theme.iconSizeSmall
|
|
||||||
color: !sortAscending ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: "Descending"
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: !sortAscending ? Theme.primary : Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: descMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
sortMenu.sortOrderSelected(false);
|
|
||||||
sortMenu.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,236 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import Quickshell.Io
|
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Rectangle {
|
DankCommon.FileInfo {}
|
||||||
id: root
|
|
||||||
|
|
||||||
property bool showFileInfo: false
|
|
||||||
property int selectedIndex: -1
|
|
||||||
property var sourceFolderModel: null
|
|
||||||
property string currentPath: ""
|
|
||||||
|
|
||||||
height: 200
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
|
|
||||||
border.color: Theme.secondary
|
|
||||||
border.width: 2
|
|
||||||
opacity: showFileInfo ? 1 : 0
|
|
||||||
z: 100
|
|
||||||
|
|
||||||
onShowFileInfoChanged: {
|
|
||||||
if (showFileInfo && currentFileName && currentPath) {
|
|
||||||
const fullPath = currentPath + "/" + currentFileName;
|
|
||||||
fileStatProcess.selectedFilePath = fullPath;
|
|
||||||
fileStatProcess.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: fileStatProcess
|
|
||||||
command: ["stat", "-c", "%y|%A|%s|%n", selectedFilePath]
|
|
||||||
property string selectedFilePath: ""
|
|
||||||
property var fileStats: null
|
|
||||||
running: false
|
|
||||||
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
if (text && text.trim()) {
|
|
||||||
const parts = text.trim().split('|');
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
fileStatProcess.fileStats = {
|
|
||||||
"modifiedTime": parts[0],
|
|
||||||
"permissions": parts[1],
|
|
||||||
"size": parseInt(parts[2]) || 0,
|
|
||||||
"fullPath": parts[3]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onExited: function (exitCode) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
property string currentFileName: ""
|
|
||||||
property bool currentFileIsDir: false
|
|
||||||
property string currentFileExtension: ""
|
|
||||||
|
|
||||||
onCurrentFileNameChanged: {
|
|
||||||
if (showFileInfo && currentFileName && currentPath) {
|
|
||||||
const fullPath = currentPath + "/" + currentFileName;
|
|
||||||
if (fullPath !== fileStatProcess.selectedFilePath) {
|
|
||||||
fileStatProcess.selectedFilePath = fullPath;
|
|
||||||
fileStatProcess.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFileInfo(filePath, fileName, isDirectory) {
|
|
||||||
if (filePath && filePath !== fileStatProcess.selectedFilePath) {
|
|
||||||
fileStatProcess.selectedFilePath = filePath;
|
|
||||||
currentFileName = fileName || "";
|
|
||||||
currentFileIsDir = isDirectory || false;
|
|
||||||
|
|
||||||
let ext = "";
|
|
||||||
if (!isDirectory && fileName) {
|
|
||||||
const lastDot = fileName.lastIndexOf('.');
|
|
||||||
if (lastDot > 0) {
|
|
||||||
ext = fileName.substring(lastDot + 1).toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentFileExtension = ext;
|
|
||||||
|
|
||||||
if (showFileInfo) {
|
|
||||||
fileStatProcess.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var currentFileDisplayData: {
|
|
||||||
if (selectedIndex < 0 || !sourceFolderModel) {
|
|
||||||
return {
|
|
||||||
"exists": false,
|
|
||||||
"name": "No selection",
|
|
||||||
"type": "",
|
|
||||||
"size": "",
|
|
||||||
"modified": "",
|
|
||||||
"permissions": "",
|
|
||||||
"extension": "",
|
|
||||||
"position": "N/A"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasValidFile = currentFileName !== "";
|
|
||||||
return {
|
|
||||||
"exists": hasValidFile,
|
|
||||||
"name": hasValidFile ? currentFileName : "Loading...",
|
|
||||||
"type": currentFileIsDir ? "Directory" : "File",
|
|
||||||
"size": fileStatProcess.fileStats ? formatFileSize(fileStatProcess.fileStats.size) : "Calculating...",
|
|
||||||
"modified": fileStatProcess.fileStats ? formatDateTime(fileStatProcess.fileStats.modifiedTime) : "Loading...",
|
|
||||||
"permissions": fileStatProcess.fileStats ? fileStatProcess.fileStats.permissions : "Loading...",
|
|
||||||
"extension": currentFileExtension,
|
|
||||||
"position": sourceFolderModel ? ((selectedIndex + 1) + " of " + sourceFolderModel.count) : "N/A"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
Row {
|
|
||||||
width: parent.width
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: "info"
|
|
||||||
size: Theme.iconSize
|
|
||||||
color: Theme.secondary
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("File Information")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
width: parent.width
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.name
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
width: parent.width
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.type + (currentFileDisplayData.extension ? " (." + currentFileDisplayData.extension + ")" : "")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: parent.width
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.size
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: parent.width
|
|
||||||
visible: currentFileDisplayData.exists && !currentFileIsDir
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.modified
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: parent.width
|
|
||||||
elide: Text.ElideRight
|
|
||||||
visible: currentFileDisplayData.exists
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.permissions
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
visible: currentFileDisplayData.exists
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: currentFileDisplayData.position
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
width: parent.width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("F1/I: Toggle • F10: Help")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceTextMedium
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0 || !bytes) {
|
|
||||||
return "0 B";
|
|
||||||
}
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTimeString) {
|
|
||||||
if (!dateTimeString) {
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
const parts = dateTimeString.split(' ');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return parts[0] + " " + parts[1].split('.')[0];
|
|
||||||
}
|
|
||||||
return dateTimeString;
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
easing.type: Theme.standardEasing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,50 +1,3 @@
|
|||||||
import QtQuick
|
import qs.DankCommon.Modals.FileBrowser as DankCommon
|
||||||
import qs.Common
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
Rectangle {
|
DankCommon.KeyboardHints {}
|
||||||
id: root
|
|
||||||
|
|
||||||
property bool showHints: false
|
|
||||||
|
|
||||||
height: 80
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
|
|
||||||
border.color: Theme.primary
|
|
||||||
border.width: 2
|
|
||||||
opacity: showHints ? 1 : 0
|
|
||||||
z: 100
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.margins: Theme.spacingS
|
|
||||||
spacing: Theme.spacingXXS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("Tab/Shift+Tab: Nav • ←→↑↓: Grid Nav • Enter/Space: Select")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
width: parent.width
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("Alt+←/Backspace: Back • F1/I: File Info • F10: Help • Esc: Close")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
width: parent.width
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
easing.type: Theme.standardEasing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ FocusScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("Authentication failed, please try again")
|
text: I18n.tr("Authentication failed - try again")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.error
|
color: Theme.error
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user