feat: update base to official upstream 2.4.2 with all cracked features intact (2.4.2-c1)
- Rebased base onto official Polyfrost OneLauncher 2.4.2 (release v2.4.2). - Ensured offline mode launches with 0 Microsoft accounts required (no OfflineRequiresMicrosoft error). - Maintained custom 3D offline skin changer, player model switch (Classic/Slim), and targeted Minecraft Java UUID character slot isolation to prevent multiplayer skin cloning. - Maintained CustomSkinLoader auto-configuration for local skins, Ely.by, and SkinRestorer. - Integrated direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked. - Ported inline username renaming modal and Change Skin navigation in account settings and onboarding. - Maintained mods filter popover blur/text ghosting fix in package manager. - Preserved fast dev profile and multiplatform release workflow for v2.4.2-c1. - All workspace tests and clippy checks pass with 0 errors.
This commit is contained in:
parent
1b5e9eb942
commit
936e4247a3
144 changed files with 7978 additions and 2043 deletions
60
.github/workflows/copr_publish.yml
vendored
Normal file
60
.github/workflows/copr_publish.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Publish COPR Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. oneclient-2.0.1)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.parse.outputs.version }}
|
||||
steps:
|
||||
- name: Parse release tag
|
||||
id: parse
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
if [[ "$TAG" =~ ^oneclient-(.+)$ ]]; then
|
||||
echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Tag '$TAG' does not match oneclient-*"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish-bin:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
container: fedora:latest
|
||||
steps:
|
||||
- name: Install tooling
|
||||
run: dnf -y install rpm-build rpmdevtools copr-cli bsdtar git
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Prepare spec
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
SPEC="distribution/copr/oneclient-bin.spec"
|
||||
sed -i "s/^Version:.*/Version: ${VERSION}/" "$SPEC"
|
||||
sed -i "s/^Release:.*/Release: 1%{?dist}/" "$SPEC"
|
||||
|
||||
- name: Build SRPM
|
||||
run: |
|
||||
rpmdev-setuptree
|
||||
spectool -g -R distribution/copr/oneclient-bin.spec
|
||||
rpmbuild -bs distribution/copr/oneclient-bin.spec
|
||||
|
||||
- name: Submit to COPR
|
||||
env:
|
||||
COPR_CONFIG_CONTENT: ${{ secrets.COPR_CONFIG }}
|
||||
run: |
|
||||
mkdir -p ~/.config
|
||||
printf '%s' "$COPR_CONFIG_CONTENT" > ~/.config/copr
|
||||
chmod 600 ~/.config/copr
|
||||
copr-cli build polyfrost/OneClient ~/rpmbuild/SRPMS/oneclient-bin-*.src.rpm
|
||||
2
.github/workflows/oneclient_nightly.yml
vendored
2
.github/workflows/oneclient_nightly.yml
vendored
|
|
@ -189,6 +189,8 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop \
|
||||
target/release/oneclient_app.desktop
|
||||
cargo generate-rpm -p packages/oneclient_app
|
||||
cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/oneclient_release.yml
vendored
2
.github/workflows/oneclient_release.yml
vendored
|
|
@ -407,6 +407,8 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop \
|
||||
target/release/oneclient_app.desktop
|
||||
cargo generate-rpm -p packages/oneclient_app
|
||||
cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/"
|
||||
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -7,9 +7,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release Tag (e.g. v2.3.1)'
|
||||
description: 'Release Tag (e.g. v2.4.2)'
|
||||
required: false
|
||||
default: 'v2.3.1-c2'
|
||||
default: 'v2.4.2-c1'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
12
.sqlx/query-af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86.json
generated
Normal file
12
.sqlx/query-af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND package_id = ? AND override_type = 'disabled'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86"
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues
|
|||
#### Making Changes Locally
|
||||
|
||||
This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a
|
||||
recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way
|
||||
recent Rust toolchain (`rustc` **1.97+**) installed before proceeding - the easiest way
|
||||
is via [rustup].
|
||||
|
||||
To make changes locally, follow these steps:
|
||||
|
|
|
|||
149
Cargo.lock
generated
149
Cargo.lock
generated
|
|
@ -158,7 +158,7 @@ dependencies = [
|
|||
"bytestring",
|
||||
"derive_more",
|
||||
"encoding_rs",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"httparse",
|
||||
|
|
@ -253,7 +253,7 @@ dependencies = [
|
|||
"cfg-if",
|
||||
"derive_more",
|
||||
"encoding_rs",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"impl-more",
|
||||
|
|
@ -428,6 +428,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
|
|
@ -2022,6 +2023,12 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
|
|
@ -2049,6 +2056,12 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2094,7 +2107,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"freya-android",
|
||||
|
|
@ -2117,7 +2130,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-android"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-components",
|
||||
"freya-core",
|
||||
|
|
@ -2130,7 +2143,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-animation"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"easer",
|
||||
"freya-core",
|
||||
|
|
@ -2139,7 +2152,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-clipboard"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"copypasta",
|
||||
"freya-core",
|
||||
|
|
@ -2148,7 +2161,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-components"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
|
|
@ -2174,7 +2187,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-core"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"async-io",
|
||||
|
|
@ -2204,7 +2217,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-edit"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"freya-clipboard",
|
||||
|
|
@ -2218,7 +2231,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-engine"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-skia-safe",
|
||||
]
|
||||
|
|
@ -2226,7 +2239,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-markdown"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-components",
|
||||
"freya-core",
|
||||
|
|
@ -2237,7 +2250,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-performance-plugin"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
"freya-engine",
|
||||
|
|
@ -2247,7 +2260,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-query"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
"futures-util",
|
||||
|
|
@ -2256,7 +2269,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-radio"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
]
|
||||
|
|
@ -2264,7 +2277,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-router"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
"freya-router-macro",
|
||||
|
|
@ -2276,7 +2289,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-router-macro"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -2287,7 +2300,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-sdk"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
]
|
||||
|
|
@ -2336,7 +2349,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-testing"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"freya-clipboard",
|
||||
|
|
@ -2351,7 +2364,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "freya-winit"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_winit",
|
||||
|
|
@ -2741,6 +2754,9 @@ name = "hashbrown"
|
|||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
|
|
@ -2750,7 +2766,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3231,9 +3247,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "interfrost"
|
||||
version = "1.3.2"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3449eb9b8a587b832db2eb3a45776928ce0927c0efa7d0be9a7f4c619140a12a"
|
||||
checksum = "76a14453cd2d9454beb9b410dc7b845b2d62ff67a6f42460bce6b9bc6e34d3b9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
|
@ -4464,7 +4480,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_app"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
|
@ -4514,7 +4530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_auth"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
|
@ -4538,7 +4554,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_cluster"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"chrono",
|
||||
|
|
@ -4561,7 +4577,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_common"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"interfrost",
|
||||
|
|
@ -4574,7 +4590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_content"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
|
|
@ -4602,7 +4618,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_core"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
|
|
@ -4623,6 +4639,7 @@ dependencies = [
|
|||
"image",
|
||||
"indicatif",
|
||||
"interfrost",
|
||||
"libc",
|
||||
"md-5 0.10.6",
|
||||
"oneclient_auth",
|
||||
"oneclient_cluster",
|
||||
|
|
@ -4666,7 +4683,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_db"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"async-tempfile",
|
||||
"chrono",
|
||||
|
|
@ -4681,7 +4698,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_discord"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"discord-rich-presence",
|
||||
|
|
@ -4693,7 +4710,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_events"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -4703,13 +4720,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_java"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-tempfile",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"oneclient_common",
|
||||
"oneclient_events",
|
||||
"oneclient_net",
|
||||
|
|
@ -4725,7 +4743,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_macro"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -4734,7 +4752,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_mc"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
|
@ -4756,7 +4774,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_net"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
|
|
@ -4778,7 +4796,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_polyplus"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"bytes 1.11.1",
|
||||
|
|
@ -4857,6 +4875,16 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
|
|
@ -4910,7 +4938,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||
[[package]]
|
||||
name = "pathgraph"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
|
|
@ -4918,6 +4946,17 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
|
|
@ -5055,7 +5094,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
|||
|
||||
[[package]]
|
||||
name = "polyio"
|
||||
version = "2.3.1-c1"
|
||||
version = "2.4.2-c1"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
|
|
@ -5077,6 +5116,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5337,7 +5377,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|||
[[package]]
|
||||
name = "ragnarok"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"euclid",
|
||||
"hotpath",
|
||||
|
|
@ -7138,7 +7178,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
|||
[[package]]
|
||||
name = "torin"
|
||||
version = "0.5.0-rc.4"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
|
||||
source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3"
|
||||
dependencies = [
|
||||
"euclid",
|
||||
"itertools 0.14.0",
|
||||
|
|
@ -7300,6 +7340,17 @@ dependencies = [
|
|||
"windows 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
|
|
@ -8358,6 +8409,24 @@ version = "0.57.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.4",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
|
|
|||
18
Cargo.toml
18
Cargo.toml
|
|
@ -15,7 +15,8 @@ homepage = "https://polyfrost.org/projects/oneclient"
|
|||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/oiupoyt/OneLauncher-Cracked"
|
||||
version = "2.3.1-c2"
|
||||
rust-version = "1.97"
|
||||
version = "2.4.2-c1"
|
||||
|
||||
[workspace.dependencies]
|
||||
# core
|
||||
|
|
@ -36,7 +37,7 @@ polyio = {path = "./packages/polyio"}
|
|||
|
||||
# core (extra)
|
||||
discord-rich-presence = {version = "=1.1.0"}
|
||||
interfrost = {version = "=1.3.2"}
|
||||
interfrost = {version = "=1.4.0"}
|
||||
|
||||
# logging
|
||||
sentry = {version = "=0.48.5", default-features = false, features = [
|
||||
|
|
@ -103,7 +104,7 @@ sha1 = {version = "=0.10.6", default-features = false}
|
|||
sha2 = {version = "=0.10.9", default-features = false}
|
||||
|
||||
# util
|
||||
arboard = {version = "=3.6.1"}
|
||||
arboard = {version = "=3.6.1", features = ["wayland-data-control"]}
|
||||
arc-swap = {version = "=1.7.1"}
|
||||
base64 = {version = "=0.22.1"}
|
||||
bytes = {version = "=1.11.1"}
|
||||
|
|
@ -113,6 +114,7 @@ dunce = {version = "=1.0.5"}
|
|||
htmd = {version = "=0.5.4"}
|
||||
indicatif = {version = "=0.18.4", features = ["tokio"]}
|
||||
junction = {version = "=2.0.0"}
|
||||
libc = {version = "=0.2.189"}
|
||||
notify = {version = "=8.2.0"}
|
||||
parking_lot = {version = "=0.12.5"}
|
||||
pulldown-cmark = {version = "=0.13.4", default-features = false}
|
||||
|
|
@ -129,6 +131,7 @@ trash = {version = "=5.2.6"}
|
|||
percent-encoding = {version = "=2.3.2"}
|
||||
url = {version = "=2.5.8", features = ["serde"]}
|
||||
uuid = {version = "=1.23.1", features = ["serde", "v4"]}
|
||||
windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]}
|
||||
|
||||
# util (codegen/macro)
|
||||
anyhow = {version = "=1.0.102"}
|
||||
|
|
@ -160,10 +163,17 @@ inherits = "release"
|
|||
debug = true
|
||||
strip = false
|
||||
|
||||
[profile.release-fastprofiling]
|
||||
inherits = "release"
|
||||
|
||||
codegen-units = 16
|
||||
debug = true
|
||||
lto = false
|
||||
strip = false
|
||||
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -1,16 +1,73 @@
|
|||
<div align="center">
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/e8359baa-23fb-457a-8910-169974487a98" alt="OneLauncher Logo" width="220" style="border-radius: 24px;" />
|
||||
<img src=".github/media/RepoBanner.png" alt="Repository Banner" />
|
||||
|
||||
forked from [OneLauncher](https://github.com/Polyfrost/OneLauncher) allowing for uh offline authentication
|
||||
# OneClient | OneLauncher
|
||||
The monorepo containing the code for OneLauncher, OneClient, and their core backend.
|
||||
|
||||
OneClient is a Minecraft client featuring fully 100% open-source components, offering many packaged and pre-configured mods in one click.
|
||||
OneLauncher is a WIP Minecraft launcher giving power-users the greatest customization whilst featuring a clean UI.
|
||||
|
||||
</div>
|
||||
|
||||
## download from [**Releases Page**](https://github.com/oiupoyt/OneLauncher-Cracked/releases).
|
||||
yes the actual client performs normally aswell
|
||||
## Installing
|
||||
|
||||
You can install the latest release of OneClient from our website: [https://polyfrost.org/projects/oneclient](https://polyfrost.org/projects/oneclient)
|
||||
as well as our [GitHub releases](https://github.com/Polyfrost/OneLauncher/releases/latest).
|
||||
|
||||
| Windows (x86_64) | macOS (Intel & Apple Silicon) | Linux (x86_64) |
|
||||
|------------------|-------------------------------|---------------------------------------------------------|
|
||||
| Installer 🔄 | DMG 🔄 | AppImage 🔄 |
|
||||
| | App Bundle 🔄 | DEB |
|
||||
| | | RPM |
|
||||
| | | [AUR](https://aur.archlinux.org/packages/oneclient-bin) |
|
||||
|
||||
> 🔄 = Has support for autoupdating built-in
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
intended for offline usage only.
|
||||
We welcome contributions! Please read our [contributing guidelines](CONTRIBUTING.md) before getting started.
|
||||
|
||||
|
||||
### Requirements
|
||||
|
||||
The project targets **Rust 1.97** or later. You can install Rust via [rustup](https://rustup.rs/).
|
||||
|
||||
|
||||
### Building & Running
|
||||
|
||||
```sh
|
||||
# Run the app
|
||||
cargo run -p oneclient_app
|
||||
|
||||
# Build a release binary
|
||||
cargo build -p oneclient_app --release
|
||||
```
|
||||
|
||||
|
||||
### Packaging / Releasing
|
||||
|
||||
Installers are produced with [**cargo-packager**](https://github.com/crabnebula-dev/cargo-packager)
|
||||
(the standalone bundler spun out of the Tauri bundler). Config lives in
|
||||
[`packages/oneclient_app/Cargo.toml`](./packages/oneclient_app/Cargo.toml) under
|
||||
`[package.metadata.packager]`.
|
||||
|
||||
```sh
|
||||
cargo install cargo-packager --locked
|
||||
|
||||
# Build the binary, then bundle it for the current OS:
|
||||
cargo build --release -p oneclient_app
|
||||
cargo packager --release -p oneclient_app --formats <targets>
|
||||
# Windows: nsis macOS: app,dmg Linux: deb,appimage
|
||||
```
|
||||
|
||||
|
||||
### Versioning
|
||||
|
||||
The workspace shares a single version, defined in the root [`Cargo.toml`](./Cargo.toml) under `[workspace.package]`.
|
||||
|
||||
|
||||
## Code signing
|
||||
|
||||
This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=github&utm_campaign=0install), and a certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=github&utm_campaign=0install). We thank them very much to their contributions to OSS software!
|
||||
|
|
|
|||
43
distribution/copr/oneclient-bin.spec
Normal file
43
distribution/copr/oneclient-bin.spec
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
%global debug_package %{nil}
|
||||
%global __strip /bin/true
|
||||
%global _appname OneClient
|
||||
%global _rpmfile %{_appname}_%{version}_linux_x86_64.rpm
|
||||
|
||||
Name: oneclient-bin
|
||||
Version: 2.0.1
|
||||
Release: 1%{?dist}
|
||||
Summary: Next-generation open source Minecraft launcher (prebuilt)
|
||||
|
||||
License: GPL-3.0-only
|
||||
URL: https://github.com/Polyfrost/OneLauncher
|
||||
Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile}
|
||||
|
||||
ExclusiveArch: x86_64
|
||||
BuildRequires: bsdtar
|
||||
Requires: gtk3
|
||||
Requires: dbus-libs
|
||||
Provides: oneclient = %{version}-%{release}
|
||||
|
||||
%description
|
||||
OneClient is a next-generation open source Minecraft launcher.
|
||||
This package ships the prebuilt binaries from the upstream release.
|
||||
|
||||
%prep
|
||||
%setup -q -c -T
|
||||
# Repackages the upstream release rpm; bsdtar reads its cpio payload directly.
|
||||
bsdtar -xf %{SOURCE0}
|
||||
|
||||
%install
|
||||
mkdir -p %{buildroot}
|
||||
cp -a usr %{buildroot}/
|
||||
ln -s oneclient_app %{buildroot}%{_bindir}/oneclient
|
||||
|
||||
%files
|
||||
%{_bindir}/oneclient_app
|
||||
%{_bindir}/oneclient
|
||||
%{_datadir}/applications/*.desktop
|
||||
%{_datadir}/icons/hicolor/*/apps/*
|
||||
|
||||
%changelog
|
||||
* Wed Sep 09 2026 Polyfrost <contact@atmofrost.org> - 2.0.1-1
|
||||
- See upstream release notes.
|
||||
|
|
@ -159,7 +159,7 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template"
|
|||
auto-req = "builtin"
|
||||
assets = [
|
||||
{ source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" },
|
||||
{ source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" },
|
||||
{ source = "target/release/oneclient_app.desktop", dest = "/usr/share/applications/oneclient_app.desktop", mode = "644" },
|
||||
{ source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" },
|
||||
{ source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" },
|
||||
{ source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" },
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 619 KiB After Width: | Height: | Size: 390 KiB |
4
packages/oneclient_app/assets/icons/icon-logo.svg
Normal file
4
packages/oneclient_app/assets/icons/icon-logo.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.4179 7.49976H46.4793C37.369 7.49976 28.9508 12.36 24.3956 20.2498L6.61807 51.0414C6.00926 52.0959 5.48182 53.1831 5.03576 54.2942L30.2924 49.6414C40.4908 47.7626 48.4706 39.7829 50.3494 29.5844L54.4179 7.49976ZM5 73.199C5.45356 74.3413 5.99291 75.4586 6.61807 76.5414L24.3956 107.333C28.9508 115.223 37.369 120.083 46.4793 120.083H54.4331L50.3494 97.9154C48.4706 87.717 40.4908 79.7372 30.2924 77.8585L5 73.199ZM74.4827 120.083H82.0344C91.1447 120.083 99.5629 115.223 104.118 107.333L121.896 76.5414C122.506 75.4839 123.035 74.3935 123.482 73.279L98.6234 77.8585C88.425 79.7372 80.4452 87.717 78.5664 97.9154L74.4827 120.083ZM123.446 54.2142C123.006 53.1309 122.49 52.0706 121.896 51.0414L104.118 20.2498C99.5629 12.36 91.1447 7.49976 82.0344 7.49976H74.498L78.5664 29.5844C80.4452 39.7828 88.425 47.7626 98.6234 49.6414L123.446 54.2142Z" fill="currentColor"/>
|
||||
<path d="M64.458 24.5L67.962 45.0947C69.2479 52.6528 75.0625 58.6345 82.5811 60.1343L100.708 63.75L82.5811 67.3657C75.0625 68.8655 69.2479 74.8472 67.962 82.4053L64.458 103L60.954 82.4053C59.6681 74.8472 53.8535 68.8655 46.335 67.3657L28.208 63.75L46.335 60.1343C53.8536 58.6345 59.6681 52.6528 60.954 45.0947L64.458 24.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
packages/oneclient_app/assets/icons/line-chart-up-01.svg
Normal file
3
packages/oneclient_app/assets/icons/line-chart-up-01.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21H4.6c-.56 0-.84 0-1.054-.109a1 1 0 0 1-.437-.437C3 20.2401 3 19.9601 3 19.4V3m17 5-3.9189 4.1827c-.1485.1585-.2227.2377-.3123.2787a.5.5 0 0 1-.2528.0433c-.0981-.0089-.1945-.0589-.3873-.159l-3.2574-1.6914c-.1928-.1001-.2892-.1501-.3873-.159a.5.5 0 0 0-.2528.0433c-.0896.041-.1638.1202-.3123.2787L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
|
|
@ -6,14 +6,14 @@ use freya::{
|
|||
use oneclient_auth::MinecraftAccount;
|
||||
|
||||
use crate::{
|
||||
ui::{divider},
|
||||
Route,
|
||||
components::{Avatar, Button, Icon, IconType, OverlayPopup},
|
||||
hooks::{
|
||||
try_accounts, try_default_account, use_account_switcher_open, use_accounts,
|
||||
use_current_account, use_dispatch,
|
||||
SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open,
|
||||
use_accounts, use_current_account, use_dispatch, use_set_default_account,
|
||||
},
|
||||
theme::colors,
|
||||
ui::divider,
|
||||
};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
@ -90,7 +90,8 @@ impl Component for AccountPanel {
|
|||
.spacing(8.)
|
||||
.opacity(progress)
|
||||
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
|
||||
.background(colors::page_elevated())
|
||||
.background(colors::page_elevated().with_a(220))
|
||||
.blur(12.)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.border(
|
||||
Border::new()
|
||||
|
|
@ -156,19 +157,15 @@ impl Component for AccountRow {
|
|||
let active = self.active;
|
||||
|
||||
let dispatch = use_dispatch();
|
||||
let set_default = use_set_default_account();
|
||||
let mut hovered = use_state(|| false);
|
||||
|
||||
let switch = move |_| {
|
||||
if active {
|
||||
return;
|
||||
}
|
||||
set_default.mutate(SetDefaultAccountKeys { id: Some(id) });
|
||||
dispatch.close_account_switcher();
|
||||
spawn(async move {
|
||||
if let Ok(state) = crate::launcher::state() {
|
||||
let _ = state.auth.set_default_account(Some(id)).await;
|
||||
crate::hooks::invalidate_auth_queries(Some(id)).await;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rect()
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ fn cluster_settings_button(cluster_id: i64) -> impl IntoElement {
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Cluster settings")
|
||||
.on_press(move |_| {
|
||||
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::borrow::Cow;
|
|||
use freya::prelude::*;
|
||||
|
||||
use crate::{
|
||||
components::{Icon, IconTint, IconType},
|
||||
components::{Icon, IconTint, IconType, TooltipPlacement, tooltip::use_tooltip_anchor},
|
||||
theme::colors,
|
||||
};
|
||||
|
||||
|
|
@ -53,6 +53,8 @@ pub struct Button {
|
|||
enabled: bool,
|
||||
focusable: bool,
|
||||
alt: Option<Box<str>>,
|
||||
tooltip: Option<Box<str>>,
|
||||
tooltip_placement: TooltipPlacement,
|
||||
on_press: Option<EventHandler<Event<PressEventData>>>,
|
||||
|
||||
elements: Vec<Element>,
|
||||
|
|
@ -111,6 +113,8 @@ impl Button {
|
|||
enabled: true,
|
||||
focusable: true,
|
||||
alt: None,
|
||||
tooltip: None,
|
||||
tooltip_placement: TooltipPlacement::default(),
|
||||
on_press: None,
|
||||
elements: Vec::new(),
|
||||
key: DiffKey::None,
|
||||
|
|
@ -203,6 +207,20 @@ impl Button {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, tooltip: impl Into<Box<str>>) -> Self {
|
||||
let tooltip = tooltip.into();
|
||||
if self.alt.is_none() {
|
||||
self.alt = Some(tooltip.clone());
|
||||
}
|
||||
self.tooltip = Some(tooltip);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip_placement(mut self, placement: TooltipPlacement) -> Self {
|
||||
self.tooltip_placement = placement;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self {
|
||||
self.cursor_icon = cursor_icon;
|
||||
self
|
||||
|
|
@ -247,6 +265,8 @@ impl Component for Button {
|
|||
let a11y_id = use_a11y();
|
||||
let focus = use_focus(a11y_id);
|
||||
|
||||
let tooltip = use_tooltip_anchor(self.tooltip.clone(), self.tooltip_placement);
|
||||
|
||||
let enabled = use_reactive(&self.enabled);
|
||||
|
||||
let cursor_icon = self.cursor_icon;
|
||||
|
|
@ -335,7 +355,7 @@ impl Component for Button {
|
|||
rect = rect.cursor(CursorIcon::NotAllowed);
|
||||
}
|
||||
|
||||
rect
|
||||
tooltip.attach(rect)
|
||||
}
|
||||
|
||||
fn render_key(&self) -> DiffKey {
|
||||
|
|
@ -444,6 +464,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button {
|
|||
Button::new()
|
||||
.secondary()
|
||||
.icon()
|
||||
.tooltip("Open folder")
|
||||
.on_press(move |_| {
|
||||
std::fs::create_dir_all(&folder).ok();
|
||||
crate::platform::open_path(&folder.to_string_lossy());
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect};
|
|||
use freya::prelude::*;
|
||||
|
||||
use crate::theme::colors;
|
||||
use crate::utils::format_duration_hm;
|
||||
use crate::utils::format_duration;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ValueUnit {
|
||||
|
|
@ -13,7 +13,7 @@ pub enum ValueUnit {
|
|||
impl ValueUnit {
|
||||
pub fn format(self, value: i64) -> String {
|
||||
match self {
|
||||
ValueUnit::Duration => format_duration_hm(value),
|
||||
ValueUnit::Duration => format_duration(value),
|
||||
ValueUnit::Count => format!("{value}×"),
|
||||
}
|
||||
}
|
||||
|
|
@ -264,6 +264,7 @@ fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option<usize> {
|
|||
pub struct BarChart {
|
||||
values: Vec<i64>,
|
||||
labels: Vec<String>,
|
||||
readout_labels: Option<Vec<String>>,
|
||||
highlight: Option<usize>,
|
||||
unit: ValueUnit,
|
||||
height: f32,
|
||||
|
|
@ -275,6 +276,7 @@ impl BarChart {
|
|||
Self {
|
||||
values,
|
||||
labels,
|
||||
readout_labels: None,
|
||||
highlight: None,
|
||||
unit: ValueUnit::Duration,
|
||||
height: DEFAULT_HEIGHT,
|
||||
|
|
@ -282,6 +284,11 @@ impl BarChart {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn readout_labels(mut self, labels: Vec<String>) -> Self {
|
||||
self.readout_labels = Some(labels);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn highlight(mut self, highlight: Option<usize>) -> Self {
|
||||
self.highlight = highlight;
|
||||
self
|
||||
|
|
@ -304,7 +311,9 @@ impl BarChart {
|
|||
}
|
||||
|
||||
fn readout_name(&self, i: usize) -> String {
|
||||
self.labels
|
||||
self.readout_labels
|
||||
.as_ref()
|
||||
.unwrap_or(&self.labels)
|
||||
.get(i)
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
|
|
@ -343,13 +352,9 @@ impl Component for BarChart {
|
|||
for (i, &v) in self.values.iter().enumerate() {
|
||||
let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 };
|
||||
let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 };
|
||||
let is_active = Some(i) == focus;
|
||||
let color = if is_active {
|
||||
colors::brand()
|
||||
} else if active.is_some() {
|
||||
colors::component_bg_hover().with_a(150)
|
||||
} else {
|
||||
colors::component_bg_hover()
|
||||
let color = match active {
|
||||
Some(a) if a != i => colors::component_bg_hover(),
|
||||
_ => colors::brand(),
|
||||
};
|
||||
|
||||
bars = bars.child(
|
||||
|
|
@ -381,14 +386,13 @@ impl Component for BarChart {
|
|||
label_row = label_row.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.horizontal()
|
||||
.main_align(Alignment::Center)
|
||||
.child(
|
||||
label()
|
||||
.text(if shown { text.clone() } else { String::new() })
|
||||
.font_size(10.)
|
||||
.max_lines(1)
|
||||
.width(Size::fill())
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
);
|
||||
|
|
@ -455,7 +459,7 @@ fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize {
|
|||
.unwrap_or(0)
|
||||
.max(1);
|
||||
// ~6.2px per char at font_size 10
|
||||
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0;
|
||||
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 12.0;
|
||||
let max_labels = (plot_width / slot_px).floor().max(1.0) as usize;
|
||||
|
||||
if n <= max_labels {
|
||||
|
|
@ -487,7 +491,7 @@ fn gridlines(height: f32) -> Element {
|
|||
|
||||
fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element {
|
||||
let fmt = |v: i64| match unit {
|
||||
ValueUnit::Duration => format_duration_hm(v),
|
||||
ValueUnit::Duration => format_duration(v),
|
||||
ValueUnit::Count => v.to_string(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use freya::prelude::*;
|
|||
|
||||
use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::clamp_to_window;
|
||||
|
||||
const MENU_BG: Color = Color::from_rgb(25, 32, 38);
|
||||
const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255);
|
||||
|
|
@ -10,8 +11,6 @@ const MENU_DANGER: Color = Color::from_rgb(242, 84, 90);
|
|||
|
||||
/// Panel padding and border on both axes, which the measured list sits inside of
|
||||
const PANEL_INSET: f32 = 14.;
|
||||
/// Gap the clamped menu keeps from the window edges
|
||||
const EDGE_MARGIN: f32 = 8.;
|
||||
|
||||
enum Entry {
|
||||
Action {
|
||||
|
|
@ -215,26 +214,6 @@ impl Component for ContextMenu {
|
|||
}
|
||||
}
|
||||
|
||||
/// `root_size` is physical while the press position is logical, so it has to be
|
||||
/// scaled down before the two are compared
|
||||
fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) {
|
||||
let platform = Platform::get();
|
||||
let scale = *platform.scale_factor.peek() as f32;
|
||||
if scale <= 0. {
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
let window = *platform.root_size.peek();
|
||||
let clamp = |pos: f32, len: f32, limit: f32| {
|
||||
pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN))
|
||||
};
|
||||
|
||||
(
|
||||
clamp(x, menu_width, window.width / scale),
|
||||
clamp(y, menu_height, window.height / scale),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ContextMenuRow {
|
||||
icon: IconType,
|
||||
|
|
|
|||
410
packages/oneclient_app/src/components/date_range_picker.rs
Normal file
410
packages/oneclient_app/src/components/date_range_picker.rs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use freya::prelude::*;
|
||||
|
||||
use crate::components::{Icon, IconType, OverlayPopup};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::{border_all_color, clamp_to_window};
|
||||
use crate::utils::format_day;
|
||||
|
||||
const CELL: f32 = 30.;
|
||||
const CELL_GAP: f32 = 2.;
|
||||
const WEEKS: u32 = 6;
|
||||
const POPUP_WIDTH: f32 = 7. * CELL + 6. * CELL_GAP + 24. + 2.;
|
||||
const POPUP_OFFSET: f32 = 4.;
|
||||
|
||||
pub type DateRange = (NaiveDate, NaiveDate);
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct DateRangePicker {
|
||||
range: State<Option<DateRange>>,
|
||||
bounds: DateRange,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
impl DateRangePicker {
|
||||
pub fn new(range: State<Option<DateRange>>, bounds: DateRange) -> Self {
|
||||
Self {
|
||||
range,
|
||||
bounds,
|
||||
height: 30.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: f32) -> Self {
|
||||
self.height = height;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DateRangePicker {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut range = self.range;
|
||||
let (min, max) = self.bounds;
|
||||
|
||||
let mut open = use_state(|| false);
|
||||
let mut anchor = use_state(|| Option::<NaiveDate>::None);
|
||||
let mut month = use_state(move || first_of_month(max));
|
||||
let mut hovering = use_state(|| false);
|
||||
let mut trigger = use_state(Area::zero);
|
||||
let mut panel_height = use_state(|| 0f32);
|
||||
|
||||
let is_open = open();
|
||||
|
||||
let selected = *range.read();
|
||||
let pending = *anchor.read();
|
||||
let shown = *month.read();
|
||||
|
||||
let can_prev = shown > first_of_month(min);
|
||||
let can_next = shown < first_of_month(max);
|
||||
|
||||
let pick = move |day: NaiveDate| {
|
||||
let started = *anchor.peek();
|
||||
match started {
|
||||
Some(start) => {
|
||||
range.set(Some((start.min(day), start.max(day))));
|
||||
anchor.set(None);
|
||||
open.set(false);
|
||||
}
|
||||
None => anchor.set(Some(day)),
|
||||
}
|
||||
};
|
||||
|
||||
let trigger_text = match selected {
|
||||
Some((a, b)) if a == b => format_day(a),
|
||||
Some((a, b)) => format!("{} – {}", format_day(a), format_day(b)),
|
||||
None => "Pick dates".to_string(),
|
||||
};
|
||||
|
||||
rect()
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.height(Size::px(self.height))
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(6.)
|
||||
.padding(Gaps::new_symmetric(0., 10.))
|
||||
.corner_radius(CornerRadius::new_all(9.))
|
||||
.background(if hovering() {
|
||||
colors::component_bg_hover()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
})
|
||||
.border(border_all_color(
|
||||
1.,
|
||||
if selected.is_some() {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_border()
|
||||
},
|
||||
))
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
.on_pointer_enter(move |_| hovering.set(true))
|
||||
.on_pointer_leave(move |_| hovering.set(false))
|
||||
.on_sized(move |e: Event<SizedEventData>| {
|
||||
trigger.set_if_modified(e.area);
|
||||
})
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
anchor.set_if_modified(None);
|
||||
open.toggle();
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconType::Calendar)
|
||||
.size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(trigger_text)
|
||||
.font_size(12.)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
)
|
||||
.maybe_child(is_open.then(|| {
|
||||
let area = *trigger.read();
|
||||
let height = *panel_height.read();
|
||||
let (x, y) = clamp_to_window(
|
||||
area.max_x() - POPUP_WIDTH,
|
||||
area.max_y() + POPUP_OFFSET,
|
||||
POPUP_WIDTH,
|
||||
height,
|
||||
);
|
||||
|
||||
let panel = rect()
|
||||
.width(Size::px(POPUP_WIDTH))
|
||||
.vertical()
|
||||
.spacing(8.)
|
||||
.padding(12.)
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.background(colors::page_elevated())
|
||||
.on_sized(move |e: Event<SizedEventData>| {
|
||||
panel_height.set_if_modified(e.area.height());
|
||||
})
|
||||
.opacity(if height > 0. { 1. } else { 0. })
|
||||
.child(month_header(shown, can_prev, can_next, move |step| {
|
||||
let next = if step < 0 {
|
||||
shown.checked_sub_months(Months::new(1))
|
||||
} else {
|
||||
shown.checked_add_months(Months::new(1))
|
||||
};
|
||||
if let Some(next) = next {
|
||||
month.set(next.clamp(first_of_month(min), first_of_month(max)));
|
||||
}
|
||||
}))
|
||||
.child(weekday_header())
|
||||
.child(month_grid(shown, min, max, selected, pending, pick))
|
||||
.child(footer(selected.is_some(), pending, move || {
|
||||
range.set(None);
|
||||
anchor.set(None);
|
||||
open.set(false);
|
||||
}));
|
||||
|
||||
OverlayPopup::new()
|
||||
.backdrop(false)
|
||||
.position(Position::new_global().top(y).left(x))
|
||||
.on_close(move |()| {
|
||||
anchor.set(None);
|
||||
open.set(false);
|
||||
})
|
||||
.child(panel.into_element())
|
||||
.into_element()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn first_of_month(date: NaiveDate) -> NaiveDate {
|
||||
date.with_day(1).unwrap_or(date)
|
||||
}
|
||||
|
||||
fn month_header(
|
||||
shown: NaiveDate,
|
||||
can_prev: bool,
|
||||
can_next: bool,
|
||||
step: impl FnMut(i32) + Clone + 'static,
|
||||
) -> Element {
|
||||
let back = step.clone();
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.child(arrow(IconType::ChevronsLeft, can_prev, back, -1))
|
||||
.child(
|
||||
label()
|
||||
.text(format!("{} {}", month_name(shown.month0()), shown.year()))
|
||||
.font_size(13.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.width(Size::flex(1.0))
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(arrow(IconType::ChevronsRight, can_next, step, 1))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn arrow(
|
||||
icon: IconType,
|
||||
enabled: bool,
|
||||
mut step: impl FnMut(i32) + 'static,
|
||||
delta: i32,
|
||||
) -> Element {
|
||||
rect()
|
||||
.width(Size::px(24.))
|
||||
.height(Size::px(24.))
|
||||
.center()
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(colors::component_bg())
|
||||
.maybe(enabled, |el| {
|
||||
el.cursor(CursorIcon::Pointer)
|
||||
.on_press(move |_| step(delta))
|
||||
})
|
||||
.child(Icon::new(icon).size(12.).color(if enabled {
|
||||
colors::fg_secondary()
|
||||
} else {
|
||||
colors::fg_secondary().with_a(70)
|
||||
}))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn weekday_header() -> Element {
|
||||
let mut row = rect().horizontal().spacing(CELL_GAP);
|
||||
for day in ["M", "T", "W", "T", "F", "S", "S"] {
|
||||
row = row.child(
|
||||
rect().width(Size::px(CELL)).center().child(
|
||||
label()
|
||||
.text(day.to_string())
|
||||
.font_size(10.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
);
|
||||
}
|
||||
row.into_element()
|
||||
}
|
||||
|
||||
fn month_grid(
|
||||
shown: NaiveDate,
|
||||
min: NaiveDate,
|
||||
max: NaiveDate,
|
||||
selected: Option<DateRange>,
|
||||
pending: Option<NaiveDate>,
|
||||
pick: impl FnMut(NaiveDate) + Clone + 'static,
|
||||
) -> Element {
|
||||
let lead = shown.weekday().num_days_from_monday();
|
||||
let first_cell = shown
|
||||
.checked_sub_days(Days::new(u64::from(lead)))
|
||||
.unwrap_or(shown);
|
||||
|
||||
let mut grid = rect().vertical().spacing(CELL_GAP);
|
||||
for week in 0..WEEKS {
|
||||
let mut row = rect().horizontal().spacing(CELL_GAP);
|
||||
for weekday in 0..7u32 {
|
||||
let offset = u64::from(week * 7 + weekday);
|
||||
let Some(day) = first_cell.checked_add_days(Days::new(offset)) else {
|
||||
continue;
|
||||
};
|
||||
row = row.child(DayCell {
|
||||
day,
|
||||
outside: day.month0() != shown.month0(),
|
||||
enabled: (min..=max).contains(&day),
|
||||
in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)),
|
||||
edge: selected.is_some_and(|(a, b)| day == a || day == b)
|
||||
|| pending == Some(day),
|
||||
pick: pick.clone(),
|
||||
});
|
||||
}
|
||||
grid = grid.child(row);
|
||||
}
|
||||
grid.into_element()
|
||||
}
|
||||
|
||||
struct DayCell<F: FnMut(NaiveDate) + Clone + 'static> {
|
||||
day: NaiveDate,
|
||||
outside: bool,
|
||||
enabled: bool,
|
||||
in_range: bool,
|
||||
edge: bool,
|
||||
pick: F,
|
||||
}
|
||||
|
||||
impl<F: FnMut(NaiveDate) + Clone + 'static> PartialEq for DayCell<F> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.day == other.day
|
||||
&& self.outside == other.outside
|
||||
&& self.enabled == other.enabled
|
||||
&& self.in_range == other.in_range
|
||||
&& self.edge == other.edge
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: FnMut(NaiveDate) + Clone + 'static> Component for DayCell<F> {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut hovering = use_state(|| false);
|
||||
|
||||
let day = self.day;
|
||||
let enabled = self.enabled;
|
||||
let mut pick = self.pick.clone();
|
||||
|
||||
let background = if self.edge {
|
||||
colors::brand()
|
||||
} else if self.in_range {
|
||||
colors::brand().with_a(50)
|
||||
} else if hovering() && enabled {
|
||||
colors::component_bg_hover()
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
};
|
||||
|
||||
let color = if self.edge {
|
||||
colors::fg_primary()
|
||||
} else if !enabled || self.outside {
|
||||
colors::fg_secondary().with_a(90)
|
||||
} else {
|
||||
colors::fg_primary()
|
||||
};
|
||||
|
||||
rect()
|
||||
.width(Size::px(CELL))
|
||||
.height(Size::px(CELL - 2.))
|
||||
.center()
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(background)
|
||||
.maybe(enabled, |el| {
|
||||
el.cursor(CursorIcon::Pointer)
|
||||
.on_pointer_enter(move |_| hovering.set(true))
|
||||
.on_pointer_leave(move |_| hovering.set(false))
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
pick(day);
|
||||
})
|
||||
})
|
||||
.child(
|
||||
label()
|
||||
.text(day.day().to_string())
|
||||
.font_size(11.)
|
||||
.color(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn footer(has_range: bool, pending: Option<NaiveDate>, mut clear: impl FnMut() + 'static) -> Element {
|
||||
let hint = match pending {
|
||||
Some(day) => format!("From {} — pick an end date", format_day(day)),
|
||||
None => "Click a start date, then an end date".to_string(),
|
||||
};
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
label()
|
||||
.text(hint)
|
||||
.font_size(10.)
|
||||
.max_lines(2)
|
||||
.width(Size::flex(1.0))
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.maybe_child(has_range.then(|| {
|
||||
rect()
|
||||
.padding(Gaps::new_symmetric(4., 8.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(colors::component_bg())
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
clear();
|
||||
})
|
||||
.child(
|
||||
label()
|
||||
.text("Reset")
|
||||
.font_size(11.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn month_name(month0: u32) -> &'static str {
|
||||
const MONTHS: [&str; 12] = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
MONTHS[(month0 as usize).min(11)]
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use oneclient_common::{VersionKey, parse_mc_version};
|
|||
use crate::AppAssets;
|
||||
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
|
||||
use crate::layout::HOME_BACKGROUND_ASSET;
|
||||
use crate::ui::ImageFallbackExt;
|
||||
|
||||
/// The size cluster cards request so it is usually already cached when anything else wants it
|
||||
pub const ART_PREVIEW_EDGE: u32 = 512;
|
||||
|
|
@ -121,6 +122,7 @@ impl Component for DynamicArt {
|
|||
.height(Size::fill())
|
||||
.aspect_ratio(AspectRatio::Max)
|
||||
.image_cover(ImageCover::Center)
|
||||
.fallback(rect().width(Size::fill()).height(Size::fill()))
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use oneclient_java::JAVA_CHOICE_DOWNLOAD;
|
|||
|
||||
use crate::components::{Button, OverlayPopup};
|
||||
use crate::hooks::{use_dispatch, use_notifications_snapshot};
|
||||
use crate::microsoft_java::MICROSOFT_JAVA_CHOICE_INSTALL;
|
||||
use crate::notifications::PendingPromptView;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
|
@ -15,7 +16,9 @@ use crate::updater::UPDATE_CHOICE_INSTALL;
|
|||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
|
||||
fn claimed_elsewhere(prompt: &PendingPromptView) -> bool {
|
||||
prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL)
|
||||
prompt.has_choice(JAVA_CHOICE_DOWNLOAD)
|
||||
|| prompt.has_choice(UPDATE_CHOICE_INSTALL)
|
||||
|| prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL)
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
@ -149,15 +152,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_rich_overlays_keep_their_own_prompts() {
|
||||
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
|
||||
fn the_rich_overlays_keep_their_own_prompts() {
|
||||
for id in [
|
||||
JAVA_CHOICE_DOWNLOAD,
|
||||
"Download",
|
||||
)])));
|
||||
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
|
||||
UPDATE_CHOICE_INSTALL,
|
||||
"Download",
|
||||
)])));
|
||||
MICROSOFT_JAVA_CHOICE_INSTALL,
|
||||
] {
|
||||
assert!(
|
||||
claimed_elsewhere(&prompt(vec![Choice::primary(id, "Download")])),
|
||||
"{id} has its own overlay"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
use freya::prelude::*;
|
||||
|
||||
use super::Dropdown;
|
||||
|
||||
pub const GRID_COLUMNS_MIN: u8 = 1;
|
||||
pub const GRID_COLUMNS_MAX: u8 = 5;
|
||||
|
||||
pub fn resolved_columns(columns: u8) -> usize {
|
||||
columns.clamp(GRID_COLUMNS_MIN, GRID_COLUMNS_MAX) as usize
|
||||
}
|
||||
|
||||
pub fn grid_columns_picker(mut columns: State<u8>, height: f32) -> impl IntoElement {
|
||||
let options: Vec<String> = (GRID_COLUMNS_MIN..=GRID_COLUMNS_MAX)
|
||||
.map(|n| n.to_string())
|
||||
.collect();
|
||||
let selected = resolved_columns(*columns.read()).to_string();
|
||||
|
||||
Dropdown::new(selected, options)
|
||||
.width(Size::px(60.))
|
||||
.height(Size::px(height))
|
||||
.on_select(move |idx: usize| columns.set(GRID_COLUMNS_MIN + idx as u8))
|
||||
}
|
||||
|
||||
pub fn cell_width(total: f32, cols: usize, gap: f32) -> f32 {
|
||||
let cols = cols.max(1);
|
||||
((total - (cols - 1) as f32 * gap) / cols as f32).max(0.)
|
||||
}
|
||||
|
|
@ -115,9 +115,11 @@ pub enum IconType {
|
|||
FolderDownload,
|
||||
Globe01,
|
||||
HelpCircle,
|
||||
IconLogo,
|
||||
InfoCircle,
|
||||
Key01,
|
||||
LayoutTop,
|
||||
LineChartUp01,
|
||||
Link03,
|
||||
LinkExternal01,
|
||||
Loading02,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::ui::border_all_color;
|
|||
|
||||
fn providers() -> Vec<(JavaVendor, &'static str)> {
|
||||
vec![
|
||||
(JavaVendor::Microsoft, "Microsoft JDK (Fastest/Recommended)"),
|
||||
(JavaVendor::Zulu, "Azul Zulu"),
|
||||
(JavaVendor::Adoptium, "Eclipse Temurin"),
|
||||
(JavaVendor::Corretto, "Amazon Corretto"),
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ use crate::components::{Dropdown, TextInput, validate_memory};
|
|||
use crate::theme::colors;
|
||||
use crate::utils::{format_memory_gb, memory_presets_mb};
|
||||
|
||||
const UNSET_LABEL: &str = "Default";
|
||||
const CUSTOM_LABEL: &str = "Custom";
|
||||
|
||||
/// Presets picker with an input for memory allocation
|
||||
pub fn memory_field(mut memory: State<String>) -> impl IntoElement {
|
||||
pub fn memory_field(
|
||||
mut memory: State<String>,
|
||||
unset_label: &str,
|
||||
placeholder: u32,
|
||||
) -> impl IntoElement {
|
||||
let presets = memory_presets_mb();
|
||||
let selected = match memory.read().trim() {
|
||||
"" => UNSET_LABEL.to_string(),
|
||||
"" => unset_label.to_string(),
|
||||
value => value
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
|
|
@ -20,7 +23,8 @@ pub fn memory_field(mut memory: State<String>) -> impl IntoElement {
|
|||
.unwrap_or_else(|| CUSTOM_LABEL.to_string()),
|
||||
};
|
||||
|
||||
let options: Vec<String> = presets.iter().copied().map(format_memory_gb).collect();
|
||||
let mut options: Vec<String> = vec![unset_label.to_string()];
|
||||
options.extend(presets.iter().copied().map(format_memory_gb));
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -31,7 +35,9 @@ pub fn memory_field(mut memory: State<String>) -> impl IntoElement {
|
|||
.width(Size::px(100.))
|
||||
.height(Size::px(34.))
|
||||
.on_select(move |idx: usize| {
|
||||
if let Some(mb) = presets.get(idx).copied() {
|
||||
if idx == 0 {
|
||||
memory.set(String::new());
|
||||
} else if let Some(mb) = presets.get(idx - 1).copied() {
|
||||
memory.set(mb.to_string());
|
||||
}
|
||||
}),
|
||||
|
|
@ -39,7 +45,7 @@ pub fn memory_field(mut memory: State<String>) -> impl IntoElement {
|
|||
.child(
|
||||
TextInput::new(memory)
|
||||
.width(Size::px(90.))
|
||||
.placeholder(oneclient_common::default_mem_max().to_string())
|
||||
.placeholder(placeholder.to_string())
|
||||
.on_validate(validate_memory)
|
||||
.trailing(
|
||||
label()
|
||||
|
|
|
|||
189
packages/oneclient_app/src/components/microsoft_java_prompt.rs
Normal file
189
packages/oneclient_app/src/components/microsoft_java_prompt.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use freya::prelude::*;
|
||||
use oneclient_events::Answer;
|
||||
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
||||
use crate::hooks::{use_dispatch, use_notifications_snapshot};
|
||||
use crate::microsoft_java::{MICROSOFT_JAVA_CHOICE_INSTALL, MICROSOFT_JAVA_CHOICE_NEVER};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
|
||||
const HOLD_SECONDS: u8 = 3;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct MicrosoftJavaPromptOverlay;
|
||||
|
||||
impl Component for MicrosoftJavaPromptOverlay {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let snapshot = use_notifications_snapshot();
|
||||
let dispatch = use_dispatch();
|
||||
let remaining = use_state(|| HOLD_SECONDS);
|
||||
|
||||
let showing = snapshot
|
||||
.pending_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL));
|
||||
|
||||
use_side_effect_with_deps(&showing, move |&showing| {
|
||||
let mut remaining = remaining;
|
||||
|
||||
if !showing {
|
||||
remaining.set(HOLD_SECONDS);
|
||||
return;
|
||||
}
|
||||
|
||||
spawn(async move {
|
||||
let mut remaining = remaining;
|
||||
for step in (0..HOLD_SECONDS).rev() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Re-armed while this was sleeping so a newer prompt owns the
|
||||
// hold now and this countdown is stale
|
||||
if { *remaining.peek() } != step + 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
remaining.set(step);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let Some(prompt) = snapshot.pending_prompt.clone() else {
|
||||
return rect().into_element();
|
||||
};
|
||||
|
||||
if !prompt.has_choice(MICROSOFT_JAVA_CHOICE_INSTALL) {
|
||||
return rect().into_element();
|
||||
}
|
||||
|
||||
let held = *remaining.read();
|
||||
let locked = held > 0;
|
||||
|
||||
let dismiss_label = prompt
|
||||
.dismiss
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Cancel".to_string());
|
||||
let never_label = prompt
|
||||
.choice(MICROSOFT_JAVA_CHOICE_NEVER)
|
||||
.map(|choice| choice.label.clone())
|
||||
.unwrap_or_else(|| "Don't ask again".to_string());
|
||||
let install_label = prompt
|
||||
.choice(MICROSOFT_JAVA_CHOICE_INSTALL)
|
||||
.map(|choice| choice.label.clone())
|
||||
.unwrap_or_else(|| "Proceed".to_string());
|
||||
|
||||
let close = dispatch.clone();
|
||||
let cancel = dispatch.clone();
|
||||
let never = dispatch.clone();
|
||||
let accept = dispatch.clone();
|
||||
|
||||
OverlayPopup::new()
|
||||
// Closing from the backdrop is a dismissal too so it waits as well
|
||||
.on_close(move |_| {
|
||||
if !locked {
|
||||
close.dismiss_prompt();
|
||||
}
|
||||
})
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.center()
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(440.))
|
||||
.max_width(Size::window_percent(90.))
|
||||
.spacing(14.)
|
||||
.padding(Gaps::new_all(20.))
|
||||
.corner_radius(CornerRadius::new_all(14.))
|
||||
.background(CARD_BG)
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(10.)
|
||||
.child(
|
||||
Icon::new(IconType::DownloadCloud02)
|
||||
.size(20.)
|
||||
.color(colors::brand()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(prompt.title.clone())
|
||||
.font_size(16.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(prompt.question.clone())
|
||||
.font_size(12.)
|
||||
.max_lines(6)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.main_align(Alignment::SpaceBetween)
|
||||
.child(
|
||||
label()
|
||||
.text(if locked {
|
||||
held.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
})
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.disabled(locked)
|
||||
.on_press(move |_| cancel.dismiss_prompt())
|
||||
.text(dismiss_label),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.disabled(locked)
|
||||
.on_press(move |_| {
|
||||
never.answer_prompt(Answer::new(
|
||||
MICROSOFT_JAVA_CHOICE_NEVER,
|
||||
))
|
||||
})
|
||||
.text(never_label),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(move |_| {
|
||||
accept.answer_prompt(Answer::new(
|
||||
MICROSOFT_JAVA_CHOICE_INSTALL,
|
||||
))
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconType::DownloadCloud02)
|
||||
.size(14.),
|
||||
)
|
||||
.text(install_label),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
use crate::state::LoginProgress;
|
||||
use freya::prelude::*;
|
||||
use freya::query::{MutationCapability, MutationStateData, UseMutation};
|
||||
use freya::text_edit::Clipboard;
|
||||
use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession};
|
||||
use oneclient_core::LauncherError;
|
||||
use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession};
|
||||
use crate::state::LoginProgress;
|
||||
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
||||
use crate::hooks::{
|
||||
|
|
@ -185,7 +185,7 @@ pub(crate) fn login_dialog(
|
|||
on_close: impl FnMut() + Clone + 'static,
|
||||
) -> impl IntoElement {
|
||||
let mut close_scrim = on_close.clone();
|
||||
let mut close_cancel = on_close.clone();
|
||||
let mut close_cancel = on_close;
|
||||
OverlayPopup::new()
|
||||
.on_close(move |()| close_scrim())
|
||||
.child(
|
||||
|
|
@ -225,9 +225,10 @@ pub(crate) fn login_dialog(
|
|||
.spacing(18.)
|
||||
.child(browser_dialog_body(auth_url))
|
||||
.child(dialog_divider())
|
||||
.child(device_code_dialog_body(user_code, verification_uri))
|
||||
.child(dialog_divider())
|
||||
.child(offline_mode_dialog_body(on_close.clone()))
|
||||
.child(device_code_dialog_body(
|
||||
user_code,
|
||||
verification_uri,
|
||||
))
|
||||
.child(status_row(status, error))
|
||||
.maybe_child(guidance.map(guidance_block)),
|
||||
),
|
||||
|
|
@ -313,13 +314,17 @@ fn status_row(status: Option<LoginProgress>, error: Option<String>) -> impl Into
|
|||
}
|
||||
|
||||
fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement {
|
||||
let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child(
|
||||
label()
|
||||
.text("What you can do:")
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
);
|
||||
let mut steps = rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(6.)
|
||||
.child(
|
||||
label()
|
||||
.text("What you can do:")
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
);
|
||||
|
||||
for (index, step) in guidance.steps_to_fix.into_iter().enumerate() {
|
||||
steps = steps.child(
|
||||
|
|
@ -438,39 +443,3 @@ fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoE
|
|||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn offline_mode_dialog_body(on_close: impl FnMut() + Clone + 'static) -> impl IntoElement {
|
||||
let on_close_btn = on_close;
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("Or Play Offline (LAN / Offline Servers)")
|
||||
.font_size(13.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(move |_| {
|
||||
let mut close_cb = on_close_btn.clone();
|
||||
spawn(async move {
|
||||
if let Ok(state) = crate::launcher::state() {
|
||||
let res = state.auth.add_offline_account("Player".to_string()).await;
|
||||
if let Ok(acc) = res {
|
||||
let _ = state.auth.set_default_account(Some(acc.id)).await;
|
||||
}
|
||||
}
|
||||
close_cb();
|
||||
});
|
||||
})
|
||||
.text("Play Offline as Player")
|
||||
.into_element(),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,16 @@ mod checkbox;
|
|||
mod cluster_landscape_art;
|
||||
mod cluster_update_popup;
|
||||
mod context_menu;
|
||||
mod date_range_picker;
|
||||
mod optional_mods_popup;
|
||||
mod dropdown;
|
||||
mod dynamic_art;
|
||||
mod file_drop;
|
||||
mod grid_columns;
|
||||
mod icons;
|
||||
mod java_install_manager;
|
||||
mod generic_prompt;
|
||||
mod java_prompt;
|
||||
mod microsoft_java_prompt;
|
||||
mod link_confirm;
|
||||
mod local_image;
|
||||
mod log_viewer;
|
||||
|
|
@ -40,6 +41,7 @@ mod tab_bar;
|
|||
mod text_input;
|
||||
mod toasts;
|
||||
mod toggle;
|
||||
mod tooltip;
|
||||
mod update_prompt;
|
||||
mod version_card;
|
||||
pub mod upload_mclogs;
|
||||
|
|
@ -53,15 +55,16 @@ pub use checkbox::checkbox_labeled;
|
|||
pub use cluster_landscape_art::ClusterLandscapeArt;
|
||||
pub use cluster_update_popup::ClusterUpdatePopup;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use date_range_picker::{DateRange, DateRangePicker};
|
||||
pub use optional_mods_popup::OptionalModsPopup;
|
||||
pub use dropdown::Dropdown;
|
||||
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
|
||||
pub use file_drop::FileDropOverlay;
|
||||
pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns};
|
||||
pub use icons::{Icon, IconTint, IconType};
|
||||
pub use java_install_manager::JavaInstallManager;
|
||||
pub use generic_prompt::GenericPromptOverlay;
|
||||
pub use java_prompt::JavaPromptOverlay;
|
||||
pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay;
|
||||
pub use link_confirm::ConfirmLinkOverlay;
|
||||
pub use local_image::LocalImage;
|
||||
pub use markdown::{Markdown, MarkdownStyle};
|
||||
|
|
@ -90,5 +93,6 @@ pub use tab_bar::{TabBar, TabItem};
|
|||
pub use text_input::{TextInput, validate_memory, validate_number};
|
||||
pub use toasts::Toasts;
|
||||
pub use toggle::{toggle, toggle_controlled};
|
||||
pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips};
|
||||
pub use update_prompt::UpdatePromptOverlay;
|
||||
pub use version_card::VersionCard;
|
||||
|
|
|
|||
|
|
@ -8,20 +8,25 @@ use crate::{
|
|||
Route,
|
||||
components::{Avatar, Icon, IconType},
|
||||
hooks::{
|
||||
settled_or_loading, try_default_account, use_active_cluster_id, use_clusters,
|
||||
use_current_account, use_dispatch, use_notifications_snapshot,
|
||||
settled_or_loading, try_default_account, use_active_cluster_id, use_browser_type,
|
||||
use_clusters, use_current_account, use_dispatch, use_notifications_snapshot,
|
||||
},
|
||||
theme,
|
||||
utils::sort_clusters_for_home,
|
||||
};
|
||||
|
||||
const NAVBAR_INTRO_MS: u64 = 460;
|
||||
const LOGO_HIDE_NAVBAR_W: f32 = 1100.;
|
||||
const NAVBAR_SIDE_PADDING_PX: f32 = 40.;
|
||||
const NAV_LINK_SPACING_PX: f32 = 36.;
|
||||
const COMPACT_LOGO_PX: f32 = 28.;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct Navbar;
|
||||
|
||||
impl Component for Navbar {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut navbar_width = use_state(|| 0f32);
|
||||
let intro = use_animation(|conf| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
AnimNum::new(0., 1.)
|
||||
|
|
@ -32,6 +37,9 @@ impl Component for Navbar {
|
|||
let eased = intro.get().value();
|
||||
let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX;
|
||||
|
||||
let measured = *navbar_width.read();
|
||||
let show_logo = measured <= 0. || measured > LOGO_HIDE_NAVBAR_W;
|
||||
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(theme::NAVBAR_HEIGHT_PX))
|
||||
|
|
@ -44,11 +52,17 @@ impl Component for Navbar {
|
|||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.cross_align(Alignment::Center)
|
||||
.padding(Gaps::new_symmetric(0.0, 40.0))
|
||||
.padding(Gaps::new_symmetric(0.0, NAVBAR_SIDE_PADDING_PX))
|
||||
.offset_y(slide)
|
||||
.opacity(eased)
|
||||
.child(navbar_left())
|
||||
.child(navbar_center())
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let next = event.data().area.width();
|
||||
if (*navbar_width.peek() - next).abs() > 0.5 {
|
||||
navbar_width.set(next);
|
||||
}
|
||||
})
|
||||
.child(navbar_left(show_logo))
|
||||
.child(navbar_center(!show_logo))
|
||||
.child(NavbarRight),
|
||||
)
|
||||
.child(
|
||||
|
|
@ -61,34 +75,60 @@ impl Component for Navbar {
|
|||
}
|
||||
}
|
||||
|
||||
fn navbar_left() -> impl IntoElement {
|
||||
fn navbar_left(show_logo: bool) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::flex(1.0))
|
||||
.width(if show_logo {
|
||||
Size::flex(1.0)
|
||||
} else {
|
||||
Size::auto()
|
||||
})
|
||||
.cross_align(Alignment::Center)
|
||||
.child(navbar_logo())
|
||||
.maybe(!show_logo, |el| {
|
||||
el.padding(Gaps::new(0., NAV_LINK_SPACING_PX, 0., 0.))
|
||||
})
|
||||
.child(if show_logo {
|
||||
NavbarLogo.into_element()
|
||||
} else {
|
||||
Icon::new(IconType::IconLogo)
|
||||
.size(COMPACT_LOGO_PX)
|
||||
.into_element()
|
||||
})
|
||||
}
|
||||
|
||||
fn navbar_logo() -> impl IntoElement {
|
||||
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
|
||||
#[derive(PartialEq)]
|
||||
struct NavbarLogo;
|
||||
|
||||
SvgViewer::new(("logo.svg", bytes.read().cloned()))
|
||||
.show_loader(false)
|
||||
.height(Size::px(44.))
|
||||
.width(Size::px(214.))
|
||||
.color(theme::colors::fg_primary())
|
||||
impl Component for NavbarLogo {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
|
||||
|
||||
SvgViewer::new(("logo.svg", bytes.read().cloned()))
|
||||
.show_loader(false)
|
||||
.height(Size::px(44.))
|
||||
.width(Size::px(214.))
|
||||
.color(theme::colors::fg_primary())
|
||||
}
|
||||
}
|
||||
|
||||
fn navbar_center() -> impl IntoElement {
|
||||
fn navbar_center(is_small: bool) -> impl IntoElement {
|
||||
let route = use_route::<Route>();
|
||||
let browse_target = browse_target();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::flex(1.0))
|
||||
.main_align(Alignment::Center)
|
||||
.main_align(if is_small {
|
||||
Alignment::Start
|
||||
} else {
|
||||
Alignment::Center
|
||||
})
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(36.)
|
||||
.spacing(if is_small {
|
||||
NAV_LINK_SPACING_PX
|
||||
} else {
|
||||
NAV_LINK_SPACING_PX / 2.
|
||||
})
|
||||
.child(NavLink {
|
||||
active: route == Route::Home {},
|
||||
target: NavTarget::Route(Route::Home {}),
|
||||
|
|
@ -115,17 +155,13 @@ fn navbar_center() -> impl IntoElement {
|
|||
target: NavTarget::Route(browse_target),
|
||||
nav_label: "Browse",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: route == Route::Stats {},
|
||||
target: NavTarget::Route(Route::Stats {}),
|
||||
nav_label: "Stats",
|
||||
})
|
||||
}
|
||||
|
||||
/// Falls back active cluster then most recently played then Versions when none exist
|
||||
fn browse_target() -> Route {
|
||||
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
|
||||
let active = *use_active_cluster_id().read();
|
||||
let package_type = use_browser_type().read().clone();
|
||||
|
||||
let cluster_id = active
|
||||
.filter(|id| clusters.iter().any(|cluster| cluster.id == *id))
|
||||
|
|
@ -134,7 +170,7 @@ fn browse_target() -> Route {
|
|||
match cluster_id {
|
||||
Some(cluster_id) => Route::Browser {
|
||||
cluster_id,
|
||||
package_type: "mod".to_string(),
|
||||
package_type,
|
||||
pick_cluster: true,
|
||||
},
|
||||
None => Route::Clusters {},
|
||||
|
|
@ -253,6 +289,10 @@ impl Component for NavbarRight {
|
|||
let _ = RouterContext::get().push(Route::SettingsLauncher {});
|
||||
};
|
||||
|
||||
let open_stats = |_| {
|
||||
let _ = RouterContext::get().push(Route::Stats {});
|
||||
};
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::flex(1.0))
|
||||
|
|
@ -261,17 +301,26 @@ impl Component for NavbarRight {
|
|||
.spacing(8.)
|
||||
.child(
|
||||
super::navbar_button()
|
||||
.tooltip("Stats")
|
||||
.child(Icon::new(IconType::LineChartUp01).size(20.))
|
||||
.on_press(open_stats),
|
||||
)
|
||||
.child(
|
||||
super::navbar_button()
|
||||
.tooltip("Notifications")
|
||||
.child(notification_bell(unread))
|
||||
.on_press(open_notifications),
|
||||
)
|
||||
.child(
|
||||
super::navbar_button()
|
||||
.tooltip("Settings")
|
||||
.child(Icon::new(IconType::Settings02).size(20.))
|
||||
.on_press(open_settings),
|
||||
)
|
||||
.child(
|
||||
super::navbar_button()
|
||||
.padding(0.0)
|
||||
.tooltip("Switch account")
|
||||
.on_press(open_account_switcher)
|
||||
.child(
|
||||
Avatar::new(account_uuid)
|
||||
|
|
|
|||
|
|
@ -44,16 +44,19 @@ pub fn window_controls() -> impl IntoElement {
|
|||
.layer(Layer::OverlayLevel(15))
|
||||
.child(
|
||||
navbar_button()
|
||||
.tooltip("Minimize")
|
||||
.child(Icon::new(IconType::Minus))
|
||||
.on_press(minimize),
|
||||
)
|
||||
.child(
|
||||
navbar_button()
|
||||
.tooltip("Maximize")
|
||||
.child(Icon::new(IconType::Maximize01).size(20.))
|
||||
.on_press(maximize),
|
||||
)
|
||||
.child(
|
||||
navbar_button()
|
||||
.tooltip("Close")
|
||||
.child(Icon::new(IconType::X))
|
||||
.on_press(close),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use crate::{
|
|||
notifications::{InboxEntry, NotificationActionKind},
|
||||
theme::colors,
|
||||
transfer::TransferStats,
|
||||
utils::{format_duration_hms, format_size},
|
||||
utils::{format_durations, format_size},
|
||||
};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
@ -445,7 +445,7 @@ fn transfer_footer(stats: TransferStats) -> Element {
|
|||
let speed = format!("{}/s", format_size(stats.speed_bps as u64));
|
||||
let eta = stats
|
||||
.eta_secs
|
||||
.map(|secs| format!("{} left", format_duration_hms(secs as i64)));
|
||||
.map(|secs| format!("{} left", format_durations(secs as i64)));
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use freya::{
|
|||
use crate::hooks::use_overlay_claim;
|
||||
|
||||
const SCRIM_ALPHA: f32 = 90.;
|
||||
const SCRIM_MIN_ALPHA: u8 = 1;
|
||||
|
||||
/// Overlay level of a top level popup. Its scrim sits two levels below it.
|
||||
pub const OVERLAY_BASE_LEVEL: u8 = 12;
|
||||
|
|
@ -116,7 +117,7 @@ impl Component for OverlayPopup {
|
|||
});
|
||||
|
||||
let scrim_alpha = if self.backdrop { SCRIM_ALPHA } else { 0. };
|
||||
let alpha = (fade.read().value() * scrim_alpha) as u8;
|
||||
let alpha = ((fade.read().value() * scrim_alpha) as u8).max(SCRIM_MIN_ALPHA);
|
||||
|
||||
rect()
|
||||
.layer(Layer::Overlay)
|
||||
|
|
@ -130,7 +131,7 @@ impl Component for OverlayPopup {
|
|||
.height(Size::window_percent(100.))
|
||||
.layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2)))
|
||||
.background(Color::from_argb(alpha, 0, 0, 0))
|
||||
.on_press(move |_| {
|
||||
.on_all_press(move |_| {
|
||||
if let Some(on_close) = scrim_close.as_ref() {
|
||||
on_close.call(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::components::{Icon, IconType, toggle_controlled};
|
|||
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::ui::{ImageFallbackExt, border_all_color};
|
||||
use crate::utils::format_size;
|
||||
|
||||
pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
|
|
@ -268,6 +268,7 @@ pub(crate) fn grid_card(
|
|||
.font_size(14.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(CARD_NAME.with_a(content_alpha)),
|
||||
)
|
||||
|
|
@ -277,6 +278,7 @@ pub(crate) fn grid_card(
|
|||
.text(format!("by {}", item.author))
|
||||
.font_size(10.)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary().with_a(content_alpha)),
|
||||
)
|
||||
|
|
@ -368,6 +370,7 @@ fn package_info(
|
|||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
|
|
@ -376,6 +379,8 @@ fn package_info(
|
|||
.font_size(15.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.max_width(Size::percent(60.))
|
||||
.color(CARD_NAME),
|
||||
)
|
||||
.child(if remote {
|
||||
|
|
@ -391,6 +396,9 @@ fn package_info(
|
|||
label()
|
||||
.text(format!("by {}", item.author))
|
||||
.font_size(10.)
|
||||
.max_lines(1)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
})
|
||||
|
|
@ -400,6 +408,7 @@ fn package_info(
|
|||
.text(item.description.clone())
|
||||
.font_size(11.)
|
||||
.max_lines(2)
|
||||
.text_overflow(TextOverflow::Ellipsis)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
|
|
@ -422,6 +431,7 @@ pub(crate) fn package_icon(
|
|||
.height(Size::px(size))
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.fallback(icon_box(IconType::DotsGrid, size))
|
||||
.into_element(),
|
||||
|
||||
None if icon_url.is_some() => icon_box(IconType::DotsGrid, size),
|
||||
|
|
|
|||
|
|
@ -247,15 +247,14 @@ fn content(
|
|||
}
|
||||
|
||||
let answers = choices.read();
|
||||
let mut chosen = Vec::new();
|
||||
for update in &all {
|
||||
match answers
|
||||
.get(&row_key(update))
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
{
|
||||
RowChoice::Update => {
|
||||
proceed_dispatch.apply_package_update(update.clone());
|
||||
}
|
||||
RowChoice::Update => chosen.push(update.clone()),
|
||||
RowChoice::Skip => {}
|
||||
RowChoice::SkipVersion => proceed_dispatch
|
||||
.skip_package_update(
|
||||
|
|
@ -264,7 +263,7 @@ fn content(
|
|||
),
|
||||
}
|
||||
}
|
||||
proceed_dispatch.close_package_updates();
|
||||
proceed_dispatch.proceed_package_updates(chosen);
|
||||
})
|
||||
.child(Icon::new(IconType::DownloadCloud02).size(15.))
|
||||
.text("Proceed"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use freya::prelude::*;
|
||||
|
||||
use crate::components::{Button, Icon, IconType};
|
||||
use crate::components::{Button, Icon, IconType, TooltipPlacement};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
|
|
@ -52,13 +52,18 @@ impl Component for Pagination {
|
|||
let current = *page.read();
|
||||
let last = total_pages.saturating_sub(1);
|
||||
|
||||
let icon_btn = move |target: usize, available: bool, icon: IconType| {
|
||||
let icon_btn = move |target: usize,
|
||||
available: bool,
|
||||
icon: IconType,
|
||||
tooltip: &'static str| {
|
||||
let mut page = page;
|
||||
let enabled = available && interactive;
|
||||
Button::new()
|
||||
.secondary()
|
||||
.icon()
|
||||
.enabled(enabled)
|
||||
.tooltip(tooltip)
|
||||
.tooltip_placement(TooltipPlacement::Top)
|
||||
.on_press(move |_| page.set(target))
|
||||
.child(Icon::new(icon).size(14.).color(if enabled {
|
||||
colors::fg_primary()
|
||||
|
|
@ -130,11 +135,17 @@ impl Component for Pagination {
|
|||
.main_align(Alignment::Center)
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(6.)
|
||||
.child(icon_btn(0, current > 0, IconType::ChevronsLeft))
|
||||
.child(icon_btn(
|
||||
0,
|
||||
current > 0,
|
||||
IconType::ChevronsLeft,
|
||||
"First page",
|
||||
))
|
||||
.child(icon_btn(
|
||||
current.saturating_sub(1),
|
||||
current > 0,
|
||||
IconType::ArrowLeft,
|
||||
"Previous page",
|
||||
));
|
||||
|
||||
if start > 0 {
|
||||
|
|
@ -155,7 +166,17 @@ impl Component for Pagination {
|
|||
row = row.child(num_btn(last));
|
||||
}
|
||||
|
||||
row.child(icon_btn(current + 1, current < last, IconType::ArrowRight))
|
||||
.child(icon_btn(last, current < last, IconType::ChevronsRight))
|
||||
row.child(icon_btn(
|
||||
current + 1,
|
||||
current < last,
|
||||
IconType::ArrowRight,
|
||||
"Next page",
|
||||
))
|
||||
.child(icon_btn(
|
||||
last,
|
||||
current < last,
|
||||
IconType::ChevronsRight,
|
||||
"Last page",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ fn header_row(
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Close")
|
||||
.on_press(on_close)
|
||||
.child(Icon::new(IconType::XClose).size(18.)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use freya::prelude::*;
|
|||
use crate::theme::colors;
|
||||
|
||||
const LAZY_OVERSCAN: i64 = 3;
|
||||
const SCROLLBAR_GUTTER: f32 = 14.;
|
||||
|
||||
pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 {
|
||||
if delta == 0.0 {
|
||||
|
|
@ -86,6 +87,7 @@ pub struct ScrollArea {
|
|||
padding: Gaps,
|
||||
spacing: f32,
|
||||
show_scrollbar: bool,
|
||||
scrollbar_gutter: bool,
|
||||
horizontal: bool,
|
||||
content_width: f32,
|
||||
stick_bottom: bool,
|
||||
|
|
@ -111,6 +113,7 @@ impl ScrollArea {
|
|||
padding: Gaps::default(),
|
||||
spacing: 0.,
|
||||
show_scrollbar: true,
|
||||
scrollbar_gutter: false,
|
||||
horizontal: false,
|
||||
content_width: 0.,
|
||||
stick_bottom: false,
|
||||
|
|
@ -148,6 +151,11 @@ impl ScrollArea {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn scrollbar_gutter(mut self, gutter: bool) -> Self {
|
||||
self.scrollbar_gutter = gutter;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn horizontal(mut self, content_width: f32) -> Self {
|
||||
self.horizontal = true;
|
||||
self.content_width = content_width;
|
||||
|
|
@ -244,6 +252,7 @@ impl ScrollArea {
|
|||
let spacing = self.spacing;
|
||||
let padding = self.padding;
|
||||
let show_scrollbar = self.show_scrollbar;
|
||||
let scrollbar_gutter = self.scrollbar_gutter;
|
||||
let stick_bottom = self.stick_bottom;
|
||||
let on_user_scroll = self.on_user_scroll.clone();
|
||||
|
||||
|
|
@ -293,10 +302,26 @@ impl ScrollArea {
|
|||
let pressing_v = use_memo(move || drag_start.read().is_some());
|
||||
let pressing_h = use_memo(move || drag_start_x.read().is_some());
|
||||
|
||||
let gutter = if show_v && scrollbar_gutter {
|
||||
SCROLLBAR_GUTTER
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
let padding = if gutter > 0. {
|
||||
Gaps::new(
|
||||
padding.top(),
|
||||
padding.right() + gutter,
|
||||
padding.bottom(),
|
||||
padding.left(),
|
||||
)
|
||||
} else {
|
||||
padding
|
||||
};
|
||||
|
||||
let ctx = ScrollAreaCtx {
|
||||
corrected_x,
|
||||
corrected_y,
|
||||
viewport_w: vp_w,
|
||||
viewport_w: (vp_w - gutter).max(0.),
|
||||
viewport_h: vp_h,
|
||||
viewport_top: vp_top,
|
||||
viewport_left: vp_left,
|
||||
|
|
@ -536,6 +561,7 @@ impl PartialEq for ScrollArea {
|
|||
&& self.padding == other.padding
|
||||
&& self.spacing == other.spacing
|
||||
&& self.show_scrollbar == other.show_scrollbar
|
||||
&& self.scrollbar_gutter == other.scrollbar_gutter
|
||||
&& self.horizontal == other.horizontal
|
||||
&& self.content_width == other.content_width
|
||||
&& self.stick_bottom == other.stick_bottom
|
||||
|
|
|
|||
|
|
@ -65,15 +65,14 @@ impl Component for CurtainFade {
|
|||
});
|
||||
});
|
||||
|
||||
let opacity = fade.get().value();
|
||||
let alpha = fade.get().value().clamp(0., 1.);
|
||||
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.position(Position::new_global().top(0.).left(0.))
|
||||
.layer(Layer::Overlay)
|
||||
.opacity(opacity)
|
||||
.background(colors::page())
|
||||
.background(colors::page().with_a((255. * alpha).round() as u8))
|
||||
.window_drag()
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
|
|||
.text(text.to_string())
|
||||
.font_size(font_size)
|
||||
.font_weight(ACTIVE_WEIGHT)
|
||||
.max_lines(1)
|
||||
.color(Color::TRANSPARENT)
|
||||
.text_align(TextAlign::Center),
|
||||
)
|
||||
|
|
@ -115,6 +116,7 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement {
|
|||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary())
|
||||
.text_align(TextAlign::Center),
|
||||
),
|
||||
|
|
@ -135,23 +137,36 @@ fn count_pill(count: &str, font_size: f32) -> impl IntoElement {
|
|||
)
|
||||
}
|
||||
|
||||
fn min_tab_width(text: &str, font_size: f32) -> f32 {
|
||||
text.chars().count() as f32 * font_size * 0.62
|
||||
}
|
||||
|
||||
impl IntoElement for TabBar {
|
||||
fn into_element(self) -> Element {
|
||||
let font_size = self.font_size;
|
||||
|
||||
let overflows = self.width == Size::auto();
|
||||
let content = if overflows {
|
||||
Content::Normal
|
||||
} else {
|
||||
Content::Fit
|
||||
};
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(self.width)
|
||||
.height(self.height)
|
||||
.spacing(self.spacing)
|
||||
.cross_align(Alignment::Center)
|
||||
.content(Content::Fit)
|
||||
.content(content)
|
||||
.children(self.tabs.into_iter().map(|tab| {
|
||||
let min_width = overflows.then(|| min_tab_width(&tab.label, font_size));
|
||||
TabButton {
|
||||
label: tab.label,
|
||||
active: tab.active,
|
||||
count: tab.count,
|
||||
font_size,
|
||||
min_width,
|
||||
on_press: tab.on_press,
|
||||
}
|
||||
.into_element()
|
||||
|
|
@ -166,6 +181,7 @@ struct TabButton {
|
|||
active: bool,
|
||||
count: Option<String>,
|
||||
font_size: f32,
|
||||
min_width: Option<f32>,
|
||||
on_press: Option<EventHandler<Event<PressEventData>>>,
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +197,8 @@ impl Component for TabButton {
|
|||
let mut el = rect()
|
||||
.vertical()
|
||||
.content(Content::Fit)
|
||||
.cross_align(Alignment::Center)
|
||||
.map(self.min_width, |el, w| el.min_width(Size::px(w)))
|
||||
.a11y_id(a11y_id)
|
||||
.a11y_focusable(true)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use oneclient_events::Level;
|
|||
|
||||
use crate::{
|
||||
ui::{divider},
|
||||
components::{Button, ButtonSize, Icon, IconType, progress_track},
|
||||
components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track},
|
||||
hooks::{use_dispatch, use_notifications_snapshot},
|
||||
motion::use_animations_enabled,
|
||||
notifications::{InboxEntry, NotificationActionKind},
|
||||
|
|
@ -328,6 +328,8 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement {
|
|||
.size(ButtonSize::Icon)
|
||||
.width(Size::px(28.))
|
||||
.height(Size::px(28.))
|
||||
.tooltip("Dismiss")
|
||||
.tooltip_placement(TooltipPlacement::Left)
|
||||
.on_press(move |_| dispatch.dismiss_toast(id))
|
||||
.child(
|
||||
Icon::new(IconType::XClose)
|
||||
|
|
|
|||
315
packages/oneclient_app/src/components/tooltip.rs
Normal file
315
packages/oneclient_app/src/components/tooltip.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
|
||||
use freya::prelude::*;
|
||||
|
||||
use crate::components::OVERLAY_MAX_LEVEL;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::{border_all_color, clamp_to_window, window_logical_size};
|
||||
|
||||
const SHOW_DELAY: Duration = Duration::from_millis(400);
|
||||
const ANCHOR_GAP: f32 = 6.;
|
||||
const MAX_WIDTH: f32 = 260.;
|
||||
const FADE_MS: u64 = 110;
|
||||
const HOVER_SLACK: f32 = 1.;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TooltipPlacement {
|
||||
Top,
|
||||
#[default]
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct AnchorRect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
impl AnchorRect {
|
||||
fn contains(&self, x: f32, y: f32) -> bool {
|
||||
x >= self.x - HOVER_SLACK
|
||||
&& x <= self.x + self.width + HOVER_SLACK
|
||||
&& y >= self.y - HOVER_SLACK
|
||||
&& y <= self.y + self.height + HOVER_SLACK
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct ActiveTooltip {
|
||||
owner: u64,
|
||||
text: Box<str>,
|
||||
anchor: AnchorRect,
|
||||
placement: TooltipPlacement,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tooltips {
|
||||
active: State<Option<ActiveTooltip>>,
|
||||
next_owner: Rc<Cell<u64>>,
|
||||
}
|
||||
|
||||
pub fn use_provide_tooltips() {
|
||||
let active = use_state(|| None::<ActiveTooltip>);
|
||||
let next_owner = use_hook(|| Rc::new(Cell::new(0u64)));
|
||||
use_provide_root_context(move || Tooltips { active, next_owner });
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AnchorState {
|
||||
tooltips: Tooltips,
|
||||
owner: u64,
|
||||
area: Rc<Cell<AnchorRect>>,
|
||||
generation: Rc<Cell<u64>>,
|
||||
text: Box<str>,
|
||||
placement: TooltipPlacement,
|
||||
}
|
||||
|
||||
pub struct TooltipAnchor(Option<AnchorState>);
|
||||
|
||||
pub fn use_tooltip_anchor(text: Option<Box<str>>, placement: TooltipPlacement) -> TooltipAnchor {
|
||||
let tooltips = use_hook(try_consume_root_context::<Tooltips>);
|
||||
let owner = use_hook(|| {
|
||||
tooltips.as_ref().map_or(0, |tooltips| {
|
||||
let owner = tooltips.next_owner.get() + 1;
|
||||
tooltips.next_owner.set(owner);
|
||||
owner
|
||||
})
|
||||
});
|
||||
let area = use_hook(|| Rc::new(Cell::new(AnchorRect::default())));
|
||||
let generation = use_hook(|| Rc::new(Cell::new(0u64)));
|
||||
|
||||
let abandoned = generation.clone();
|
||||
use_drop(move || abandoned.set(abandoned.get() + 1));
|
||||
|
||||
TooltipAnchor(tooltips.zip(text).map(|(tooltips, text)| AnchorState {
|
||||
tooltips,
|
||||
owner,
|
||||
area,
|
||||
generation,
|
||||
text,
|
||||
placement,
|
||||
}))
|
||||
}
|
||||
|
||||
impl TooltipAnchor {
|
||||
pub fn attach(&self, rect: Rect) -> Rect {
|
||||
let Some(state) = self.0.clone() else {
|
||||
return rect;
|
||||
};
|
||||
|
||||
let sized = state.clone();
|
||||
let enter = state.clone();
|
||||
let leave = state.clone();
|
||||
let pressed = state.clone();
|
||||
let scrolled = state;
|
||||
|
||||
rect.on_sized(move |e: Event<SizedEventData>| {
|
||||
let area = e.area;
|
||||
sized.area.set(AnchorRect {
|
||||
x: area.min_x(),
|
||||
y: area.min_y(),
|
||||
width: area.width(),
|
||||
height: area.height(),
|
||||
});
|
||||
})
|
||||
.on_pointer_enter(move |e: Event<PointerEventData>| {
|
||||
let global = e.global_location();
|
||||
let element = e.element_location();
|
||||
|
||||
let mut anchor = enter.area.get();
|
||||
anchor.x = (global.x - element.x) as f32;
|
||||
anchor.y = (global.y - element.y) as f32;
|
||||
|
||||
let generation = enter.generation.get() + 1;
|
||||
enter.generation.set(generation);
|
||||
|
||||
let pending = enter.generation.clone();
|
||||
let mut active = enter.tooltips.active;
|
||||
let tooltip = ActiveTooltip {
|
||||
owner: enter.owner,
|
||||
text: enter.text.clone(),
|
||||
anchor,
|
||||
placement: enter.placement,
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
tokio::time::sleep(SHOW_DELAY).await;
|
||||
if pending.get() == generation {
|
||||
active.set(Some(tooltip));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on_pointer_leave(move |_| hide(&leave))
|
||||
.on_mouse_down(move |_| hide(&pressed))
|
||||
.on_wheel(move |_| hide(&scrolled))
|
||||
}
|
||||
}
|
||||
|
||||
fn hide(state: &AnchorState) {
|
||||
state.generation.set(state.generation.get() + 1);
|
||||
clear_owner(state.tooltips.active, state.owner);
|
||||
}
|
||||
|
||||
fn clear_owner(mut active: State<Option<ActiveTooltip>>, owner: u64) {
|
||||
let shown = active.peek().as_ref().map(|tooltip| tooltip.owner);
|
||||
if shown == Some(owner) {
|
||||
active.set(None);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct TooltipHost;
|
||||
|
||||
impl Component for TooltipHost {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let Some(tooltips) = use_hook(try_consume_root_context::<Tooltips>) else {
|
||||
return rect();
|
||||
};
|
||||
|
||||
let state = tooltips.active;
|
||||
let active = (*state.read()).clone();
|
||||
|
||||
let mut host = rect()
|
||||
.layer(Layer::OverlayLevel(OVERLAY_MAX_LEVEL))
|
||||
.position(Position::new_global().top(0.).left(0.))
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.interactive(false);
|
||||
|
||||
if let Some((owner, anchor)) = active.as_ref().map(|active| (active.owner, active.anchor)) {
|
||||
host = host.on_global_pointer_move(move |e: Event<PointerEventData>| {
|
||||
let cursor = e.global_location();
|
||||
if !anchor.contains(cursor.x as f32, cursor.y as f32) {
|
||||
clear_owner(state, owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
host.maybe_child(active.map(|active| {
|
||||
let owner = active.owner;
|
||||
TooltipBubble {
|
||||
text: active.text,
|
||||
anchor: active.anchor,
|
||||
placement: active.placement,
|
||||
key: DiffKey::None,
|
||||
}
|
||||
.key(owner)
|
||||
.into_element()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct TooltipBubble {
|
||||
text: Box<str>,
|
||||
anchor: AnchorRect,
|
||||
placement: TooltipPlacement,
|
||||
key: DiffKey,
|
||||
}
|
||||
|
||||
impl KeyExt for TooltipBubble {
|
||||
fn write_key(&mut self) -> &mut DiffKey {
|
||||
&mut self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for TooltipBubble {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut size = use_state(Size2D::zero);
|
||||
|
||||
let measured = *size.read();
|
||||
let placed = measured.width > 0.;
|
||||
|
||||
let fade = use_animation(|conf| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
AnimNum::new(0., 1.).time(FADE_MS).ease(Ease::Out)
|
||||
});
|
||||
|
||||
let (x, y) = place(self.anchor, self.placement, measured);
|
||||
|
||||
rect()
|
||||
.position(Position::new_global().top(y).left(x))
|
||||
.interactive(false)
|
||||
.opacity(if placed { fade.read().value() } else { 0. })
|
||||
.max_width(Size::px(MAX_WIDTH))
|
||||
.padding(Gaps::new_symmetric(5., 9.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.on_sized(move |e: Event<SizedEventData>| {
|
||||
let bubble = e.area.size;
|
||||
let previous = *size.peek();
|
||||
if (bubble.width - previous.width).abs() > 0.5
|
||||
|| (bubble.height - previous.height).abs() > 0.5
|
||||
{
|
||||
size.set(bubble);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
label()
|
||||
.text(self.text.to_string())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(2)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_key(&self) -> DiffKey {
|
||||
self.key.clone().or(self.default_key())
|
||||
}
|
||||
}
|
||||
|
||||
fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, f32) {
|
||||
let window = window_logical_size();
|
||||
|
||||
let centered_x = anchor.x + (anchor.width - size.width) / 2.;
|
||||
let centered_y = anchor.y + (anchor.height - size.height) / 2.;
|
||||
|
||||
let above = anchor.y - size.height - ANCHOR_GAP;
|
||||
let below = anchor.y + anchor.height + ANCHOR_GAP;
|
||||
let before = anchor.x - size.width - ANCHOR_GAP;
|
||||
let after = anchor.x + anchor.width + ANCHOR_GAP;
|
||||
|
||||
let fits_above = above >= 0.;
|
||||
let fits_below = below + size.height <= window.height;
|
||||
let fits_before = before >= 0.;
|
||||
let fits_after = after + size.width <= window.width;
|
||||
|
||||
let (x, y) = match placement {
|
||||
TooltipPlacement::Top => (
|
||||
centered_x,
|
||||
if fits_above || !fits_below { above } else { below },
|
||||
),
|
||||
TooltipPlacement::Bottom => (
|
||||
centered_x,
|
||||
if fits_below || !fits_above { below } else { above },
|
||||
),
|
||||
TooltipPlacement::Left => (
|
||||
if fits_before || !fits_after {
|
||||
before
|
||||
} else {
|
||||
after
|
||||
},
|
||||
centered_y,
|
||||
),
|
||||
TooltipPlacement::Right => (
|
||||
if fits_after || !fits_before {
|
||||
after
|
||||
} else {
|
||||
before
|
||||
},
|
||||
centered_y,
|
||||
),
|
||||
};
|
||||
|
||||
clamp_to_window(x, y, size.width, size.height)
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use tokio::sync::mpsc;
|
|||
|
||||
use crate::hooks::PumpSignal;
|
||||
use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView};
|
||||
use crate::state::{AppChannel, AppState, LoginProgress};
|
||||
use crate::state::{AppChannel, AppState, LoginProgress, StorageScanProgress};
|
||||
|
||||
/// Quiet period before log lines are written without it every line wakes the log view
|
||||
const GAME_LOG_FLUSH: Duration = Duration::from_millis(120);
|
||||
|
|
@ -127,6 +127,7 @@ impl EventPump {
|
|||
let mut logs: Vec<(i64, String)> = Vec::new();
|
||||
let mut failed: Option<(i64, String)> = None;
|
||||
let mut login: Option<Option<LoginProgress>> = None;
|
||||
let mut storage_scan: Option<Option<StorageScanProgress>> = None;
|
||||
let mut sync_complete = false;
|
||||
|
||||
for event in batch {
|
||||
|
|
@ -148,7 +149,6 @@ impl EventPump {
|
|||
cluster_id,
|
||||
message,
|
||||
}) => failed = Some((cluster_id, message)),
|
||||
// Lifted out so it never reaches the engine the sign-in modal renders it inline
|
||||
Event::Progress(ProgressEvent::Update {
|
||||
id,
|
||||
ref label,
|
||||
|
|
@ -161,6 +161,18 @@ impl EventPump {
|
|||
total,
|
||||
}));
|
||||
}
|
||||
Event::Progress(ProgressEvent::Update {
|
||||
id,
|
||||
ref label,
|
||||
current,
|
||||
total,
|
||||
}) if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => {
|
||||
storage_scan = Some((current < total).then(|| StorageScanProgress {
|
||||
label: label.clone(),
|
||||
current,
|
||||
total,
|
||||
}));
|
||||
}
|
||||
other => engine_events.push(other),
|
||||
}
|
||||
}
|
||||
|
|
@ -197,6 +209,12 @@ impl EventPump {
|
|||
.microsoft_login = progress;
|
||||
}
|
||||
|
||||
if let Some(progress) = storage_scan {
|
||||
self.station
|
||||
.write_channel(AppChannel::StorageScan)
|
||||
.storage_scan = progress;
|
||||
}
|
||||
|
||||
if !engine_events.is_empty() {
|
||||
folded.touched_engine = true;
|
||||
let mut guard = self.station.write_channel(AppChannel::Notifications);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use freya::prelude::spawn_forever;
|
||||
use freya::radio::RadioStation;
|
||||
use oneclient_cluster::profiles::list_named_profiles;
|
||||
use oneclient_cluster::{
|
||||
ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate,
|
||||
Cluster, ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate,
|
||||
};
|
||||
use oneclient_common::domain::{ContentType, ProviderId};
|
||||
use oneclient_content::packages::{LiveSync, LocalImportReport};
|
||||
|
|
@ -87,6 +88,26 @@ fn plan_launch_updates(
|
|||
}
|
||||
}
|
||||
|
||||
/// Names the clusters that pinned `java_path` to this runtime by hand
|
||||
fn clusters_pinned_to_java(
|
||||
profiles: &[GameSettingsProfile],
|
||||
clusters: &[Cluster],
|
||||
absolute_path: &str,
|
||||
) -> Vec<String> {
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.java_path.as_deref() == Some(absolute_path))
|
||||
.map(|profile| {
|
||||
clusters
|
||||
.iter()
|
||||
.find(|cluster| {
|
||||
cluster.setting_profile_name.as_deref() == Some(profile.name.as_str())
|
||||
})
|
||||
.map_or_else(|| profile.name.clone(), |cluster| cluster.name.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pump alone owns the toast timers so adding or removing a toast has to
|
||||
/// tell it to re-arm hover-pause is a timer property not component state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -275,6 +296,13 @@ impl Actions {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn skip_microsoft_java(&self) {
|
||||
if let Some(updated) = self.mutate_settings(|settings| settings.skip_microsoft_java = true)
|
||||
{
|
||||
self.persist(updated);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_onboarding(&self) {
|
||||
if let Some(updated) = self.mutate_settings(|settings| {
|
||||
settings.seen_onboarding = false;
|
||||
|
|
@ -560,6 +588,29 @@ impl Actions {
|
|||
let path = path.into();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
// Checks if the jdk is pinned to the cluster
|
||||
match (
|
||||
list_named_profiles(&state.services.db).await,
|
||||
state.clusters.list().await,
|
||||
) {
|
||||
(Ok(profiles), Ok(clusters)) => {
|
||||
let pinned = clusters_pinned_to_java(&profiles, &clusters, &path);
|
||||
if !pinned.is_empty() {
|
||||
events
|
||||
.notify("Cannot remove this JDK")
|
||||
.body(format!("It is used by cluster: {}", pinned.join(", ")))
|
||||
.error()
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
}
|
||||
(Err(err), _) | (_, Err(err)) => {
|
||||
tracing::error!("could not check whether the java runtime is in use: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
match state.java.remove_runtime(&path).await {
|
||||
Ok(()) => super::invalidate_java_queries().await,
|
||||
Err(err) => tracing::error!("failed to remove java runtime: {err:#}"),
|
||||
|
|
@ -743,6 +794,7 @@ impl Actions {
|
|||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(&session),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -795,6 +847,12 @@ impl Actions {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn proceed_package_updates(&self, chosen: Vec<oneclient_core::BrowserPackageUpdate>) {
|
||||
self.with_engine(move |state| {
|
||||
state.notifications.proceed_package_updates(chosen)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn close_package_updates(&self) {
|
||||
self.with_engine(|state| state.notifications.close_package_updates());
|
||||
}
|
||||
|
|
@ -1032,7 +1090,7 @@ impl Actions {
|
|||
if install.result.is_ok() {
|
||||
// Before the refresh below so the version list never renders
|
||||
// the moment where both copies read as active
|
||||
if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity(
|
||||
if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(
|
||||
cluster_id,
|
||||
&state.services.content(),
|
||||
)
|
||||
|
|
@ -1162,6 +1220,7 @@ impl Actions {
|
|||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1178,7 +1237,9 @@ impl Actions {
|
|||
}
|
||||
};
|
||||
|
||||
actions.record_bundle_checks([cluster_id]);
|
||||
if result.settled() {
|
||||
actions.record_bundle_checks([cluster_id]);
|
||||
}
|
||||
|
||||
super::invalidate_cluster_queries().await;
|
||||
if let Some(spec) =
|
||||
|
|
@ -1304,7 +1365,18 @@ impl Actions {
|
|||
if synced
|
||||
&& let Ok(clusters) = state.clusters.list().await
|
||||
{
|
||||
actions.record_bundle_checks(clusters.iter().map(|cluster| cluster.id));
|
||||
let unsettled: std::collections::HashSet<i64> = changed
|
||||
.iter()
|
||||
.filter(|(_, result)| !result.settled())
|
||||
.map(|(cluster_id, _)| *cluster_id)
|
||||
.collect();
|
||||
|
||||
actions.record_bundle_checks(
|
||||
clusters
|
||||
.iter()
|
||||
.map(|cluster| cluster.id)
|
||||
.filter(|cluster_id| !unsettled.contains(cluster_id)),
|
||||
);
|
||||
}
|
||||
|
||||
actions
|
||||
|
|
@ -1384,23 +1456,14 @@ impl Actions {
|
|||
return;
|
||||
}
|
||||
|
||||
let applied = match tokio::time::timeout(
|
||||
BUNDLE_APPLY_BUDGET,
|
||||
oneclient_core::apply_bundle_updates(cluster_id, state.bundles.as_ref(), &content),
|
||||
let result = match oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(Instant::now() + BUNDLE_APPLY_BUDGET),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(applied) => applied,
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"bundle updates exceeded their launch budget, launching anyway"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = match applied {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
|
|
@ -1412,7 +1475,14 @@ impl Actions {
|
|||
}
|
||||
};
|
||||
|
||||
if synced {
|
||||
if result.stopped_early {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"bundle updates ran out of their launch budget; the rest go at the next launch"
|
||||
);
|
||||
}
|
||||
|
||||
if synced && result.settled() {
|
||||
self.record_bundle_checks([cluster_id]);
|
||||
}
|
||||
|
||||
|
|
@ -1485,7 +1555,10 @@ impl Actions {
|
|||
else {
|
||||
return;
|
||||
};
|
||||
self.prompt_package_updates(group).await;
|
||||
let chosen = self.prompt_package_updates(group).await;
|
||||
if !chosen.is_empty() {
|
||||
self.apply_updates_for_launch(state, &chosen).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1568,6 +1641,8 @@ impl Actions {
|
|||
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
|
||||
});
|
||||
|
||||
super::invalidate_cluster_queries().await;
|
||||
|
||||
if applied > 0 {
|
||||
super::invalidate_cluster_queries().await;
|
||||
}
|
||||
|
|
@ -1613,7 +1688,10 @@ impl Actions {
|
|||
let _ = wait.await;
|
||||
}
|
||||
|
||||
async fn prompt_package_updates(&self, group: PackageUpdateGroup) {
|
||||
async fn prompt_package_updates(
|
||||
&self,
|
||||
group: PackageUpdateGroup,
|
||||
) -> Vec<oneclient_core::BrowserPackageUpdate> {
|
||||
let (done, wait) = tokio::sync::oneshot::channel();
|
||||
|
||||
self.with_engine(move |state| {
|
||||
|
|
@ -1621,71 +1699,7 @@ impl Actions {
|
|||
state.center_open = false;
|
||||
});
|
||||
|
||||
let _ = wait.await;
|
||||
}
|
||||
|
||||
pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&events,
|
||||
format!("Updating {}", update.display_name),
|
||||
);
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
|
||||
let result = oneclient_core::apply_browser_package_update(
|
||||
&update,
|
||||
Some(&child),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await;
|
||||
|
||||
child.finish();
|
||||
let session_id = session.detach();
|
||||
|
||||
let spec = match &result {
|
||||
Ok(_) => NotificationSpec {
|
||||
title: "Updated".to_string(),
|
||||
body: format!(
|
||||
"{} is now on {}",
|
||||
update.display_name, update.latest_version_name
|
||||
),
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
Err(err) => NotificationSpec {
|
||||
title: "Update failed".to_string(),
|
||||
body: err.to_string(),
|
||||
level: Level::Error,
|
||||
icon: None,
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
actions.with_engine(|app| {
|
||||
app.notifications
|
||||
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
|
||||
});
|
||||
|
||||
// A failed update stays in the list so the user can retry
|
||||
if result.is_ok() {
|
||||
actions.with_engine(|app| {
|
||||
app.notifications
|
||||
.resolve_package_update(update.cluster_id, &update.hash);
|
||||
});
|
||||
super::invalidate_cluster_queries().await;
|
||||
}
|
||||
});
|
||||
wait.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The package stays marked out of date only the modal stops asking and
|
||||
|
|
@ -1748,6 +1762,8 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
|||
return;
|
||||
};
|
||||
|
||||
crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await;
|
||||
|
||||
// Before the game process never after Minecraft reads its mods once at
|
||||
// startup
|
||||
actions
|
||||
|
|
@ -1990,4 +2006,88 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
const JDK_25: &str = r"C:\jdk-25\bin\javaw.exe";
|
||||
|
||||
fn profile(name: &str, java_path: Option<&str>) -> GameSettingsProfile {
|
||||
GameSettingsProfile {
|
||||
name: name.into(),
|
||||
java_path: java_path.map(Into::into),
|
||||
..GameSettingsProfile::default_global_profile()
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster(id: i64, name: &str, profile_name: Option<&str>) -> Cluster {
|
||||
Cluster {
|
||||
id,
|
||||
name: name.into(),
|
||||
folder_name: name.into(),
|
||||
setting_profile_name: profile_name.map(Into::into),
|
||||
mc_version: "26.2".into(),
|
||||
mc_loader: oneclient_common::domain::GameLoader::default(),
|
||||
mc_loader_version: None,
|
||||
stage: ClusterStage::default(),
|
||||
created_at: None,
|
||||
last_played: None,
|
||||
overall_played: Duration::ZERO,
|
||||
linked_modpack_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runtime_nobody_pinned_is_free_to_go() {
|
||||
let profiles = vec![profile("26.2 Fabric", None)];
|
||||
let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))];
|
||||
|
||||
assert!(clusters_pinned_to_java(&profiles, &clusters, JDK_25).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_runtime_is_reported_under_its_cluster_name() {
|
||||
let profiles = vec![profile("26.2 Fabric", Some(JDK_25))];
|
||||
let clusters = vec![cluster(1, "26.2 Fabric", Some("26.2 Fabric"))];
|
||||
|
||||
assert_eq!(
|
||||
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
||||
vec!["26.2 Fabric".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_runtime_being_removed_counts() {
|
||||
let profiles = vec![
|
||||
profile("21 pinned", Some(r"C:\jdk-21\bin\javaw.exe")),
|
||||
profile("25 pinned", Some(JDK_25)),
|
||||
];
|
||||
let clusters = vec![
|
||||
cluster(1, "Old pack", Some("21 pinned")),
|
||||
cluster(2, "New pack", Some("25 pinned")),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
||||
vec!["New pack".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_cluster_holding_the_runtime_is_named() {
|
||||
let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))];
|
||||
let clusters = vec![cluster(1, "Alpha", Some("a")), cluster(2, "Beta", Some("b"))];
|
||||
|
||||
assert_eq!(
|
||||
clusters_pinned_to_java(&profiles, &clusters, JDK_25),
|
||||
vec!["Alpha".to_string(), "Beta".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_no_cluster_claims_falls_back_to_its_own_name() {
|
||||
let profiles = vec![profile("orphaned", Some(JDK_25))];
|
||||
|
||||
assert_eq!(
|
||||
clusters_pinned_to_java(&profiles, &[], JDK_25),
|
||||
vec!["orphaned".to_string()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,17 @@ pub fn use_browser_compat() -> State<bool> {
|
|||
consume_root_context::<BrowserCompatState>().0
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BrowserTypeState(pub State<String>);
|
||||
|
||||
pub fn use_provide_browser_type(state: BrowserTypeState) {
|
||||
use_hook(move || provide_root_context(state));
|
||||
}
|
||||
|
||||
pub fn use_browser_type() -> State<String> {
|
||||
consume_root_context::<BrowserTypeState>().0
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LinkConfirmState(pub State<Option<String>>);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ pub use overlay_claims::{
|
|||
pub use view_state::{PersistedView, use_view_state};
|
||||
|
||||
pub use active_cluster::{
|
||||
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState,
|
||||
OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id,
|
||||
use_browser_compat, use_browser_state_store, use_link_confirm, use_onboarding_selection,
|
||||
use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state,
|
||||
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState, BrowserUiState,
|
||||
LinkConfirmState, OnboardingSelectionState, SplashState, StartMaximizedState,
|
||||
use_active_cluster_id, use_browser_compat, use_browser_state_store, use_browser_type,
|
||||
use_link_confirm, use_onboarding_selection, use_provide_active_cluster,
|
||||
use_provide_browser_compat, use_provide_browser_state, use_provide_browser_type,
|
||||
use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash,
|
||||
use_provide_start_maximized, use_splash, use_start_maximized,
|
||||
};
|
||||
|
|
@ -72,7 +73,7 @@ pub(crate) use queries::invalidate_auth_queries;
|
|||
use crate::notifications::NotificationSnapshot;
|
||||
use crate::state::{
|
||||
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
|
||||
SettingsState,
|
||||
SettingsState, StorageScanProgress,
|
||||
};
|
||||
use freya::prelude::*;
|
||||
use freya::radio::use_radio;
|
||||
|
|
@ -126,6 +127,13 @@ pub fn use_installs_snapshot() -> InstallState {
|
|||
use_radio(AppChannel::Installs).read().installs.clone()
|
||||
}
|
||||
|
||||
pub fn use_storage_scan_progress() -> Option<StorageScanProgress> {
|
||||
use_radio(AppChannel::StorageScan)
|
||||
.read()
|
||||
.storage_scan
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn use_pending_launch() -> Option<String> {
|
||||
use_radio(AppChannel::PendingLaunch)
|
||||
.read()
|
||||
|
|
|
|||
|
|
@ -38,13 +38,11 @@ impl QueryCapability for FetchPlayerProfileQuery {
|
|||
}
|
||||
|
||||
let access_token = keys.access_token.clone();
|
||||
|
||||
let state = crate::launcher::state()?;
|
||||
let client = &state.services.requester;
|
||||
|
||||
Ok(
|
||||
minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref())
|
||||
.await?,
|
||||
)
|
||||
Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?)
|
||||
}
|
||||
|
||||
// TODO Cache
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use super::{use_dispatch, use_settings_snapshot};
|
|||
pub struct PersistedView {
|
||||
pub layout: State<ViewLayout>,
|
||||
pub sort: State<Option<String>>,
|
||||
pub columns: State<u8>,
|
||||
}
|
||||
|
||||
pub fn use_view_state(key: &str) -> PersistedView {
|
||||
|
|
@ -18,17 +17,14 @@ pub fn use_view_state(key: &str) -> PersistedView {
|
|||
let initial = settings.view_state(&key);
|
||||
let init_layout = initial.layout;
|
||||
let init_sort = initial.sort;
|
||||
let init_columns = initial.columns;
|
||||
let layout = use_state(move || init_layout);
|
||||
let sort = use_state(move || init_sort.clone());
|
||||
let columns = use_state(move || init_columns);
|
||||
|
||||
let mut first = use_state(|| true);
|
||||
use_side_effect(move || {
|
||||
let next = ViewState {
|
||||
layout: *layout.read(),
|
||||
sort: sort.read().clone(),
|
||||
columns: *columns.read(),
|
||||
};
|
||||
if *first.peek() {
|
||||
first.set(false);
|
||||
|
|
@ -42,9 +38,5 @@ pub fn use_view_state(key: &str) -> PersistedView {
|
|||
dispatch.set_settings(updated);
|
||||
});
|
||||
|
||||
PersistedView {
|
||||
layout,
|
||||
sort,
|
||||
columns,
|
||||
}
|
||||
PersistedView { layout, sort }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ impl Component for AnimatedAppOutlet {
|
|||
}
|
||||
};
|
||||
|
||||
let overlay = show_overlay.then(|| appshell_overlay().opacity(chrome_opacity));
|
||||
let overlay = show_overlay.then(|| appshell_overlay(chrome_opacity));
|
||||
|
||||
let back = (!is_home).then(|| {
|
||||
rect()
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ use oneclient_core::clusters::Cluster;
|
|||
use oneclient_db::models::ClusterId;
|
||||
|
||||
use crate::hooks::{
|
||||
ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters,
|
||||
use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat,
|
||||
use_provide_browser_state, use_splash,
|
||||
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState,
|
||||
use_active_cluster_id, use_clusters, use_game_snapshot, use_launcher,
|
||||
use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state,
|
||||
use_provide_browser_type, use_splash,
|
||||
};
|
||||
use crate::theme::colors;
|
||||
use oneclient_events::LaunchStage;
|
||||
|
|
@ -43,6 +44,9 @@ impl Component for AppShell {
|
|||
let browser_state = use_state(HashMap::new);
|
||||
use_provide_browser_state(BrowserStateStore(browser_state));
|
||||
|
||||
let browser_type = use_state(|| "mod".to_string());
|
||||
use_provide_browser_type(BrowserTypeState(browser_type));
|
||||
|
||||
// `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here
|
||||
let mut drop_hovering = use_state(|| false);
|
||||
let mut drop_pending = use_state(Vec::<PathBuf>::new);
|
||||
|
|
@ -229,7 +233,10 @@ fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElemen
|
|||
.into_element()
|
||||
}
|
||||
|
||||
pub(crate) fn appshell_overlay() -> Rect {
|
||||
pub(crate) fn appshell_overlay(alpha: f32) -> Rect {
|
||||
let alpha = alpha.clamp(0., 1.);
|
||||
let scale = |a: u8| (f32::from(a) * alpha).round() as u8;
|
||||
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
|
|
@ -242,15 +249,15 @@ pub(crate) fn appshell_overlay() -> Rect {
|
|||
.width(Size::fill())
|
||||
.background(
|
||||
LinearGradient::new()
|
||||
.stop((Color::BLACK.with_a(100), 0.))
|
||||
.stop((colors::page().with_a(255), 95.0)),
|
||||
.stop((Color::BLACK.with_a(scale(100)), 0.))
|
||||
.stop((colors::page().with_a(scale(255)), 95.0)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.height(Size::fill())
|
||||
.width(Size::fill())
|
||||
.background(colors::page()),
|
||||
.background(colors::page().with_a(scale(255))),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ fn cluster_header(
|
|||
Button::new()
|
||||
.danger()
|
||||
.icon()
|
||||
.tooltip("Stop game")
|
||||
.on_press(move |_| kill_dispatch.kill_cluster(cluster_id))
|
||||
.child(Icon::new(IconType::Square).size(16.)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ use freya::prelude::*;
|
|||
use freya::router::*;
|
||||
|
||||
use crate::components::{
|
||||
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup,
|
||||
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
|
||||
UpdatePromptOverlay,
|
||||
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
|
||||
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost,
|
||||
UpdatePromptOverlay, use_provide_tooltips,
|
||||
};
|
||||
use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
|
@ -24,15 +24,16 @@ impl Component for RootLayout {
|
|||
let home_ready = use_state(|| false);
|
||||
use_provide_splash(SplashState { active, home_ready });
|
||||
use_provide_overlay_claims();
|
||||
use_provide_tooltips();
|
||||
|
||||
// macOS rounds the window natively so Freya must not round on top of it
|
||||
#[cfg(target_os = "macos")]
|
||||
let corner = 0.;
|
||||
let _corner = 0.;
|
||||
|
||||
// Elsewhere the window is borderless and squared when maximized
|
||||
// No reactive maximized signal exists so root_size changes stand in as the trigger
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let corner = {
|
||||
let _corner = {
|
||||
let root_size = Platform::get().root_size;
|
||||
// Seeded because the query below only answers after the window is visible
|
||||
// which would round the corners of the first frames of a maximized launch
|
||||
|
|
@ -63,7 +64,7 @@ impl Component for RootLayout {
|
|||
.background(colors::page())
|
||||
.color(colors::fg_primary())
|
||||
.font_family(theme::DEFAULT_FONT)
|
||||
.corner_radius(CornerRadius::new_all(corner))
|
||||
//.corner_radius(CornerRadius::new_all(corner))
|
||||
.overflow(Overflow::Clip)
|
||||
.child(
|
||||
rect()
|
||||
|
|
@ -76,12 +77,14 @@ impl Component for RootLayout {
|
|||
.child(Toasts)
|
||||
.child(UpdatePromptOverlay)
|
||||
.child(JavaPromptOverlay)
|
||||
.child(MicrosoftJavaPromptOverlay)
|
||||
// Must stay last it renders whatever the overlays above did not claim
|
||||
.child(GenericPromptOverlay)
|
||||
.child(ClusterUpdatePopup)
|
||||
.child(OptionalModsPopup)
|
||||
.child(PackageUpdatePopup)
|
||||
.child(StatusBar)
|
||||
.child(TooltipHost)
|
||||
.child(SplashCurtain)
|
||||
.child(AnimationClockDriver)
|
||||
.child(HomeArtPrefetch)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use crate::use_dispatch;
|
|||
const SIDEBAR_WIDTH_PX: f32 = 225.;
|
||||
const ITEM_HEIGHT_PX: f32 = 33.;
|
||||
const SEARCH_WIDTH_PX: f32 = 256.;
|
||||
const SEARCH_MIN_WIDTH_PX: f32 = 120.;
|
||||
const SEARCH_RESULTS_MAX: usize = 12;
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -386,6 +387,7 @@ impl Component for SettingsShell {
|
|||
let changelog_unread = has_unread_changelog();
|
||||
|
||||
let search = use_state(String::new);
|
||||
let header_width = use_state(|| 0f32);
|
||||
let query = SearchQuery::new(&search.read());
|
||||
let scroll = use_scroll_controller(ScrollConfig::default);
|
||||
SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll));
|
||||
|
|
@ -433,12 +435,17 @@ impl Component for SettingsShell {
|
|||
.height(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.spacing(24.)
|
||||
.child(content_header(active.label().to_string(), search))
|
||||
.child(content_header(
|
||||
active.label().to_string(),
|
||||
search,
|
||||
header_width,
|
||||
))
|
||||
.child(
|
||||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.show_scrollbar(true)
|
||||
.scrollbar_gutter(true)
|
||||
.spacing(4.)
|
||||
.scroll_controller(scroll)
|
||||
.on_ctx(|ctx| {
|
||||
|
|
@ -450,27 +457,49 @@ impl Component for SettingsShell {
|
|||
}
|
||||
}
|
||||
|
||||
fn content_header(title: String, search: State<String>) -> impl IntoElement {
|
||||
fn content_header(
|
||||
title: String,
|
||||
search: State<String>,
|
||||
mut width: State<f32>,
|
||||
) -> impl IntoElement {
|
||||
let search_width = search_width_for(*width.read());
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.cross_align(Alignment::Center)
|
||||
.main_align(Alignment::SpaceBetween)
|
||||
.spacing(12.)
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let next = event.data().area.width();
|
||||
if (*width.peek() - next).abs() > 0.5 {
|
||||
width.set(next);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
label()
|
||||
.text(title)
|
||||
.font_size(30.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.max_lines(1)
|
||||
.width(Size::flex(1.0))
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(search_box(search))
|
||||
.child(search_box(search, search_width))
|
||||
}
|
||||
|
||||
fn search_box(mut search: State<String>) -> impl IntoElement {
|
||||
fn search_width_for(header_width: f32) -> f32 {
|
||||
if header_width <= 0. {
|
||||
return SEARCH_WIDTH_PX;
|
||||
}
|
||||
(header_width * 0.45).clamp(SEARCH_MIN_WIDTH_PX, SEARCH_WIDTH_PX)
|
||||
}
|
||||
|
||||
fn search_box(mut search: State<String>, width: f32) -> impl IntoElement {
|
||||
let has_query = !search.read().trim().is_empty();
|
||||
let base = TextInput::new(search)
|
||||
.placeholder("Search settings...")
|
||||
.width(Size::px(SEARCH_WIDTH_PX))
|
||||
.width(Size::px(width))
|
||||
.leading(
|
||||
Icon::new(IconType::SearchMd)
|
||||
.size(16.)
|
||||
|
|
@ -631,7 +660,7 @@ fn sidebar(active: SettingsTab, changelog_unread: bool) -> impl IntoElement {
|
|||
.padding(Gaps::new(16., 0., 0., 0.))
|
||||
.content(Content::Flex)
|
||||
.child(
|
||||
rect()
|
||||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(16.)
|
||||
|
|
@ -741,6 +770,9 @@ impl Component for SidebarInfo {
|
|||
.width(Size::fill())
|
||||
.spacing(4.)
|
||||
.font_size(12.)
|
||||
.padding(Gaps::new(
|
||||
15.,0.,0.,0.
|
||||
))
|
||||
.color(colors::fg_secondary())
|
||||
.children(
|
||||
items
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod launcher;
|
|||
pub mod state;
|
||||
mod transfer;
|
||||
mod layout;
|
||||
pub mod microsoft_java;
|
||||
mod motion;
|
||||
mod notifications;
|
||||
pub mod platform;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use oneclient_app::state::{AppChannel, AppState, LauncherInit};
|
|||
use oneclient_app::{
|
||||
Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants,
|
||||
events, platform, router, theme, use_provide_actions, use_provide_link_confirm,
|
||||
use_provide_start_maximized,
|
||||
use_provide_start_maximized, microsoft_java,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
use tokio::runtime::Builder;
|
||||
|
|
@ -60,7 +60,10 @@ impl App for OneClientApp {
|
|||
match events::start_launcher(station, events_bus).await {
|
||||
// Must follow startup `sync_bundles` needs the launcher handle and firing
|
||||
// it early leaves `syncing_bundles` stuck disabling every launch button
|
||||
Ok(()) => startup.sync_bundles(),
|
||||
Ok(()) => {
|
||||
startup.sync_bundles();
|
||||
microsoft_java::spawn_auto_install();
|
||||
}
|
||||
Err(err) => {
|
||||
events::report_startup_failure(&station, &err);
|
||||
oneclient_app::updater::spawn_update_check(false, rescue_bus);
|
||||
|
|
@ -182,7 +185,6 @@ fn main() {
|
|||
|
||||
let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting);
|
||||
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
oneclient_app::platform::macos::loop_memory_collector();
|
||||
|
||||
|
|
@ -201,7 +203,7 @@ fn main() {
|
|||
)))
|
||||
.with_size(1200., 800.)
|
||||
.with_min_size(800., 600.)
|
||||
.with_transparency(true)
|
||||
.with_transparency(false)
|
||||
.with_background(Color::TRANSPARENT)
|
||||
// A half-copied data folder is unrecoverable, so the window refuses to
|
||||
// close while one is being moved; the move screen says as much
|
||||
|
|
@ -239,7 +241,7 @@ fn main() {
|
|||
std::env::var("ONECLIENT_GPU_CACHE_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(512 * 1024 * 1024),
|
||||
.unwrap_or(96 * 1024 * 1024),
|
||||
)
|
||||
.with_default_font(theme::DEFAULT_FONT);
|
||||
|
||||
|
|
|
|||
358
packages/oneclient_app/src/microsoft_java.rs
Normal file
358
packages/oneclient_app/src/microsoft_java.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use freya::prelude::spawn_forever;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use oneclient_common::Patch;
|
||||
use oneclient_core::settings::store::save_settings_and_apply;
|
||||
use oneclient_core::{LauncherState, ProfileUpdate};
|
||||
use oneclient_db::models::ClusterId;
|
||||
use oneclient_events::{Choice, Prompt, Signal};
|
||||
use oneclient_java::{JavaRuntime, JavaVendor};
|
||||
|
||||
use crate::hooks::Actions;
|
||||
use crate::launcher;
|
||||
|
||||
/// Front-ends match on these
|
||||
pub const MICROSOFT_JAVA_CHOICE_INSTALL: &str = "java.microsoft.install";
|
||||
pub const MICROSOFT_JAVA_CHOICE_NEVER: &str = "java.microsoft.never";
|
||||
|
||||
/// Cancel works per session, "Don't ask again" is set in settings
|
||||
static ASKED: Mutex<BTreeSet<ClusterId>> = Mutex::new(BTreeSet::new());
|
||||
|
||||
enum MicrosoftJavaAnswer {
|
||||
Install,
|
||||
Never,
|
||||
}
|
||||
|
||||
pub fn spawn_auto_install() {
|
||||
tokio::spawn(auto_install(ui_refresh_channel()));
|
||||
}
|
||||
|
||||
fn ui_refresh_channel() -> UnboundedSender<()> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
|
||||
spawn_forever(async move {
|
||||
while rx.recv().await.is_some() {
|
||||
crate::hooks::invalidate_profile_queries().await;
|
||||
}
|
||||
});
|
||||
tx
|
||||
}
|
||||
|
||||
async fn auto_install(refresh: UnboundedSender<()>) {
|
||||
let Ok(state) = launcher::state() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if opted_out(&state) || already_migrated(&state) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(cluster_id) = automatic_cluster(&state).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let major = match oneclient_core::required_java_major(&state, cluster_id).await {
|
||||
Ok(Some(major)) => major,
|
||||
Ok(None) => {
|
||||
tracing::info!(cluster_id, "the cluster's manifest names no Java version");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.java
|
||||
.has_vendor_runtime(&JavaVendor::Microsoft, Some(major))
|
||||
.await
|
||||
{
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("could not check for a Microsoft Java runtime: {err:#}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !publishes(&state, major).await {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
major,
|
||||
"fetching a Microsoft runtime for the cluster on Automatic"
|
||||
);
|
||||
install_and_unpin(cluster_id, major, refresh);
|
||||
}
|
||||
|
||||
pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId) {
|
||||
let Ok(state) = launcher::state() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if opted_out(&state) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(pinned) = pinned_runtime(&state, cluster_id).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if pinned.vendor == JavaVendor::Microsoft {
|
||||
return;
|
||||
}
|
||||
|
||||
let major = match oneclient_core::required_java_major(&state, cluster_id).await {
|
||||
Ok(Some(major)) => major,
|
||||
Ok(None) => {
|
||||
tracing::info!(cluster_id, "the cluster's manifest names no Java version");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let installed = match state
|
||||
.java
|
||||
.has_vendor_runtime(&JavaVendor::Microsoft, Some(major))
|
||||
.await
|
||||
{
|
||||
Ok(installed) => installed,
|
||||
Err(err) => {
|
||||
tracing::warn!("could not check for a Microsoft Java runtime: {err:#}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !installed && !publishes(&state, major).await {
|
||||
return;
|
||||
}
|
||||
|
||||
if !claim_ask(cluster_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match actions.events().ask(offer_prompt(installed)).await {
|
||||
Ok(Some(chosen)) => match chosen.value {
|
||||
MicrosoftJavaAnswer::Install => {
|
||||
if installed {
|
||||
unpin_to_automatic(&state, cluster_id, major, true, ui_refresh_channel()).await;
|
||||
} else {
|
||||
install_and_unpin(cluster_id, major, ui_refresh_channel());
|
||||
}
|
||||
}
|
||||
MicrosoftJavaAnswer::Never => actions.skip_microsoft_java(),
|
||||
},
|
||||
Ok(None) => tracing::info!(cluster_id, "Microsoft Java offer dismissed"),
|
||||
Err(err) => tracing::warn!(cluster_id, "Microsoft Java offer failed: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn offer_prompt(installed: bool) -> Prompt<MicrosoftJavaAnswer> {
|
||||
let closing = if installed {
|
||||
"You already have it installed, so this cluster will switch over immediately."
|
||||
} else {
|
||||
"Minecraft will launch with Microsoft OpenJDK next time."
|
||||
};
|
||||
|
||||
Prompt::new(
|
||||
"Microsoft Java runtime",
|
||||
format!(
|
||||
"Based on our research, we now recommend Microsoft's OpenJDK \
|
||||
as they have specific optimizations for Minecraft. \
|
||||
Would you like to use Microsoft OpenJDK as your default Java installation? \
|
||||
{closing}"
|
||||
),
|
||||
)
|
||||
.option(
|
||||
Choice::new(MICROSOFT_JAVA_CHOICE_NEVER, "Don't ask again"),
|
||||
MicrosoftJavaAnswer::Never,
|
||||
)
|
||||
.option(
|
||||
Choice::primary(MICROSOFT_JAVA_CHOICE_INSTALL, "Proceed"),
|
||||
MicrosoftJavaAnswer::Install,
|
||||
)
|
||||
.dismiss("Cancel")
|
||||
}
|
||||
|
||||
fn install_and_unpin(cluster_id: ClusterId, major: u32, refresh: UnboundedSender<()>) {
|
||||
tokio::spawn(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
let runtime = match state
|
||||
.java
|
||||
.install_runtime_from(&JavaVendor::Microsoft, major)
|
||||
.await
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(err) => {
|
||||
events
|
||||
.notify("Java install failed")
|
||||
.body(err.to_string())
|
||||
.error()
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
events.signal(Signal::JavaChanged);
|
||||
tracing::info!(cluster_id, version = %runtime.version, "installed a Microsoft runtime");
|
||||
|
||||
unpin_to_automatic(&state, cluster_id, major, false, refresh).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn unpin_to_automatic(
|
||||
state: &Arc<LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
major: u32,
|
||||
immediate: bool,
|
||||
refresh: UnboundedSender<()>,
|
||||
) {
|
||||
let events = state.services.events.clone();
|
||||
|
||||
mark_migrated(state).await;
|
||||
|
||||
// Cleared rather than pointed at the new runtime Automatic ranks the
|
||||
// default vendor first so it lands on this one anyway and the cluster
|
||||
// keeps following later Microsoft installs instead of freezing on one
|
||||
let update = ProfileUpdate {
|
||||
java_path: Patch::Clear,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match state.clusters.update_profile(cluster_id, update).await {
|
||||
Ok(_) => {
|
||||
let _ = refresh.send(());
|
||||
let body = if immediate {
|
||||
format!(
|
||||
"This cluster is back on Automatic and will run on the optimized Microsoft \
|
||||
{major} JDK from now on."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"This cluster is back on Automatic and will pick the optimized Microsoft \
|
||||
{major} JDK from its next launch."
|
||||
)
|
||||
};
|
||||
events.notify("Java switched").body(body).send();
|
||||
}
|
||||
// Only when installation was successfull and the cluster is not pointing to the new version
|
||||
Err(err) => events
|
||||
.notify("Cluster not switched")
|
||||
.body(format!(
|
||||
"Optimized Microsoft {major} JDK is ready but the cluster still points at its \
|
||||
old runtime: {err}. Please report to discord.gg/polyfrost"
|
||||
))
|
||||
.error()
|
||||
.send(),
|
||||
}
|
||||
}
|
||||
|
||||
fn already_migrated(state: &Arc<LauncherState>) -> bool {
|
||||
state.settings.read().microsoft_java_migrated
|
||||
}
|
||||
|
||||
async fn mark_migrated(state: &Arc<LauncherState>) {
|
||||
let snapshot = {
|
||||
let mut settings = state.settings.write();
|
||||
if settings.microsoft_java_migrated {
|
||||
return;
|
||||
}
|
||||
settings.microsoft_java_migrated = true;
|
||||
settings.clone()
|
||||
};
|
||||
|
||||
if let Err(err) = save_settings_and_apply(&state.services, &snapshot).await {
|
||||
tracing::warn!("could not record the Microsoft Java migration: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
fn opted_out(state: &Arc<LauncherState>) -> bool {
|
||||
let settings = state.settings.read();
|
||||
settings.skip_microsoft_java || !settings.seen_onboarding
|
||||
}
|
||||
|
||||
fn claim_ask(cluster_id: ClusterId) -> bool {
|
||||
ASKED
|
||||
.lock()
|
||||
.map(|mut asked| asked.insert(cluster_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// If there is not Microsoft build for given host, the offer is abandoned
|
||||
async fn publishes(state: &Arc<LauncherState>, major: u32) -> bool {
|
||||
match state
|
||||
.java
|
||||
.latest_package(&JavaVendor::Microsoft, major)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => {
|
||||
tracing::info!(major, "Microsoft publishes no build for this host");
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("could not reach Microsoft's downloads: {err:#}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the cluster the launcher opens on the one the home panel preselects last played first and newest version when nothing has been played yet
|
||||
async fn automatic_cluster(state: &Arc<LauncherState>) -> Option<ClusterId> {
|
||||
let clusters = match state.clusters.list().await {
|
||||
Ok(clusters) => clusters,
|
||||
Err(err) => {
|
||||
tracing::warn!("could not read the cluster list: {err:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let cluster = crate::utils::sort_clusters_for_home(clusters)
|
||||
.into_iter()
|
||||
.next()?;
|
||||
|
||||
let global = state.settings.read().global_game_settings.clone();
|
||||
|
||||
match state.clusters.resolve_settings(&global, &cluster).await {
|
||||
Ok(profile) => {
|
||||
let automatic = profile.java_path.is_none();
|
||||
tracing::info!(
|
||||
cluster_id = cluster.id,
|
||||
name = %cluster.name,
|
||||
automatic,
|
||||
"checked the Java of the cluster the launcher opens on"
|
||||
);
|
||||
automatic.then_some(cluster.id)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id = cluster.id,
|
||||
"could not resolve cluster settings: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` when the cluster is on Automatic or when its pin points at a runtime that is no longer on disk
|
||||
async fn pinned_runtime(state: &Arc<LauncherState>, cluster_id: ClusterId) -> Option<JavaRuntime> {
|
||||
let cluster = state.clusters.get(cluster_id).await.ok()?;
|
||||
let global = state.settings.read().global_game_settings.clone();
|
||||
let profile = state
|
||||
.clusters
|
||||
.resolve_settings(&global, &cluster)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let path = profile.java_path?;
|
||||
state.java.runtime_for_profile(Some(&path)).await.ok()?
|
||||
}
|
||||
|
|
@ -225,7 +225,7 @@ pub struct NotificationState {
|
|||
/// Resumes the launch that opened the update modal
|
||||
/// Held here not by the launch task
|
||||
/// because every way the modal can end goes through this state
|
||||
package_updates_done: Option<oneshot::Sender<()>>,
|
||||
package_updates_done: Option<oneshot::Sender<Vec<BrowserPackageUpdate>>>,
|
||||
optional_mods_done: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +388,7 @@ impl NotificationState {
|
|||
pub fn open_package_updates(
|
||||
&mut self,
|
||||
groups: Vec<PackageUpdateGroup>,
|
||||
done: Option<oneshot::Sender<()>>,
|
||||
done: Option<oneshot::Sender<Vec<BrowserPackageUpdate>>>,
|
||||
) {
|
||||
let groups: Vec<PackageUpdateGroup> = groups
|
||||
.into_iter()
|
||||
|
|
@ -397,19 +397,25 @@ impl NotificationState {
|
|||
|
||||
if groups.is_empty() {
|
||||
if let Some(done) = done {
|
||||
let _ = done.send(());
|
||||
let _ = done.send(Vec::new());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A second modal would strand the first launch so release the old continuation first
|
||||
self.finish_package_updates();
|
||||
self.finish_package_updates(Vec::new());
|
||||
self.package_updates = Some(groups);
|
||||
self.package_updates_done = done;
|
||||
}
|
||||
|
||||
pub fn close_package_updates(&mut self) {
|
||||
self.finish_package_updates();
|
||||
self.finish_package_updates(Vec::new());
|
||||
}
|
||||
|
||||
/// The launch applies these itself so the game starts on the versions the
|
||||
/// player just picked rather than racing them
|
||||
pub fn proceed_package_updates(&mut self, chosen: Vec<BrowserPackageUpdate>) {
|
||||
self.finish_package_updates(chosen);
|
||||
}
|
||||
|
||||
/// Applying and skipping are both answers either drops the row and can close the modal
|
||||
|
|
@ -426,16 +432,16 @@ impl NotificationState {
|
|||
groups.retain(|group| !group.packages.is_empty());
|
||||
|
||||
if groups.is_empty() {
|
||||
self.finish_package_updates();
|
||||
self.finish_package_updates(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe to call when nothing is open so every exit path can call it unconditionally
|
||||
fn finish_package_updates(&mut self) {
|
||||
fn finish_package_updates(&mut self, chosen: Vec<BrowserPackageUpdate>) {
|
||||
self.package_updates = None;
|
||||
if let Some(done) = self.package_updates_done.take() {
|
||||
// A send failure means the launch task is gone nothing left to resume
|
||||
let _ = done.send(());
|
||||
let _ = done.send(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,9 +471,11 @@ impl NotificationState {
|
|||
);
|
||||
self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL);
|
||||
}
|
||||
// The sign-in modal renders this progress itself it must not also become a toast
|
||||
|
||||
Event::Progress(ProgressEvent::Update { id, .. })
|
||||
if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {}
|
||||
Event::Progress(ProgressEvent::Update { id, .. })
|
||||
if id == oneclient_core::storage::STORAGE_SCAN_PROGRESS => {}
|
||||
Event::Progress(ProgressEvent::Update {
|
||||
id,
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub enum AppChannel {
|
|||
AccountSwitcher,
|
||||
MicrosoftLogin,
|
||||
Installs,
|
||||
StorageScan,
|
||||
Relocation,
|
||||
PendingLaunch,
|
||||
}
|
||||
|
|
@ -43,6 +44,7 @@ pub struct AppState {
|
|||
pub account_switcher_open: bool,
|
||||
pub microsoft_login: Option<LoginProgress>,
|
||||
pub installs: InstallState,
|
||||
pub storage_scan: Option<StorageScanProgress>,
|
||||
pub relocation: RelocationState,
|
||||
pub pending_launch: Option<String>,
|
||||
}
|
||||
|
|
@ -121,6 +123,13 @@ pub struct LoginProgress {
|
|||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StorageScanProgress {
|
||||
pub label: String,
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LaunchBlock {
|
||||
Starting(i64),
|
||||
|
|
|
|||
|
|
@ -92,6 +92,29 @@ pub fn relative_time(created_at: Instant) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Gap the clamped menu keeps from the window edges
|
||||
pub const EDGE_MARGIN: f32 = 8.;
|
||||
|
||||
/// `root_size` is physical while the press position is logical, so it has to be
|
||||
/// scaled down before the two are compared
|
||||
pub fn clamp_to_window(x: f32, y: f32, width: f32, height: f32) -> (f32, f32) {
|
||||
let platform = Platform::get();
|
||||
let scale = *platform.scale_factor.peek() as f32;
|
||||
if scale <= 0. {
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
let window = *platform.root_size.peek();
|
||||
let clamp = |pos: f32, len: f32, limit: f32| {
|
||||
pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN))
|
||||
};
|
||||
|
||||
(
|
||||
clamp(x, width, window.width / scale),
|
||||
clamp(y, height, window.height / scale),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the `Rect` not an `Element` so callers can inset or round it
|
||||
pub fn divider() -> Rect {
|
||||
rect()
|
||||
|
|
@ -114,6 +137,23 @@ pub fn centered_note(text: &str) -> Element {
|
|||
.into_element()
|
||||
}
|
||||
|
||||
pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize {
|
||||
if width <= 0. {
|
||||
return 1;
|
||||
}
|
||||
|
||||
(((width + gap) / (max_col + gap)).ceil() as usize).max(1)
|
||||
}
|
||||
|
||||
pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize {
|
||||
let max = max.max(1);
|
||||
if width <= 0. {
|
||||
return max;
|
||||
}
|
||||
|
||||
(((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max)
|
||||
}
|
||||
|
||||
/// Short final rows are padded with empty flex cells so tiles keep the column width
|
||||
pub fn flow_grid(items: Vec<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
|
||||
let cols = cols.max(1);
|
||||
|
|
@ -172,3 +212,25 @@ pub fn entrance_motion_layer(
|
|||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
pub trait ImageFallbackExt: Sized {
|
||||
fn fallback(self, placeholder: impl IntoElement) -> Self;
|
||||
}
|
||||
|
||||
impl ImageFallbackExt for ImageViewer {
|
||||
fn fallback(self, placeholder: impl IntoElement) -> Self {
|
||||
let placeholder = placeholder.into_element();
|
||||
self.error_renderer(move |_: String| placeholder.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn window_logical_size() -> Size2D {
|
||||
let platform = Platform::get();
|
||||
let scale = *platform.scale_factor.peek() as f32;
|
||||
let size = *platform.root_size.peek();
|
||||
if scale <= 0. {
|
||||
return size;
|
||||
}
|
||||
|
||||
Size2D::new(size.width / scale, size.height / scale)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,22 +245,28 @@ pub fn format_memory_gb(mb: u32) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m`
|
||||
pub fn format_duration_hm(secs: i64) -> String {
|
||||
/// `45` -> `45s` `540` -> `9m` `7200` -> `2h` `7384` -> `2h 3m` `180000` -> `2d 2h`
|
||||
pub fn format_duration(secs: i64) -> String {
|
||||
if secs <= 0 {
|
||||
return "0m".to_string();
|
||||
}
|
||||
let hours = secs / 3600;
|
||||
|
||||
let days = secs / 86_400;
|
||||
let hours = (secs % 86_400) / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}h {minutes}m")
|
||||
} else {
|
||||
format!("{minutes}m")
|
||||
|
||||
match (days, hours, minutes) {
|
||||
(0, 0, 0) => format!("{secs}s"),
|
||||
(0, 0, m) => format!("{m}m"),
|
||||
(0, h, 0) => format!("{h}h"),
|
||||
(0, h, m) => format!("{h}h {m}m"),
|
||||
(d, 0, _) => format!("{d}d"),
|
||||
(d, h, _) => format!("{d}d {h}h"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s`
|
||||
pub fn format_duration_hms(secs: i64) -> String {
|
||||
pub fn format_durations(secs: i64) -> String {
|
||||
if secs <= 0 {
|
||||
return "0s".to_string();
|
||||
}
|
||||
|
|
@ -466,6 +472,17 @@ mod tests {
|
|||
assert_eq!(format_memory_gb(1536), "1.5 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_never_show_a_zero_unit() {
|
||||
assert_eq!(format_duration(45), "45s");
|
||||
assert_eq!(format_duration(540), "9m");
|
||||
assert_eq!(format_duration(7200), "2h");
|
||||
assert_eq!(format_duration(7384), "2h 3m");
|
||||
assert_eq!(format_duration(180_000), "2d 2h");
|
||||
assert_eq!(format_duration(172_800), "2d");
|
||||
assert_eq!(format_duration(0), "0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_legacy_version_keeps_its_line_name() {
|
||||
let clusters = [versioned(1, "1.21")];
|
||||
|
|
|
|||
|
|
@ -1,83 +1,131 @@
|
|||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use chrono::{Local, NaiveDate};
|
||||
use chrono::{Datelike, Local, Months, NaiveDate};
|
||||
use freya::prelude::*;
|
||||
|
||||
use oneclient_core::game::{DayPlaytime, PlaytimeStats, WEEKDAY_LABELS};
|
||||
|
||||
use crate::components::{BarChart, IconType, Segment, SegmentedControl, ValueUnit};
|
||||
use crate::utils::{format_day, format_duration_hm, format_hour, parse_day};
|
||||
use crate::components::{
|
||||
BarChart, DateRange, DateRangePicker, IconType, Segment, SegmentedControl, ValueUnit,
|
||||
};
|
||||
use crate::utils::{format_day, format_duration, format_hour, parse_day};
|
||||
|
||||
use super::{chart_card, nav_button};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum WhenMode {
|
||||
Weekday,
|
||||
enum PlaytimeMode {
|
||||
Hour,
|
||||
Day,
|
||||
Month,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) struct WhenChart {
|
||||
pub(super) struct PlaytimeChart {
|
||||
per_weekday: Vec<i64>,
|
||||
per_hour: Vec<i64>,
|
||||
peak_weekday: Option<usize>,
|
||||
peak_hour: Option<usize>,
|
||||
daily: Vec<DayPlaytime>,
|
||||
}
|
||||
|
||||
impl WhenChart {
|
||||
impl PlaytimeChart {
|
||||
pub(super) fn from_stats(stats: &PlaytimeStats) -> Self {
|
||||
Self {
|
||||
per_weekday: stats.per_weekday.to_vec(),
|
||||
per_hour: stats.per_hour.to_vec(),
|
||||
peak_weekday: stats.peak_weekday,
|
||||
peak_hour: stats.peak_hour,
|
||||
daily: stats.daily.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for WhenChart {
|
||||
impl Component for PlaytimeChart {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mode = use_state(|| WhenMode::Weekday);
|
||||
let mode = use_state(|| PlaytimeMode::Day);
|
||||
let m = *mode.read();
|
||||
|
||||
let control = SegmentedControl::new(mode)
|
||||
.height(30.)
|
||||
.segment(Segment::new(WhenMode::Weekday).label("Day"))
|
||||
.segment(Segment::new(WhenMode::Hour).label("Hour"))
|
||||
.segment(Segment::new(PlaytimeMode::Hour).label("Hour"))
|
||||
.segment(Segment::new(PlaytimeMode::Day).label("Day"))
|
||||
.segment(Segment::new(PlaytimeMode::Month).label("Month"))
|
||||
.into_element();
|
||||
|
||||
let (subtitle, chart) = match m {
|
||||
WhenMode::Weekday => {
|
||||
let day = self
|
||||
.peak_weekday
|
||||
.map(|i| WEEKDAY_LABELS[i].to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
(
|
||||
format!("Most active on {day}"),
|
||||
BarChart::new(self.per_weekday.clone(), weekday_labels())
|
||||
.highlight(self.peak_weekday)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(6.)
|
||||
.into_element(),
|
||||
)
|
||||
}
|
||||
WhenMode::Hour => {
|
||||
let (title, subtitle, chart) = match m {
|
||||
PlaytimeMode::Hour => {
|
||||
let hour = self
|
||||
.peak_hour
|
||||
.map(format_hour)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
(
|
||||
format!("Peak around {hour}"),
|
||||
"Playtime by hour",
|
||||
format!("Every day's {hour} hour, added up"),
|
||||
BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect())
|
||||
.readout_labels(
|
||||
(0..24)
|
||||
.map(|h| format!("All {} hours", format_hour(h)))
|
||||
.collect(),
|
||||
)
|
||||
.highlight(self.peak_hour)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(3.)
|
||||
.into_element(),
|
||||
)
|
||||
}
|
||||
PlaytimeMode::Day => {
|
||||
let day = self
|
||||
.peak_weekday
|
||||
.map(|i| WEEKDAY_FULL[i].to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
(
|
||||
"Playtime by day",
|
||||
format!("Every {day} you have played, added up"),
|
||||
BarChart::new(self.per_weekday.clone(), weekday_labels())
|
||||
.readout_labels(
|
||||
WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect(),
|
||||
)
|
||||
.highlight(self.peak_weekday)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(6.)
|
||||
.into_element(),
|
||||
)
|
||||
}
|
||||
PlaytimeMode::Month => {
|
||||
let months = monthly_series(&self.daily);
|
||||
let values: Vec<i64> = months.iter().map(|(_, s)| *s).collect();
|
||||
let labels: Vec<String> = months
|
||||
.iter()
|
||||
.map(|(m, _)| MONTH_ABBR[m.month0() as usize].to_string())
|
||||
.collect();
|
||||
let readout: Vec<String> = months
|
||||
.iter()
|
||||
.map(|(m, _)| format!("{} {}", MONTH_ABBR[m.month0() as usize], m.year()))
|
||||
.collect();
|
||||
let best = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, v)| **v)
|
||||
.filter(|(_, v)| **v > 0)
|
||||
.map(|(i, _)| i);
|
||||
let subtitle = match best {
|
||||
Some(i) => format!("Best month so far: {}", readout[i]),
|
||||
None => "No sessions recorded yet".to_string(),
|
||||
};
|
||||
(
|
||||
"Playtime by month",
|
||||
subtitle,
|
||||
BarChart::new(values, labels)
|
||||
.readout_labels(readout)
|
||||
.highlight(best)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(4.)
|
||||
.into_element(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
chart_card("When you play", subtitle, Some(control), chart)
|
||||
chart_card(title, subtitle, Some(control), chart)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,25 +161,36 @@ impl Component for DailyChart {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
let range = use_state(|| Range::TwoWeeks);
|
||||
let mut offset = use_state(|| 0usize);
|
||||
let custom = use_state(|| Option::<DateRange>::None);
|
||||
|
||||
let series = continuous_series(&self.daily);
|
||||
if series.is_empty() {
|
||||
let (Some((first_day, _)), Some((last_day, _))) = (series.first(), series.last()) else {
|
||||
return chart_card(
|
||||
"Daily playtime",
|
||||
"No sessions recorded yet".to_string(),
|
||||
None,
|
||||
rect().height(Size::px(120.)).into_element(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let (first_day, last_day) = (*first_day, *last_day);
|
||||
|
||||
let window = range.read().days();
|
||||
let total = series.len();
|
||||
let max_offset = total.saturating_sub(1) / window;
|
||||
let off = (*offset.read()).min(max_offset);
|
||||
let picked = *custom.read();
|
||||
|
||||
let end = total.saturating_sub(off * window);
|
||||
let start = end.saturating_sub(window);
|
||||
let slice = &series[start..end];
|
||||
let (start, end) = match picked {
|
||||
Some((from, to)) => (
|
||||
day_index(first_day, from).min(total),
|
||||
(day_index(first_day, to) + 1).min(total),
|
||||
),
|
||||
None => {
|
||||
let window = range.read().days();
|
||||
let max_offset = total.saturating_sub(1) / window;
|
||||
let off = (*offset.read()).min(max_offset);
|
||||
let end = total.saturating_sub(off * window);
|
||||
(end.saturating_sub(window), end)
|
||||
}
|
||||
};
|
||||
let slice = &series[start.min(end)..end];
|
||||
|
||||
let values: Vec<i64> = slice.iter().map(|(_, s)| *s).collect();
|
||||
let labels: Vec<String> = slice.iter().map(|(d, _)| format_day(*d)).collect();
|
||||
|
|
@ -141,16 +200,21 @@ impl Component for DailyChart {
|
|||
(Some((a, _)), Some((b, _))) => {
|
||||
format!(
|
||||
"{} · {} – {}",
|
||||
format_duration_hm(win_total),
|
||||
format_duration(win_total),
|
||||
format_day(*a),
|
||||
format_day(*b)
|
||||
)
|
||||
}
|
||||
_ => format_duration_hm(win_total),
|
||||
_ => format_duration(win_total),
|
||||
};
|
||||
|
||||
let can_older = off < max_offset;
|
||||
let can_newer = off > 0;
|
||||
let paging = picked.is_none();
|
||||
let window = range.read().days();
|
||||
let max_offset = total.saturating_sub(1) / window;
|
||||
let off = (*offset.read()).min(max_offset);
|
||||
let can_older = paging && off < max_offset;
|
||||
let can_newer = paging && off > 0;
|
||||
|
||||
let nav = rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
|
|
@ -168,25 +232,31 @@ impl Component for DailyChart {
|
|||
.child(
|
||||
SegmentedControl::new(range)
|
||||
.height(30.)
|
||||
.disabled(!paging)
|
||||
.segment(Segment::new(Range::TwoWeeks).label("2W"))
|
||||
.segment(Segment::new(Range::Month).label("1M"))
|
||||
.segment(Segment::new(Range::Quarter).label("3M"))
|
||||
.into_element(),
|
||||
)
|
||||
.child(DateRangePicker::new(custom, (first_day, last_day)).height(30.))
|
||||
.into_element();
|
||||
|
||||
chart_card(
|
||||
"Daily playtime",
|
||||
subtitle,
|
||||
Some(nav),
|
||||
BarChart::new(values, labels)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(3.)
|
||||
.into_element(),
|
||||
)
|
||||
chart_card(
|
||||
"Daily playtime",
|
||||
subtitle,
|
||||
Some(nav),
|
||||
BarChart::new(values, labels)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(3.)
|
||||
.into_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn day_index(first: NaiveDate, day: NaiveDate) -> usize {
|
||||
(day - first).num_days().max(0) as usize
|
||||
}
|
||||
|
||||
fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> {
|
||||
let map: HashMap<NaiveDate, i64> = daily
|
||||
.iter()
|
||||
|
|
@ -211,6 +281,41 @@ fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> {
|
|||
out
|
||||
}
|
||||
|
||||
fn monthly_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> {
|
||||
let mut totals: BTreeMap<NaiveDate, i64> = BTreeMap::new();
|
||||
for day in daily {
|
||||
let Some(date) = parse_day(&day.date) else {
|
||||
continue;
|
||||
};
|
||||
let Some(month) = date.with_day(1) else {
|
||||
continue;
|
||||
};
|
||||
*totals.entry(month).or_insert(0) += day.secs;
|
||||
}
|
||||
|
||||
let (Some(first), Some(last)) = (
|
||||
totals.keys().min().copied(),
|
||||
totals.keys().max().copied(),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut cur = first;
|
||||
while cur <= last {
|
||||
out.push((cur, totals.get(&cur).copied().unwrap_or(0)));
|
||||
cur = match cur.checked_add_months(Months::new(1)) {
|
||||
Some(next) => next,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const MONTH_ABBR: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option<Element> {
|
||||
if session_secs.len() < 2 && !force {
|
||||
return None;
|
||||
|
|
@ -253,6 +358,16 @@ pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option<Ele
|
|||
))
|
||||
}
|
||||
|
||||
const WEEKDAY_FULL: [&str; 7] = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
];
|
||||
|
||||
fn weekday_labels() -> Vec<String> {
|
||||
WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats};
|
|||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::ui::{border_all_color, columns_for};
|
||||
|
||||
mod charts;
|
||||
mod servers;
|
||||
mod tiles;
|
||||
|
||||
use charts::{DailyChart, WhenChart, distribution_card};
|
||||
use charts::{DailyChart, PlaytimeChart, distribution_card};
|
||||
use servers::servers_section;
|
||||
use tiles::{personas_row, tiles_row};
|
||||
|
||||
|
|
@ -27,14 +27,13 @@ fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element {
|
|||
|
||||
root = root.child(tiles_row(analytics, force_all));
|
||||
|
||||
if !stats.personas.is_empty() {
|
||||
root = root.child(personas_row(&stats.personas));
|
||||
if !analytics.personas.is_empty() {
|
||||
root = root.child(personas_row(&analytics.personas));
|
||||
}
|
||||
|
||||
let mut charts: Vec<Element> = vec![
|
||||
WhenChart::from_stats(stats).into_element(),
|
||||
DailyChart::new(stats.daily.clone()).into_element(),
|
||||
];
|
||||
root = root.child(DailyChart::new(stats.daily.clone()));
|
||||
|
||||
let mut charts: Vec<Element> = vec![PlaytimeChart::from_stats(stats).into_element()];
|
||||
if let Some(dist) = distribution_card(&stats.session_secs, force_all) {
|
||||
charts.push(dist);
|
||||
}
|
||||
|
|
@ -127,40 +126,64 @@ fn empty_analytics() -> Analytics {
|
|||
per_hour: [0; 24],
|
||||
daily,
|
||||
session_secs: Vec::new(),
|
||||
longest_session_secs: 0,
|
||||
active_days: 0,
|
||||
current_streak: 0,
|
||||
longest_streak: 0,
|
||||
avg_secs_per_active_day: 0.0,
|
||||
peak_hour: None,
|
||||
peak_weekday: None,
|
||||
night_share: 0.0,
|
||||
personas: Vec::new(),
|
||||
};
|
||||
|
||||
Analytics {
|
||||
playtime,
|
||||
servers: Vec::new(),
|
||||
personas: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
const CHART_GAP: f32 = 16.;
|
||||
const CHART_MIN_W: f32 = 420.;
|
||||
|
||||
fn charts_grid(cards: Vec<Element>) -> Element {
|
||||
let mut grid = rect().vertical().width(Size::fill()).spacing(24.);
|
||||
for pair in cards.chunks(2) {
|
||||
if pair.len() == 1 {
|
||||
grid = grid.child(rect().width(Size::fill()).child(pair[0].clone()));
|
||||
continue;
|
||||
ChartsGrid { cards }.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ChartsGrid {
|
||||
cards: Vec<Element>,
|
||||
}
|
||||
|
||||
impl Component for ChartsGrid {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut width = use_state(|| 0f32);
|
||||
let cols = columns_for(*width.read(), CHART_MIN_W, 2, CHART_GAP);
|
||||
|
||||
let mut grid = rect().vertical().width(Size::fill()).spacing(24.);
|
||||
for chunk in self.cards.chunks(cols) {
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Start)
|
||||
.spacing(CHART_GAP);
|
||||
for card in chunk {
|
||||
row = row.child(rect().width(Size::flex(1.0)).child(card.clone()));
|
||||
}
|
||||
for _ in chunk.len()..cols {
|
||||
row = row.child(rect().width(Size::flex(1.0)));
|
||||
}
|
||||
grid = grid.child(row);
|
||||
}
|
||||
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Start)
|
||||
.spacing(16.);
|
||||
for card in pair {
|
||||
row = row.child(rect().width(Size::flex(1.0)).child(card.clone()));
|
||||
}
|
||||
grid = grid.child(row);
|
||||
grid.on_sized(move |e: Event<SizedEventData>| {
|
||||
let w = e.area.width();
|
||||
if (w - *width.peek()).abs() > 0.5 {
|
||||
width.set(w);
|
||||
}
|
||||
})
|
||||
}
|
||||
grid.into_element()
|
||||
}
|
||||
|
||||
pub(super) fn chart_card(
|
||||
|
|
|
|||
|
|
@ -3,17 +3,39 @@ use freya::prelude::*;
|
|||
use oneclient_core::game::ServerStat;
|
||||
|
||||
use crate::components::{
|
||||
Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color,
|
||||
Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, Segment, SegmentedControl,
|
||||
ValueUnit, slice_color,
|
||||
};
|
||||
use crate::hooks::{loaded_image, use_cached_image};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::utils::{format_duration_hm, plural};
|
||||
use crate::ui::{ImageFallbackExt, border_all_color};
|
||||
use crate::utils::{format_duration, plural};
|
||||
|
||||
use super::{card, card_header};
|
||||
|
||||
/// Number of servers charted the rest are grouped into an "Other" slice
|
||||
const TOP_SERVERS: usize = 10;
|
||||
const MAX_SLICES: usize = 10;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ServerMetric {
|
||||
Playtime,
|
||||
Joins,
|
||||
}
|
||||
|
||||
impl ServerMetric {
|
||||
fn of(self, s: &ServerStat) -> i64 {
|
||||
match self {
|
||||
ServerMetric::Playtime => s.total_secs,
|
||||
ServerMetric::Joins => s.joins,
|
||||
}
|
||||
}
|
||||
|
||||
fn unit(self) -> ValueUnit {
|
||||
match self {
|
||||
ServerMetric::Playtime => ValueUnit::Duration,
|
||||
ServerMetric::Joins => ValueUnit::Count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn servers_section(servers: &[ServerStat]) -> Element {
|
||||
ServersSection {
|
||||
|
|
@ -31,27 +53,46 @@ impl Component for ServersSection {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
let hovered = use_state(|| Option::<usize>::None);
|
||||
let mut show_all = use_state(|| false);
|
||||
let metric = use_state(|| ServerMetric::Playtime);
|
||||
let m = *metric.read();
|
||||
|
||||
let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum();
|
||||
|
||||
let mut sorted = self.servers.clone();
|
||||
sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs));
|
||||
sorted.sort_by_key(|s| std::cmp::Reverse(m.of(s)));
|
||||
|
||||
let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS));
|
||||
let mut values: Vec<i64> = top.iter().map(|s| s.total_secs).collect();
|
||||
let keep = if sorted.len() > MAX_SLICES {
|
||||
MAX_SLICES - 1
|
||||
} else {
|
||||
sorted.len()
|
||||
};
|
||||
let (top, rest) = sorted.split_at(keep);
|
||||
let mut values: Vec<i64> = top.iter().map(|s| m.of(s)).collect();
|
||||
let mut labels: Vec<String> = top.iter().map(server_label).collect();
|
||||
if !rest.is_empty() {
|
||||
values.push(rest.iter().map(|s| s.total_secs).sum());
|
||||
labels.push(format!("{} more", rest.len()));
|
||||
values.push(rest.iter().map(|s| m.of(s)).sum());
|
||||
labels.push("Other".to_string());
|
||||
}
|
||||
|
||||
let details = (!self.servers.is_empty()).then(|| {
|
||||
Button::new()
|
||||
.ghost()
|
||||
.small()
|
||||
.on_press(move |_| show_all.set(true))
|
||||
.child(Icon::new(IconType::Eye).size(12.))
|
||||
.text("Details")
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
SegmentedControl::new(metric)
|
||||
.height(30.)
|
||||
.segment(Segment::new(ServerMetric::Playtime).label("Playtime"))
|
||||
.segment(Segment::new(ServerMetric::Joins).label("Joins")),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.small()
|
||||
.on_press(move |_| show_all.set(true))
|
||||
.child(Icon::new(IconType::Eye).size(12.))
|
||||
.text("Details"),
|
||||
)
|
||||
.into_element()
|
||||
});
|
||||
|
||||
|
|
@ -78,13 +119,14 @@ impl Component for ServersSection {
|
|||
.spacing(20.)
|
||||
.child(
|
||||
PieChart::new(values.clone(), labels.clone())
|
||||
.unit(ValueUnit::Duration)
|
||||
.unit(m.unit())
|
||||
.hovered(hovered),
|
||||
)
|
||||
.child(rect().width(Size::flex(1.0)).child(legend(
|
||||
&values,
|
||||
&labels,
|
||||
*hovered.read(),
|
||||
m.unit(),
|
||||
))),
|
||||
)
|
||||
.maybe_child(show_all.read().then(|| {
|
||||
|
|
@ -105,7 +147,7 @@ impl Component for ServersSection {
|
|||
}
|
||||
}
|
||||
|
||||
fn legend(values: &[i64], labels: &[String], active: Option<usize>) -> Element {
|
||||
fn legend(values: &[i64], labels: &[String], active: Option<usize>, unit: ValueUnit) -> Element {
|
||||
let total: i64 = values.iter().sum();
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(4.);
|
||||
|
||||
|
|
@ -155,7 +197,7 @@ fn legend(values: &[i64], labels: &[String], active: Option<usize>) -> Element {
|
|||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format_duration_hm(*value))
|
||||
.text(unit.format(*value))
|
||||
.font_size(11.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
|
|
@ -223,7 +265,7 @@ impl Component for ServerDetailsPopup {
|
|||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"{} server{} by playtime",
|
||||
"All {} server{} by playtime",
|
||||
servers.len(),
|
||||
plural(servers.len() as i64)
|
||||
))
|
||||
|
|
@ -332,7 +374,7 @@ impl Component for ServerRow {
|
|||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format_duration_hm(s.total_secs))
|
||||
.text(format_duration(s.total_secs))
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
|
|
@ -371,6 +413,7 @@ impl Component for ServerIcon {
|
|||
.height(Size::px(size))
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.fallback(server_icon_placeholder(self.is_ip, size))
|
||||
.into_element(),
|
||||
None => server_icon_placeholder(self.is_ip, size),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,134 +4,278 @@ use oneclient_core::game::{Analytics, Persona};
|
|||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::utils::format_duration_hm;
|
||||
use crate::ui::{border_all_color, columns_for, flow_grid};
|
||||
use crate::utils::{format_duration, plural};
|
||||
|
||||
use super::card;
|
||||
|
||||
pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element {
|
||||
let stats = &analytics.playtime;
|
||||
let avg_session = if stats.session_count > 0 {
|
||||
stats.total_secs / stats.session_count as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum();
|
||||
pub(super) fn tiles_row(analytics: &Analytics, _force_all: bool) -> Element {
|
||||
TilesRow {
|
||||
analytics: analytics.clone(),
|
||||
}
|
||||
.into_element()
|
||||
}
|
||||
|
||||
let mut tiles = vec![
|
||||
stat_tile(
|
||||
const TILE_GAP: f32 = 14.;
|
||||
const TILE_MIN_W: f32 = 190.;
|
||||
const TILE_H: f32 = 104.;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct TilesRow {
|
||||
analytics: Analytics,
|
||||
}
|
||||
|
||||
impl Component for TilesRow {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let width = use_state(|| 0f32);
|
||||
let cols = columns_for(*width.read(), TILE_MIN_W, 4, TILE_GAP);
|
||||
|
||||
let stats = &self.analytics.playtime;
|
||||
let avg_session = if stats.session_count > 0 {
|
||||
stats.total_secs / stats.session_count as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let hero = hero_tile(
|
||||
IconType::ClockRewind,
|
||||
"Total playtime",
|
||||
format_duration_hm(stats.total_secs),
|
||||
),
|
||||
stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()),
|
||||
stat_tile(
|
||||
IconType::Calendar,
|
||||
"Avg / session",
|
||||
format_duration_hm(avg_session),
|
||||
),
|
||||
stat_tile(
|
||||
IconType::Rocket02,
|
||||
"Active days",
|
||||
stats.active_days.to_string(),
|
||||
),
|
||||
];
|
||||
if force_all || !analytics.servers.is_empty() {
|
||||
tiles.push(stat_tile(
|
||||
IconType::Globe01,
|
||||
"Server joins",
|
||||
format!("{total_joins}"),
|
||||
));
|
||||
}
|
||||
format_duration(stats.total_secs),
|
||||
format!(
|
||||
"over {} session{}",
|
||||
stats.session_count,
|
||||
plural(stats.session_count as i64)
|
||||
),
|
||||
);
|
||||
|
||||
let mut rest = vec![
|
||||
stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()),
|
||||
stat_tile(
|
||||
IconType::Sliders04,
|
||||
"Avg / session",
|
||||
format_duration(avg_session),
|
||||
),
|
||||
stat_tile(
|
||||
IconType::Maximize01,
|
||||
"Longest session",
|
||||
format_duration(stats.longest_session_secs),
|
||||
),
|
||||
stat_tile(
|
||||
IconType::Rocket02,
|
||||
"Day streak",
|
||||
streak_value(stats.current_streak),
|
||||
),
|
||||
stat_tile(
|
||||
IconType::CheckCircle,
|
||||
"Best streak",
|
||||
streak_value(stats.longest_streak),
|
||||
),
|
||||
stat_tile(
|
||||
IconType::Calendar,
|
||||
"Days played",
|
||||
stats.active_days.to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
let mut root = rect().vertical().width(Size::fill()).spacing(TILE_GAP);
|
||||
|
||||
if cols >= 3 {
|
||||
let alongside: Vec<Element> = rest.drain(..(cols - 2).min(rest.len())).collect();
|
||||
let mut lead = rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.height(Size::px(TILE_H))
|
||||
.spacing(TILE_GAP)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::flex(2.0))
|
||||
.height(Size::fill())
|
||||
.child(hero),
|
||||
);
|
||||
for tile in alongside {
|
||||
lead = lead.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::fill())
|
||||
.child(tile),
|
||||
);
|
||||
}
|
||||
root = root.child(lead);
|
||||
} else {
|
||||
root = root.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(TILE_H))
|
||||
.child(hero),
|
||||
);
|
||||
}
|
||||
|
||||
let rows: Vec<Element> = rest
|
||||
.into_iter()
|
||||
.map(|tile| {
|
||||
rect()
|
||||
.height(Size::px(TILE_H))
|
||||
.width(Size::flex(1.0))
|
||||
.child(tile)
|
||||
.into_element()
|
||||
})
|
||||
.collect();
|
||||
|
||||
root.child(flow_grid(rows, cols, width, TILE_GAP))
|
||||
}
|
||||
}
|
||||
|
||||
fn streak_value(days: usize) -> String {
|
||||
format!("{days}d")
|
||||
}
|
||||
|
||||
fn icon_chip(icon: IconType, size: f32, tint: Color) -> Element {
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::px(size))
|
||||
.height(Size::px(size))
|
||||
.corner_radius(CornerRadius::new_all(size * 0.5))
|
||||
.background(tint.with_a(38))
|
||||
.center()
|
||||
.child(Icon::new(icon).size(size * 0.52).color(tint))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn hero_tile(icon: IconType, caption: &str, value: String, note: String) -> Element {
|
||||
card()
|
||||
.width(Size::fill())
|
||||
.spacing(16.)
|
||||
.children(tiles)
|
||||
.height(Size::fill())
|
||||
.padding(Gaps::new_symmetric(16., 20.))
|
||||
.spacing(10.)
|
||||
.background(colors::brand().with_a(18))
|
||||
.border(border_all_color(1., colors::brand().with_a(90)))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(icon_chip(icon, 24., colors::brand()))
|
||||
.child(
|
||||
label()
|
||||
.text(caption.to_string())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::End)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
label()
|
||||
.text(value)
|
||||
.font_size(30.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
rect().margin(Gaps::new(0., 0., 5., 0.)).child(
|
||||
label()
|
||||
.text(note)
|
||||
.font_size(12.)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn stat_tile(icon: IconType, caption: &str, value: String) -> Element {
|
||||
card()
|
||||
.width(Size::flex(1.0))
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(Icon::new(icon).size(16.).color(colors::fg_secondary()))
|
||||
.child(icon_chip(icon, 24., colors::fg_secondary()))
|
||||
.child(
|
||||
label()
|
||||
.text(caption.to_string())
|
||||
.font_size(12.)
|
||||
.max_lines(1)
|
||||
.width(Size::fill())
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(value)
|
||||
.font_size(26.)
|
||||
.font_size(24.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
pub(super) fn personas_row(personas: &[Persona]) -> Element {
|
||||
let mut row = rect().horizontal().width(Size::fill()).spacing(16.);
|
||||
for persona in personas {
|
||||
row = row.child(persona_card(*persona));
|
||||
}
|
||||
row.into_element()
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Wrap {
|
||||
wrap_spacing: Some(10.),
|
||||
})
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.children(personas.iter().map(|p| persona_badge(*p)))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn persona_badge(persona: Persona) -> Element {
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(7.)
|
||||
.padding(Gaps::new_symmetric(6., 11.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(colors::component_bg())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.child(
|
||||
Icon::new(persona_icon(persona))
|
||||
.size(13.)
|
||||
.color(colors::brand()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(persona.title().to_string())
|
||||
.font_size(13.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(persona.description().to_string())
|
||||
.font_size(11.)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn persona_icon(persona: Persona) -> IconType {
|
||||
match persona {
|
||||
Persona::NightOwl => IconType::ClockRewind,
|
||||
Persona::Veteran => IconType::ClockRewind,
|
||||
Persona::Marathoner => IconType::Maximize01,
|
||||
Persona::Regular => IconType::CheckCircle,
|
||||
Persona::Loyalist => IconType::Key01,
|
||||
Persona::Explorer => IconType::Globe01,
|
||||
Persona::NightOwl => IconType::Eye,
|
||||
Persona::EarlyBird => IconType::Bell01,
|
||||
Persona::WeekendWarrior => IconType::Calendar,
|
||||
Persona::Gamer => IconType::Rocket02,
|
||||
Persona::Sprinter => IconType::Play,
|
||||
}
|
||||
}
|
||||
|
||||
fn persona_card(persona: Persona) -> Element {
|
||||
card()
|
||||
.width(Size::flex(1.0))
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(14.)
|
||||
.border(border_all_color(1., colors::brand()))
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::px(44.))
|
||||
.height(Size::px(44.))
|
||||
.corner_radius(CornerRadius::new_all(22.))
|
||||
.background(colors::brand().with_a(40))
|
||||
.center()
|
||||
.child(
|
||||
Icon::new(persona_icon(persona))
|
||||
.size(22.)
|
||||
.color(colors::brand()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.spacing(2.)
|
||||
.child(
|
||||
label()
|
||||
.text(persona.title().to_string())
|
||||
.font_size(18.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(persona.description().to_string())
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ pub(super) fn grid_row(
|
|||
cluster_id: i64,
|
||||
package_type: &str,
|
||||
installed: &InstalledMap,
|
||||
metrics: GridMetrics,
|
||||
cols: usize,
|
||||
) -> impl IntoElement {
|
||||
let package_type = package_type.to_string();
|
||||
let fill = metrics.cols.saturating_sub(row.len());
|
||||
let fill = cols.saturating_sub(row.len());
|
||||
let installed: Vec<Option<InstallSource>> = row
|
||||
.iter()
|
||||
.map(|item| installed_for(installed, item))
|
||||
|
|
@ -43,14 +43,14 @@ pub(super) fn grid_row(
|
|||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(metrics.card_h))
|
||||
.height(Size::px(CARD_H))
|
||||
.spacing(GRID_SPACING)
|
||||
.content(Content::Flex)
|
||||
.children(
|
||||
row.into_iter()
|
||||
.zip(installed)
|
||||
.map(move |(item, installed)| {
|
||||
PackageCard::new(item, cluster_id, package_type.clone(), installed, metrics)
|
||||
PackageCard::new(item, cluster_id, package_type.clone(), installed)
|
||||
.into_element()
|
||||
}),
|
||||
)
|
||||
|
|
@ -77,7 +77,6 @@ struct PackageCard {
|
|||
cluster_id: i64,
|
||||
package_type: String,
|
||||
installed: Option<InstallSource>,
|
||||
metrics: GridMetrics,
|
||||
}
|
||||
|
||||
impl PackageCard {
|
||||
|
|
@ -86,14 +85,12 @@ impl PackageCard {
|
|||
cluster_id: i64,
|
||||
package_type: String,
|
||||
installed: Option<InstallSource>,
|
||||
metrics: GridMetrics,
|
||||
) -> Self {
|
||||
Self {
|
||||
item,
|
||||
cluster_id,
|
||||
package_type,
|
||||
installed,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,7 +110,7 @@ impl Component for PackageCard {
|
|||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::px(self.metrics.card_h))
|
||||
.height(Size::px(CARD_H))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(CARD_BG)
|
||||
.border(border_all_color(
|
||||
|
|
@ -135,7 +132,7 @@ impl Component for PackageCard {
|
|||
.margin(Gaps::new_all(1.))
|
||||
.corner_radius(CornerRadius::new(10., 10., 0., 0.))
|
||||
.overflow(Overflow::Clip)
|
||||
.child(PackageBanner::new(icon_url, self.metrics.banner_h))
|
||||
.child(PackageBanner::new(icon_url, BANNER_H))
|
||||
// Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's
|
||||
.maybe_child(self.installed.map(|installed| {
|
||||
rect()
|
||||
|
|
|
|||
|
|
@ -16,18 +16,18 @@ use oneclient_core::settings::ViewLayout;
|
|||
|
||||
use crate::components::{
|
||||
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
|
||||
cell_width, grid_columns_picker, resolved_columns,
|
||||
};
|
||||
use crate::hooks::use_cluster;
|
||||
use crate::hooks::{
|
||||
BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list,
|
||||
cluster_content_items, content_type_for_slug, pick_version_metadata, search_items,
|
||||
search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store,
|
||||
use_bundles_with_status, use_cluster_content, use_clusters, use_debounced,
|
||||
use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced,
|
||||
use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata,
|
||||
};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::grid_columns_for_width;
|
||||
|
||||
use super::{
|
||||
InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay,
|
||||
|
|
@ -45,29 +45,30 @@ use skeletons::{SkeletonListRow, skeleton_grid_row};
|
|||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
|
||||
const SCROLLBAR_GUTTER: f32 = 18.;
|
||||
const CATEGORY_SIDEBAR_W: f32 = 190.;
|
||||
const PROVIDER_LABELS_W: f32 = 900.;
|
||||
const WIDE_SEARCH_W: f32 = 780.;
|
||||
const SEARCH_W: f32 = 260.;
|
||||
const SEARCH_COMPACT_W: f32 = 170.;
|
||||
const CARD_H: f32 = 240.;
|
||||
const BANNER_H: f32 = 100.;
|
||||
const CARD_TEXT_H: f32 = CARD_H - BANNER_H;
|
||||
const BANNER_RATIO: f32 = 0.35;
|
||||
const BANNER_MAX_H: f32 = 200.;
|
||||
const MAX_CARD_W: f32 = 400.;
|
||||
const LIST_ROW_H: f32 = 78.;
|
||||
const GRID_SPACING: f32 = 12.;
|
||||
const LIST_SPACING: f32 = 8.;
|
||||
const SEARCH_DEBOUNCE_MS: u64 = 250;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) struct GridMetrics {
|
||||
pub cols: usize,
|
||||
pub card_h: f32,
|
||||
pub banner_h: f32,
|
||||
}
|
||||
const BROWSE_TYPES: [(&str, &str); 3] = [
|
||||
("mod", "Mods"),
|
||||
("texture", "Textures"),
|
||||
("shader", "Shaders"),
|
||||
];
|
||||
|
||||
fn type_title(package_type: &str) -> &'static str {
|
||||
match package_type {
|
||||
"shader" => "Shaders",
|
||||
"texture" => "Textures",
|
||||
_ => "Mods",
|
||||
}
|
||||
BROWSE_TYPES
|
||||
.iter()
|
||||
.find(|(slug, _)| *slug == package_type)
|
||||
.map_or("Mods", |(_, title)| *title)
|
||||
}
|
||||
|
||||
fn encode_package_id(provider: ProviderId, id: &str) -> String {
|
||||
|
|
@ -83,6 +84,37 @@ pub struct Browser {
|
|||
}
|
||||
|
||||
impl Component for Browser {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut last_type = use_browser_type();
|
||||
use_side_effect_with_deps(&self.package_type, move |package_type| {
|
||||
last_type.set_if_modified(package_type.clone());
|
||||
});
|
||||
|
||||
BrowserBody {
|
||||
cluster_id: self.cluster_id,
|
||||
package_type: self.package_type.clone(),
|
||||
pick_cluster: self.pick_cluster,
|
||||
key: DiffKey::None,
|
||||
}
|
||||
.key((self.cluster_id, self.package_type.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct BrowserBody {
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
pick_cluster: bool,
|
||||
key: DiffKey,
|
||||
}
|
||||
|
||||
impl KeyExt for BrowserBody {
|
||||
fn write_key(&mut self) -> &mut DiffKey {
|
||||
&mut self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BrowserBody {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let cluster_id = self.cluster_id;
|
||||
let package_type = self.package_type.clone();
|
||||
|
|
@ -94,9 +126,7 @@ impl Component for Browser {
|
|||
|
||||
let query = use_state(|| saved.query.clone());
|
||||
let provider = use_state(|| saved.provider);
|
||||
let view = use_view_state(&format!("browser.{package_type}"));
|
||||
let view_mode = view.layout;
|
||||
let grid_columns = view.columns;
|
||||
let view_mode = use_view_state(&format!("browser.{package_type}")).layout;
|
||||
let compatible_only = use_browser_compat();
|
||||
let selected_categories = use_state(|| saved.categories.clone());
|
||||
let page = use_state(|| saved.page);
|
||||
|
|
@ -191,14 +221,12 @@ impl Component for Browser {
|
|||
let mode = *view_mode.read();
|
||||
|
||||
let mut grid_width = use_state(|| 0f32);
|
||||
let cols = resolved_columns(*grid_columns.read());
|
||||
let card_w = cell_width(
|
||||
let controls_width = use_state(|| 0f32);
|
||||
let cols = grid_columns_for_width(
|
||||
(*grid_width.read() - SCROLLBAR_GUTTER).max(0.),
|
||||
cols,
|
||||
MAX_CARD_W,
|
||||
GRID_SPACING,
|
||||
);
|
||||
let banner_h = (card_w * BANNER_RATIO).clamp(BANNER_H, BANNER_MAX_H);
|
||||
let card_h = banner_h + CARD_TEXT_H;
|
||||
|
||||
let fade_dep = (current_page, packages.is_empty(), pending, mode);
|
||||
let fade = use_animation_with_dependencies(&fade_dep, |conf, _| {
|
||||
|
|
@ -222,19 +250,8 @@ impl Component for Browser {
|
|||
let rows: Vec<Vec<ProjectSummary>> =
|
||||
packages.chunks(cols).map(|c| c.to_vec()).collect();
|
||||
|
||||
sa.lazy(rows.len(), card_h, GRID_SPACING, move |i| {
|
||||
grid_row(
|
||||
rows[i].clone(),
|
||||
cluster_id,
|
||||
&pkg,
|
||||
&installed,
|
||||
GridMetrics {
|
||||
cols,
|
||||
card_h,
|
||||
banner_h,
|
||||
},
|
||||
)
|
||||
.into_element()
|
||||
sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| {
|
||||
grid_row(rows[i].clone(), cluster_id, &pkg, &installed, cols).into_element()
|
||||
})
|
||||
}
|
||||
ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| {
|
||||
|
|
@ -245,13 +262,8 @@ impl Component for Browser {
|
|||
match mode {
|
||||
ViewLayout::Grid => {
|
||||
let rows = BROWSE_PAGE_SIZE.div_ceil(cols);
|
||||
sa.lazy(rows, card_h, GRID_SPACING, move |_| {
|
||||
skeleton_grid_row(GridMetrics {
|
||||
cols,
|
||||
card_h,
|
||||
banner_h,
|
||||
})
|
||||
.into_element()
|
||||
sa.lazy(rows, CARD_H, GRID_SPACING, move |_| {
|
||||
skeleton_grid_row(cols).into_element()
|
||||
})
|
||||
}
|
||||
ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| {
|
||||
|
|
@ -278,7 +290,17 @@ impl Component for Browser {
|
|||
package_type: package_type.clone(),
|
||||
}),
|
||||
))
|
||||
.child(controls(provider, query, view_mode, grid_columns))
|
||||
.child(controls(
|
||||
TypePicker {
|
||||
cluster_id,
|
||||
package_type: package_type.clone(),
|
||||
pick_cluster: self.pick_cluster,
|
||||
},
|
||||
provider,
|
||||
query,
|
||||
view_mode,
|
||||
controls_width,
|
||||
))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -319,6 +341,10 @@ impl Component for Browser {
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_key(&self) -> DiffKey {
|
||||
self.key.clone().or(self.default_key())
|
||||
}
|
||||
}
|
||||
|
||||
fn page_title(
|
||||
|
|
@ -341,7 +367,7 @@ fn page_title(
|
|||
// Without the picker the cluster is fixed so it's just a subtitle
|
||||
None => cluster_name.map(|name| {
|
||||
label()
|
||||
.text(format!("for {name}"))
|
||||
.text(name)
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element()
|
||||
|
|
@ -364,6 +390,41 @@ fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
|
|||
.unwrap_or_else(|| cluster.mc_version.clone())
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct TypePicker {
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
pick_cluster: bool,
|
||||
}
|
||||
|
||||
impl Component for TypePicker {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let cluster_id = self.cluster_id;
|
||||
let pick_cluster = self.pick_cluster;
|
||||
let current = self.package_type.clone();
|
||||
|
||||
let labels: Vec<String> = BROWSE_TYPES
|
||||
.iter()
|
||||
.map(|(_, title)| (*title).to_string())
|
||||
.collect();
|
||||
let selected = type_title(&self.package_type).to_string();
|
||||
|
||||
Dropdown::new(selected, labels)
|
||||
.width(Size::px(CATEGORY_SIDEBAR_W))
|
||||
.height(Size::px(30.))
|
||||
.on_select(move |idx: usize| {
|
||||
let picked = BROWSE_TYPES.get(idx).filter(|(slug, _)| *slug != current);
|
||||
if let Some((slug, _)) = picked {
|
||||
let _ = RouterContext::get().push(Route::Browser {
|
||||
cluster_id,
|
||||
package_type: (*slug).to_string(),
|
||||
pick_cluster,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The choice lives in the route so back and forward keep the cluster being browsed
|
||||
#[derive(PartialEq)]
|
||||
struct ClusterPicker {
|
||||
|
|
@ -396,12 +457,6 @@ impl Component for ClusterPicker {
|
|||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.margin(Gaps::new(4., 0., 0., 0.))
|
||||
.child(
|
||||
label()
|
||||
.text("for")
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
Dropdown::new(selected, labels)
|
||||
.width(Size::px(240.))
|
||||
|
|
@ -420,22 +475,34 @@ impl Component for ClusterPicker {
|
|||
}
|
||||
|
||||
fn controls(
|
||||
type_picker: TypePicker,
|
||||
provider: State<ProviderId>,
|
||||
query: State<String>,
|
||||
view_mode: State<ViewLayout>,
|
||||
grid_columns: State<u8>,
|
||||
mut controls_width: State<f32>,
|
||||
) -> impl IntoElement {
|
||||
let measured = *controls_width.read();
|
||||
let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W;
|
||||
let search_width = if measured == 0. || measured >= WIDE_SEARCH_W {
|
||||
SEARCH_W
|
||||
} else {
|
||||
SEARCH_COMPACT_W
|
||||
};
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.content(Content::Flex)
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let w = event.data().area.width();
|
||||
if (w - *controls_width.peek()).abs() > 0.5 {
|
||||
*controls_width.write() = w;
|
||||
}
|
||||
})
|
||||
.child(type_picker)
|
||||
.child(rect().width(Size::flex(1.0)))
|
||||
.maybe_child(
|
||||
(*view_mode.read() == ViewLayout::Grid)
|
||||
.then(|| grid_columns_picker(grid_columns, 30.).into_element()),
|
||||
)
|
||||
.child(
|
||||
SegmentedControl::new(view_mode)
|
||||
.equal_width(30.)
|
||||
|
|
@ -446,16 +513,19 @@ fn controls(
|
|||
.child(
|
||||
SegmentedControl::new(provider)
|
||||
.no_tint()
|
||||
.segments(ProviderId::REMOTE_PROVIDERS.iter().map(|provider| {
|
||||
Segment::new(*provider)
|
||||
.icon(IconType::from(*provider))
|
||||
.label(provider.to_string())
|
||||
.segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| {
|
||||
let segment = Segment::new(*provider).icon(IconType::from(*provider));
|
||||
if show_provider_labels {
|
||||
segment.label(provider.to_string())
|
||||
} else {
|
||||
segment
|
||||
}
|
||||
}))
|
||||
.into_element(),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(query)
|
||||
.width(Size::px(260.))
|
||||
.width(Size::px(search_width))
|
||||
.placeholder("Search for content")
|
||||
.leading(
|
||||
Icon::new(IconType::SearchMd)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ impl Component for CategorySidebar {
|
|||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(190.))
|
||||
.width(Size::px(CATEGORY_SIDEBAR_W))
|
||||
.height(Size::fill())
|
||||
.spacing(8.)
|
||||
.child(
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ fn use_skeleton_pulse() -> f32 {
|
|||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SkeletonCard {
|
||||
metrics: GridMetrics,
|
||||
}
|
||||
struct SkeletonCard;
|
||||
|
||||
impl Component for SkeletonCard {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
|
|
@ -37,13 +35,13 @@ impl Component for SkeletonCard {
|
|||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::px(self.metrics.card_h))
|
||||
.height(Size::px(CARD_H))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(CARD_BG)
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.overflow(Overflow::Clip)
|
||||
.opacity(pulse)
|
||||
.child(skeleton_block(Size::fill(), self.metrics.banner_h))
|
||||
.child(skeleton_block(Size::fill(), BANNER_H))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -90,13 +88,13 @@ impl Component for SkeletonListRow {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn skeleton_grid_row(metrics: GridMetrics) -> impl IntoElement {
|
||||
pub(super) fn skeleton_grid_row(cols: usize) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(metrics.card_h))
|
||||
.height(Size::px(CARD_H))
|
||||
.spacing(GRID_SPACING)
|
||||
.content(Content::Flex)
|
||||
.children((0..metrics.cols).map(move |_| SkeletonCard { metrics }.into_element()))
|
||||
.children((0..cols).map(|_| SkeletonCard.into_element()))
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}
|
|||
use crate::components::{Icon, IconType};
|
||||
use crate::hooks::{loaded_image, use_cached_image};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::ui::{ImageFallbackExt, border_all_color};
|
||||
|
||||
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
|
||||
|
||||
|
|
@ -251,29 +251,36 @@ impl Component for Thumbnail {
|
|||
let query = use_cached_image(self.icon_url.clone(), 256);
|
||||
let loaded = loaded_image(self.icon_url.as_deref(), &query);
|
||||
|
||||
let placeholder = thumbnail_placeholder(size, radius, 0.4);
|
||||
|
||||
match loaded {
|
||||
Some((url, bytes)) => ImageViewer::new((url, bytes))
|
||||
.width(Size::px(size))
|
||||
.height(Size::px(size))
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.corner_radius(CornerRadius::new_all(radius))
|
||||
.fallback(placeholder)
|
||||
.into_element(),
|
||||
None => rect()
|
||||
.center()
|
||||
.width(Size::px(size))
|
||||
.height(Size::px(size))
|
||||
.corner_radius(CornerRadius::new_all(radius))
|
||||
.background(colors::component_bg())
|
||||
.child(
|
||||
Icon::new(IconType::DotsGrid)
|
||||
.size(size * 0.4)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element(),
|
||||
None => placeholder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element {
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(size))
|
||||
.height(Size::px(size))
|
||||
.corner_radius(CornerRadius::new_all(radius))
|
||||
.background(colors::component_bg())
|
||||
.child(
|
||||
Icon::new(IconType::DotsGrid)
|
||||
.size(size * icon_ratio)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) struct PackageBanner {
|
||||
icon_url: Option<String>,
|
||||
|
|
@ -311,6 +318,8 @@ impl Component for PackageBanner {
|
|||
.overflow(Overflow::Clip)
|
||||
.background(BANNER_BG);
|
||||
|
||||
let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45);
|
||||
|
||||
match loaded {
|
||||
Some((url, bytes)) => banner
|
||||
.child(
|
||||
|
|
@ -324,7 +333,8 @@ impl Component for PackageBanner {
|
|||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.aspect_ratio(AspectRatio::Max)
|
||||
.image_cover(ImageCover::Center),
|
||||
.image_cover(ImageCover::Center)
|
||||
.fallback(rect().width(Size::fill()).height(Size::fill())),
|
||||
)
|
||||
.layer(Layer::Relative(1)),
|
||||
)
|
||||
|
|
@ -347,23 +357,12 @@ impl Component for PackageBanner {
|
|||
.width(Size::px(icon))
|
||||
.height(Size::px(icon))
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.corner_radius(CornerRadius::new_all(10.)),
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.fallback(icon_placeholder),
|
||||
)
|
||||
.layer(Layer::Relative(5)),
|
||||
),
|
||||
None => banner.child(
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(icon))
|
||||
.height(Size::px(icon))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(colors::component_bg())
|
||||
.child(
|
||||
Icon::new(IconType::DotsGrid)
|
||||
.size(icon * 0.45)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
),
|
||||
None => banner.child(icon_placeholder),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,14 @@ use super::*;
|
|||
|
||||
use oneclient_content::packages::types::GalleryImage;
|
||||
|
||||
use crate::components::{
|
||||
Button, Icon, IconType, OverlayPopup, cell_width, grid_columns_picker, resolved_columns,
|
||||
};
|
||||
use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state};
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup};
|
||||
use crate::hooks::{loaded_image, query_is_busy, use_cached_image};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::{border_all_color, flow_grid};
|
||||
use crate::ui::{ImageFallbackExt, border_all_color, flow_grid, grid_columns_for_width};
|
||||
|
||||
const MAX_COL_W: f32 = 400.;
|
||||
const GRID_GAP: f32 = 16.;
|
||||
const TILE_PREVIEW_H: f32 = 168.;
|
||||
const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W;
|
||||
const TILE_PREVIEW_MAX_H: f32 = 420.;
|
||||
|
||||
/// Downscale ceilings for the image cache full-size tiles made the grid slow to fill
|
||||
const TILE_EDGE: u32 = 640;
|
||||
|
|
@ -35,11 +31,6 @@ impl Component for Gallery {
|
|||
let grid_width = use_state(|| 0f32);
|
||||
let mut viewing = use_state(|| None::<usize>);
|
||||
|
||||
let grid_columns = use_view_state("browser.gallery").columns;
|
||||
let cols = resolved_columns(*grid_columns.read());
|
||||
let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP);
|
||||
let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H);
|
||||
|
||||
let tiles: Vec<Element> = self
|
||||
.images
|
||||
.iter()
|
||||
|
|
@ -48,7 +39,6 @@ impl Component for Gallery {
|
|||
let key = image.url.clone();
|
||||
GalleryTile {
|
||||
image: image.clone(),
|
||||
preview_h,
|
||||
on_open: (move |()| viewing.set(Some(idx))).into(),
|
||||
key: DiffKey::None,
|
||||
}
|
||||
|
|
@ -57,21 +47,11 @@ impl Component for Gallery {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
|
||||
let images = self.images.clone();
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.content(Content::Flex)
|
||||
.child(rect().width(Size::flex(1.0)))
|
||||
.child(grid_columns_picker(grid_columns, 30.)),
|
||||
)
|
||||
.child(flow_grid(tiles, cols, grid_width, GRID_GAP))
|
||||
.maybe_child((*viewing.read()).map(|start| {
|
||||
GalleryViewer {
|
||||
|
|
@ -87,7 +67,6 @@ impl Component for Gallery {
|
|||
#[derive(PartialEq)]
|
||||
struct GalleryTile {
|
||||
image: GalleryImage,
|
||||
preview_h: f32,
|
||||
on_open: EventHandler<()>,
|
||||
key: DiffKey,
|
||||
}
|
||||
|
|
@ -108,7 +87,7 @@ impl Component for GalleryTile {
|
|||
|
||||
let preview = rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(self.preview_h))
|
||||
.height(Size::px(TILE_PREVIEW_H))
|
||||
.center()
|
||||
.overflow(Overflow::Clip)
|
||||
.background(colors::component_bg())
|
||||
|
|
@ -118,12 +97,14 @@ impl Component for GalleryTile {
|
|||
.height(Size::fill())
|
||||
.aspect_ratio(AspectRatio::Max)
|
||||
.image_cover(ImageCover::Center)
|
||||
.fallback(broken_image(32.))
|
||||
.into_element()
|
||||
}));
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.max_width(Size::px(MAX_COL_W))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.overflow(Overflow::Clip)
|
||||
.background(PANEL_BG)
|
||||
|
|
@ -202,6 +183,7 @@ impl Component for GalleryViewer {
|
|||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.fallback(broken_image(48.))
|
||||
.into_element()
|
||||
}))
|
||||
// A full-size fetch separate from the tile's cached copy a failed fetch is not loading
|
||||
|
|
@ -265,6 +247,19 @@ impl Component for GalleryViewer {
|
|||
}
|
||||
}
|
||||
|
||||
fn broken_image(size: f32) -> Element {
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.child(
|
||||
Icon::new(IconType::FileX02)
|
||||
.size(size)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn chevron_btn(
|
||||
icon: IconType,
|
||||
enabled: bool,
|
||||
|
|
@ -337,6 +332,7 @@ fn header_row(
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Close")
|
||||
.on_press(on_close)
|
||||
.child(Icon::new(IconType::XClose).size(18.)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ impl Component for ClusterSettings {
|
|||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.spacing(4.)
|
||||
.child(section_header("GAME"))
|
||||
.child(
|
||||
|
|
@ -184,6 +185,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen
|
|||
.small()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip(if overridden {
|
||||
"Reset to the global setting"
|
||||
} else {
|
||||
"Already using the global setting"
|
||||
})
|
||||
.corner_radius(CornerRadius::new_all(7.))
|
||||
.maybe(overridden, |el| el.on_press(move |_| on_reset.call(())))
|
||||
.enabled(overridden)
|
||||
|
|
@ -357,8 +363,8 @@ impl Component for MemoryRow {
|
|||
let overridden = self.value.is_some();
|
||||
let dispatch = use_dispatch();
|
||||
|
||||
let global = self.global.to_string();
|
||||
let initial = self.value.unwrap_or(self.global).to_string();
|
||||
let global = self.global;
|
||||
let initial = self.value.map(|v| v.to_string()).unwrap_or_default();
|
||||
let mut memory = use_state({
|
||||
let v = initial.clone();
|
||||
move || v
|
||||
|
|
@ -387,13 +393,13 @@ impl Component for MemoryRow {
|
|||
}
|
||||
|
||||
let on_reset: EventHandler<()> = (move |()| {
|
||||
last.set(global.clone());
|
||||
memory.set(global.clone());
|
||||
last.set(String::new());
|
||||
memory.set(String::new());
|
||||
dispatch.update_cluster_profile(cluster_id, clear_update(Field::MemMax));
|
||||
})
|
||||
.into();
|
||||
|
||||
let control = memory_field(memory);
|
||||
let control = memory_field(memory, "Global", global);
|
||||
|
||||
settings_row(
|
||||
IconType::Database01,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ impl Component for ClusterOverview {
|
|||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
|
|||
|
|
@ -419,10 +419,10 @@ impl Component for PackageManager {
|
|||
let search = use_state(String::new);
|
||||
let enabled_filter = use_state(|| EnabledFilter::All);
|
||||
let hidden_filter = use_state(|| HiddenFilter::Hide);
|
||||
let toolbar_width = use_state(|| 0f32);
|
||||
let view = use_view_state("cluster.packages");
|
||||
let sort = view.sort;
|
||||
let layout = view.layout;
|
||||
let grid_columns = view.columns;
|
||||
let query = SearchQuery::new(&search.read());
|
||||
let sort_mode = sort
|
||||
.read()
|
||||
|
|
@ -475,10 +475,15 @@ impl Component for PackageManager {
|
|||
enabled_filter,
|
||||
hidden_filter,
|
||||
layout,
|
||||
grid_columns,
|
||||
cluster_id,
|
||||
package_type,
|
||||
toolbar_width,
|
||||
))
|
||||
.maybe_child(
|
||||
content_type
|
||||
.is_global()
|
||||
.then(|| views::global_notice(noun_plural)),
|
||||
)
|
||||
.maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type)))
|
||||
.child(ContentBox::new(
|
||||
filtered,
|
||||
|
|
@ -488,7 +493,6 @@ impl Component for PackageManager {
|
|||
cluster_id,
|
||||
content_kind,
|
||||
card_layout,
|
||||
*grid_columns.read(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use oneclient_core::settings::ViewLayout;
|
|||
|
||||
use crate::components::{
|
||||
Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx,
|
||||
Segment, SegmentedControl, TabBar, TabItem, TextInput, grid_columns_picker, resolved_columns,
|
||||
Segment, SegmentedControl, TabBar, TabItem, TextInput,
|
||||
};
|
||||
use crate::hooks::{use_dispatch, use_overlay_claim};
|
||||
use crate::routes::Route;
|
||||
|
|
@ -122,6 +122,17 @@ impl HiddenFilter {
|
|||
|
||||
const FILTER_PANEL_W: f32 = 172.;
|
||||
const FILTER_BTN_W: f32 = 34.;
|
||||
const TOOLBAR_STACK_W: f32 = 640.;
|
||||
const TABS_ROW_H: f32 = 34.;
|
||||
|
||||
fn tabs_scrollbar_theme() -> ScrollBarThemePartial {
|
||||
ScrollBarThemePartial {
|
||||
thumb_background: Some(colors::fg_secondary().with_a(120).into()),
|
||||
hover_thumb_background: Some(colors::fg_secondary().with_a(190).into()),
|
||||
active_thumb_background: Some(colors::fg_primary().into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn toolbar_bar(
|
||||
|
|
@ -134,9 +145,9 @@ pub(super) fn toolbar_bar(
|
|||
enabled_filter: State<EnabledFilter>,
|
||||
hidden_filter: State<HiddenFilter>,
|
||||
layout: State<ViewLayout>,
|
||||
grid_columns: State<u8>,
|
||||
cluster_id: i64,
|
||||
package_type: &'static str,
|
||||
mut toolbar_width: State<f32>,
|
||||
) -> impl IntoElement {
|
||||
let tab_items = tabs.iter().enumerate().map(|(i, tab)| {
|
||||
let mut active = active;
|
||||
|
|
@ -146,69 +157,127 @@ pub(super) fn toolbar_bar(
|
|||
let mut top_corners = CornerRadius::new_all(0.);
|
||||
top_corners.fill_top(12.);
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.content(Content::Flex)
|
||||
.overflow(Overflow::Clip)
|
||||
.padding(Gaps::new_symmetric(8., 12.))
|
||||
.corner_radius(top_corners)
|
||||
.background(colors::page_elevated())
|
||||
let measured = *toolbar_width.read();
|
||||
let stacked = measured > 0. && measured < TOOLBAR_STACK_W;
|
||||
|
||||
let filter_tabs = ScrollView::new()
|
||||
.direction(Direction::Horizontal)
|
||||
.invert_scroll_wheel(true)
|
||||
.show_scrollbar(stacked)
|
||||
.scrollbar_theme(tabs_scrollbar_theme())
|
||||
.width(if stacked {
|
||||
Size::fill()
|
||||
} else {
|
||||
Size::flex(1.0)
|
||||
})
|
||||
.height(if stacked {
|
||||
Size::fill()
|
||||
} else {
|
||||
Size::auto()
|
||||
})
|
||||
.child(
|
||||
ScrollView::new()
|
||||
.direction(Direction::Horizontal)
|
||||
.show_scrollbar(false)
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::auto())
|
||||
.child(
|
||||
TabBar::new()
|
||||
.width(Size::auto())
|
||||
.height(Size::auto())
|
||||
.spacing(20.)
|
||||
.font_size(12.)
|
||||
.tabs(tab_items),
|
||||
),
|
||||
TabBar::new()
|
||||
.width(Size::auto())
|
||||
.height(if stacked {
|
||||
Size::fill()
|
||||
} else {
|
||||
Size::auto()
|
||||
})
|
||||
.spacing(20.)
|
||||
.font_size(12.)
|
||||
.tabs(tab_items),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(search)
|
||||
.placeholder("Search...")
|
||||
.width(Size::px(180.))
|
||||
.leading(
|
||||
Icon::new(IconType::SearchMd)
|
||||
.size(14.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element(),
|
||||
),
|
||||
)
|
||||
.child(FilterButton {
|
||||
.into_element();
|
||||
|
||||
let mut controls: Vec<Element> = vec![
|
||||
TextInput::new(search)
|
||||
.placeholder("Search...")
|
||||
.width(Size::px(180.))
|
||||
.leading(
|
||||
Icon::new(IconType::SearchMd)
|
||||
.size(14.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element(),
|
||||
)
|
||||
.into_element(),
|
||||
FilterButton {
|
||||
sort,
|
||||
current_sort,
|
||||
enabled_filter,
|
||||
hidden_filter,
|
||||
}
|
||||
.into_element(),
|
||||
];
|
||||
|
||||
controls.push(
|
||||
SegmentedControl::new(layout)
|
||||
.height(34.)
|
||||
.icon_size(15.)
|
||||
.equal_width(34.)
|
||||
.segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap))
|
||||
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid))
|
||||
.into_element(),
|
||||
);
|
||||
controls.push(
|
||||
Button::new()
|
||||
.primary()
|
||||
.height(Size::px(34.))
|
||||
.font_size(12.)
|
||||
.on_press(move |_| open_browser(cluster_id, package_type))
|
||||
.child(Icon::new(IconType::Plus).size(15.))
|
||||
.text("Add Content")
|
||||
.into_element(),
|
||||
);
|
||||
|
||||
let inner = if stacked {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(8.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(TABS_ROW_H))
|
||||
.cross_align(Alignment::Center)
|
||||
.child(filter_tabs),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.content(Content::Flex)
|
||||
.children(controls),
|
||||
)
|
||||
.into_element()
|
||||
} else {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.content(Content::Flex)
|
||||
.child(filter_tabs)
|
||||
.children(controls)
|
||||
.into_element()
|
||||
};
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.padding(Gaps::new_symmetric(8., 12.))
|
||||
.corner_radius(top_corners)
|
||||
.background(colors::page_elevated())
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let next = event.data().area.width();
|
||||
if (*toolbar_width.peek() - next).abs() > 0.5 {
|
||||
toolbar_width.set(next);
|
||||
}
|
||||
})
|
||||
.maybe_child(
|
||||
(*layout.read() == ViewLayout::Grid)
|
||||
.then(|| grid_columns_picker(grid_columns, 34.).into_element()),
|
||||
)
|
||||
.child(
|
||||
SegmentedControl::new(layout)
|
||||
.height(34.)
|
||||
.icon_size(15.)
|
||||
.equal_width(34.)
|
||||
.segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap))
|
||||
.segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.height(Size::px(34.))
|
||||
.font_size(12.)
|
||||
.on_press(move |_| open_browser(cluster_id, package_type))
|
||||
.child(Icon::new(IconType::Plus).size(15.))
|
||||
.text("Add Content"),
|
||||
)
|
||||
.child(inner)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +294,16 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp
|
|||
),
|
||||
};
|
||||
|
||||
notice_bar(text)
|
||||
}
|
||||
|
||||
pub(super) fn global_notice(noun_plural: &'static str) -> Element {
|
||||
notice_bar(format!(
|
||||
"These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere."
|
||||
))
|
||||
}
|
||||
|
||||
fn notice_bar(text: String) -> Element {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
|
|
@ -286,6 +365,7 @@ impl Component for FilterButton {
|
|||
Button::new()
|
||||
.secondary()
|
||||
.icon()
|
||||
.tooltip("Sort and filter")
|
||||
.width(Size::px(FILTER_BTN_W))
|
||||
.height(Size::px(34.))
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
|
|
@ -583,7 +663,6 @@ pub(super) struct ContentBox {
|
|||
cluster_id: i64,
|
||||
kind: ContentKind,
|
||||
layout: CardLayout,
|
||||
grid_columns: u8,
|
||||
}
|
||||
|
||||
impl ContentBox {
|
||||
|
|
@ -596,7 +675,6 @@ impl ContentBox {
|
|||
cluster_id: i64,
|
||||
kind: ContentKind,
|
||||
layout: CardLayout,
|
||||
grid_columns: u8,
|
||||
) -> Self {
|
||||
Self {
|
||||
items,
|
||||
|
|
@ -606,7 +684,6 @@ impl ContentBox {
|
|||
cluster_id,
|
||||
kind,
|
||||
layout,
|
||||
grid_columns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -620,7 +697,6 @@ impl Component for ContentBox {
|
|||
let noun_plural = self.noun_plural;
|
||||
let kind = &self.kind;
|
||||
let layout = self.layout;
|
||||
let grid_columns = self.grid_columns;
|
||||
|
||||
let dispatch = use_dispatch();
|
||||
|
||||
|
|
@ -631,6 +707,7 @@ impl Component for ContentBox {
|
|||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.lazy(count, CARD_H, CARD_SPACING, move |i| {
|
||||
let item = items[i].clone();
|
||||
let key = item.package_id.clone();
|
||||
|
|
@ -644,8 +721,9 @@ impl Component for ContentBox {
|
|||
CardLayout::Grid => ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.scrollbar_gutter(true)
|
||||
.content(move |ctx: ScrollAreaCtx| {
|
||||
grid_content(&items, package_type, cluster_id, grid_columns, ctx).into_element()
|
||||
grid_content(&items, package_type, cluster_id, ctx).into_element()
|
||||
})
|
||||
.into_element(),
|
||||
});
|
||||
|
|
@ -710,12 +788,11 @@ fn grid_content(
|
|||
items: &[PackageEntry],
|
||||
package_type: &'static str,
|
||||
cluster_id: i64,
|
||||
grid_columns: u8,
|
||||
ctx: ScrollAreaCtx,
|
||||
) -> impl IntoElement {
|
||||
let count = items.len();
|
||||
let fits = ((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize;
|
||||
let cols = resolved_columns(grid_columns).min(fits.max(1));
|
||||
let cols =
|
||||
(((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 5);
|
||||
let rows_total = count.div_ceil(cols);
|
||||
let slot = CARD_GRID_H + GRID_GAP;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ use oneclient_core::settings::ViewLayout;
|
|||
|
||||
use crate::components::{
|
||||
Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment,
|
||||
SegmentedControl, cell_width, grid_columns_picker, open_folder_button, resolved_columns,
|
||||
screenshot_context_menu,
|
||||
SegmentedControl, open_folder_button, screenshot_context_menu,
|
||||
};
|
||||
use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state};
|
||||
use crate::layout::cluster_content;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::{border_all_color, flow_grid, fmt_date};
|
||||
use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width};
|
||||
use crate::utils::{format_res, format_size};
|
||||
|
||||
use super::cluster_not_found;
|
||||
|
|
@ -23,8 +22,6 @@ const THUMB_EDGE: u32 = 480;
|
|||
const MAX_COL_W: f32 = 400.;
|
||||
const GRID_GAP: f32 = 16.;
|
||||
const TILE_PREVIEW_H: f32 = 168.;
|
||||
const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W;
|
||||
const TILE_PREVIEW_MAX_H: f32 = 420.;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
|
||||
|
|
@ -48,9 +45,7 @@ impl Component for ClusterScreenshots {
|
|||
|
||||
let shots = try_cluster_screenshots(&query).unwrap_or_default();
|
||||
|
||||
let view = use_view_state("cluster.screenshots");
|
||||
let view_mode = view.layout;
|
||||
let grid_columns = view.columns;
|
||||
let view_mode = use_view_state("cluster.screenshots").layout;
|
||||
let menu_dispatch = use_dispatch();
|
||||
|
||||
let mut edit_mode = use_state(|| false);
|
||||
|
|
@ -64,16 +59,11 @@ impl Component for ClusterScreenshots {
|
|||
&shots,
|
||||
folder,
|
||||
view_mode,
|
||||
grid_columns,
|
||||
edit_mode,
|
||||
selected,
|
||||
confirm_delete,
|
||||
);
|
||||
|
||||
let cols = resolved_columns(*grid_columns.read());
|
||||
let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP);
|
||||
let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H);
|
||||
|
||||
let content: Element = if shots.is_empty() {
|
||||
empty_state(query_is_loading(&query))
|
||||
.into_element()
|
||||
|
|
@ -108,7 +98,6 @@ impl Component for ClusterScreenshots {
|
|||
info,
|
||||
selected: is_selected,
|
||||
edit_mode: editing,
|
||||
preview_h,
|
||||
on_activate: on_activate.into(),
|
||||
on_context,
|
||||
}
|
||||
|
|
@ -126,7 +115,10 @@ impl Component for ClusterScreenshots {
|
|||
}
|
||||
|
||||
match *view_mode.read() {
|
||||
ViewLayout::Grid => flow_grid(items, cols, grid_width, GRID_GAP),
|
||||
ViewLayout::Grid => {
|
||||
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
|
||||
flow_grid(items, cols, grid_width, GRID_GAP)
|
||||
}
|
||||
ViewLayout::List => rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
|
|
@ -139,6 +131,7 @@ impl Component for ClusterScreenshots {
|
|||
let body = ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.scrollbar_gutter(true)
|
||||
.children(vec![content]);
|
||||
|
||||
let confirm_overlay = confirm_delete.read().then(|| {
|
||||
|
|
@ -184,7 +177,6 @@ fn toolbar_row(
|
|||
shots: &[ScreenshotInfo],
|
||||
folder: Option<PathBuf>,
|
||||
view_mode: State<ViewLayout>,
|
||||
grid_columns: State<u8>,
|
||||
mut edit_mode: State<bool>,
|
||||
mut selected: State<HashSet<PathBuf>>,
|
||||
mut confirm_delete: State<bool>,
|
||||
|
|
@ -252,10 +244,6 @@ fn toolbar_row(
|
|||
);
|
||||
} else {
|
||||
right = right
|
||||
.maybe_child(
|
||||
(*view_mode.read() == ViewLayout::Grid)
|
||||
.then(|| grid_columns_picker(grid_columns, 36.).into_element()),
|
||||
)
|
||||
.child(
|
||||
SegmentedControl::new(view_mode)
|
||||
.equal_width(40.)
|
||||
|
|
@ -405,7 +393,6 @@ struct ScreenshotTile {
|
|||
info: ScreenshotInfo,
|
||||
selected: bool,
|
||||
edit_mode: bool,
|
||||
preview_h: f32,
|
||||
on_activate: EventHandler<()>,
|
||||
on_context: EventHandler<(f32, f32)>,
|
||||
}
|
||||
|
|
@ -430,6 +417,7 @@ impl Component for ScreenshotTile {
|
|||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.max_width(Size::px(MAX_COL_W))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(CARD_BG)
|
||||
.overflow(Overflow::Clip)
|
||||
|
|
@ -445,7 +433,7 @@ impl Component for ScreenshotTile {
|
|||
})
|
||||
.child(preview_box(
|
||||
info.path.clone(),
|
||||
self.preview_h,
|
||||
TILE_PREVIEW_H,
|
||||
info.resolution,
|
||||
))
|
||||
.child(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const MIN_CARD_WIDTH_PX: f32 = 280.;
|
|||
const SIDEBAR_WIDTH_PX: f32 = 300.;
|
||||
const CARD_HEIGHT_PX: f32 = 146.;
|
||||
const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info";
|
||||
const ART_MAX_HEIGHT_PX: f32 = 140.;
|
||||
const ART_MIN_SIDEBAR_PX: f32 = 260.;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct Clusters;
|
||||
|
|
@ -196,6 +198,7 @@ struct DetailSidebar {
|
|||
|
||||
impl Component for DetailSidebar {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut sidebar_height = use_state(|| 0f32);
|
||||
let active_id = use_active_cluster_id();
|
||||
let dispatch = use_dispatch();
|
||||
let game = use_game_snapshot();
|
||||
|
|
@ -224,6 +227,8 @@ impl Component for DetailSidebar {
|
|||
.map(|m| m.tags.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let art_height = art_height_for(*sidebar_height.read());
|
||||
|
||||
rect()
|
||||
.width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
.min_width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
|
|
@ -231,13 +236,29 @@ impl Component for DetailSidebar {
|
|||
.vertical()
|
||||
.spacing(8.)
|
||||
.padding(8.)
|
||||
.content(Content::Flex)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.overflow(Overflow::Clip)
|
||||
.child(rect().width(Size::fill()).max_height(Size::px(140.)).child(
|
||||
ClusterLandscapeArt::for_version(self.line.major, version_value, loader_value, false),
|
||||
))
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let height = event.data().area.height();
|
||||
if (*sidebar_height.peek() - height).abs() > 0.5 {
|
||||
*sidebar_height.write() = height;
|
||||
}
|
||||
})
|
||||
.maybe_child(art_height.map(|max| {
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.max_height(Size::px(max))
|
||||
.child(ClusterLandscapeArt::for_version(
|
||||
self.line.major,
|
||||
version_value,
|
||||
loader_value,
|
||||
false,
|
||||
))
|
||||
.into_element()
|
||||
}))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -251,20 +272,27 @@ impl Component for DetailSidebar {
|
|||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.content(Content::Flex)
|
||||
.spacing(4.)
|
||||
.child(
|
||||
label()
|
||||
.text(heading)
|
||||
.font_size(24.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.maybe_child(tags_row(&tags))
|
||||
.child(
|
||||
label()
|
||||
.text(description)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(4.)
|
||||
.child(
|
||||
label()
|
||||
.text(heading)
|
||||
.font_size(24.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.children(tags_row(&tags))
|
||||
.child(
|
||||
label()
|
||||
.text(description)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.children(version_rows(self.line, &keys, version_value, self.selected_version))
|
||||
.children(loader_rows(&loaders, loader_value, self.selected_loader)),
|
||||
|
|
@ -288,6 +316,16 @@ impl Component for DetailSidebar {
|
|||
}
|
||||
}
|
||||
|
||||
fn art_height_for(sidebar_height: f32) -> Option<f32> {
|
||||
if sidebar_height <= 0. {
|
||||
return Some(ART_MAX_HEIGHT_PX);
|
||||
}
|
||||
if sidebar_height < ART_MIN_SIDEBAR_PX {
|
||||
return None;
|
||||
}
|
||||
Some((sidebar_height * 0.32).min(ART_MAX_HEIGHT_PX))
|
||||
}
|
||||
|
||||
fn tags_row(tags: &[String]) -> Option<Element> {
|
||||
if tags.is_empty() {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use oneclient_net::status::{self, ServiceStatus};
|
|||
|
||||
use crate::Actions;
|
||||
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
|
||||
use crate::hooks::use_dispatch;
|
||||
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch};
|
||||
use crate::notifications::{
|
||||
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
|
||||
OptionalModsGroup,
|
||||
|
|
@ -97,6 +97,8 @@ impl Component for Debug {
|
|||
vec![CorruptionSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section("Clusters", vec![ClusterList.into_element()]))
|
||||
.child(divider())
|
||||
.child(section("SQL Console", vec![SqlConsole.into_element()]))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
|
|
@ -112,6 +114,92 @@ impl Component for Debug {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ClusterList;
|
||||
|
||||
impl Component for ClusterList {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let dispatch = use_dispatch();
|
||||
let active_id = use_active_cluster_id();
|
||||
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
|
||||
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(8.);
|
||||
|
||||
if clusters.is_empty() {
|
||||
return list.child(
|
||||
label()
|
||||
.text("No clusters in the database.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
);
|
||||
}
|
||||
|
||||
for cluster in clusters {
|
||||
let cluster_id = cluster.id;
|
||||
let launch = dispatch.clone();
|
||||
let mut active_id = active_id;
|
||||
let loader_version = cluster
|
||||
.mc_loader_version
|
||||
.as_deref()
|
||||
.map_or_else(String::new, |version| format!(" {version}"));
|
||||
|
||||
list = list.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.main_align(Alignment::SpaceBetween)
|
||||
.spacing(12.)
|
||||
.padding(Gaps::new_symmetric(10., 14.))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(colors::page_elevated())
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.child(
|
||||
label()
|
||||
.text(cluster.name.clone())
|
||||
.font_size(14.)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"#{cluster_id} · {} · {}{loader_version} · {:?}",
|
||||
cluster.mc_version, cluster.mc_loader, cluster.stage
|
||||
))
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(move |_| launch.launch_cluster(cluster_id))
|
||||
.text("Launch"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.on_press(move |_| {
|
||||
*active_id.write() = Some(cluster_id);
|
||||
let _ = RouterContext::get()
|
||||
.push(Route::ClusterOverview { cluster_id });
|
||||
})
|
||||
.text("View"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ToastController;
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,29 @@ impl Component for SettingsAccounts {
|
|||
let remove = use_remove_account();
|
||||
let refresh = use_refresh_account();
|
||||
|
||||
let username = use_state(String::new);
|
||||
let mut username = use_state(String::new);
|
||||
let mut show_offline = use_state(|| false);
|
||||
let mut closing_offline = use_state(|| false);
|
||||
let edit_account = use_state(|| None::<(Uuid, String)>);
|
||||
let edit_name = use_state(String::new);
|
||||
|
||||
use_side_effect(move || {
|
||||
if !*closing_offline.read() {
|
||||
return;
|
||||
}
|
||||
match &*add_offline.read().state() {
|
||||
MutationStateData::Settled { res: Ok(_), .. } => {
|
||||
closing_offline.set(false);
|
||||
show_offline.set(false);
|
||||
username.set(String::new());
|
||||
}
|
||||
MutationStateData::Settled { res: Err(_), .. } => {
|
||||
closing_offline.set(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
let accounts = try_accounts(&accounts_query).unwrap_or_default();
|
||||
let default_account = try_default_account(&default_query);
|
||||
let default_id = default_account.as_ref().map(|a| a.id);
|
||||
|
|
@ -59,20 +77,8 @@ impl Component for SettingsAccounts {
|
|||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut show_offline = show_offline;
|
||||
let mut username = username;
|
||||
spawn(async move {
|
||||
let reader = add_offline
|
||||
.mutate_async(AddOfflineAccountKeys { username: name })
|
||||
.await;
|
||||
if matches!(
|
||||
&*reader.state(),
|
||||
MutationStateData::Settled { res: Ok(_), .. }
|
||||
) {
|
||||
show_offline.set(false);
|
||||
username.set(String::new());
|
||||
}
|
||||
});
|
||||
add_offline.mutate(AddOfflineAccountKeys { username: name });
|
||||
closing_offline.set(true);
|
||||
};
|
||||
|
||||
let mut rows: Vec<Element> = accounts
|
||||
|
|
@ -410,7 +416,7 @@ fn offline_dialog(
|
|||
.child(field_label("UUID"))
|
||||
.child(
|
||||
label()
|
||||
.text(uuid_preview.unwrap_or_else(|| "—".to_string()))
|
||||
.text(uuid_preview.unwrap_or_else(|| "-".to_string()))
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
|
|
@ -606,6 +612,7 @@ impl Component for AccountRow {
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Refresh this account")
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
if *refreshing.peek() {
|
||||
|
|
@ -634,6 +641,7 @@ impl Component for AccountRow {
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Change Skin")
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||
|
|
@ -654,6 +662,7 @@ impl Component for AccountRow {
|
|||
.ghost()
|
||||
.icon()
|
||||
.enabled(is_offline)
|
||||
.tooltip("Edit username")
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
edit_name_state.set(username_val.clone());
|
||||
|
|
@ -673,6 +682,7 @@ impl Component for AccountRow {
|
|||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.tooltip("Remove account")
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
remove.mutate(RemoveAccountKeys { id });
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ impl Component for SettingsJava {
|
|||
let mut show_manager = use_state(|| false);
|
||||
let pending_remove = use_state(|| None::<PendingRemove>);
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code, unused_variables)]
|
||||
fn invalidate_runtimes(dispatch: Actions) {
|
||||
spawn(async move {
|
||||
invalidate_java_queries().await;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
use freya::prelude::*;
|
||||
use oneclient_common::Patch;
|
||||
use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution};
|
||||
#[cfg(windows)]
|
||||
use oneclient_core::settings::LauncherSettings;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
use oneclient_core::settings::SettingsOsExtra;
|
||||
|
||||
use super::settings_page;
|
||||
use crate::components::{
|
||||
Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
use crate::components::toggle_controlled;
|
||||
use crate::hooks::{use_dispatch, use_settings_snapshot};
|
||||
use crate::theme::colors;
|
||||
use crate::view::app::settings::{section_header, settings_row};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::components::toggle_controlled;
|
||||
#[cfg(target_os = "linux")]
|
||||
use oneclient_core::settings::SettingsOsExtra;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct SettingsMinecraft;
|
||||
|
||||
|
|
@ -105,7 +100,7 @@ impl Component for SettingsMinecraft {
|
|||
IconType::Database01,
|
||||
"Memory",
|
||||
"The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.",
|
||||
memory_field(memory),
|
||||
memory_field(memory, "Default", oneclient_common::default_mem_max()),
|
||||
))
|
||||
.child(settings_row(
|
||||
IconType::Terminal,
|
||||
|
|
@ -151,15 +146,7 @@ impl Component for SettingsMinecraft {
|
|||
.width(Size::px(220.)),
|
||||
));
|
||||
|
||||
#[cfg(windows)]
|
||||
let page = page.child(settings_row(
|
||||
IconType::Rocket02,
|
||||
"Prefer Dedicated GPU",
|
||||
"Ask Windows to run Java on the high-performance GPU.",
|
||||
discrete_gpu_field(settings, dispatch.clone()),
|
||||
));
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
let page = page
|
||||
.child(section_header("GRAPHICS"))
|
||||
.child(discrete_gpu_row(profile.os_extra.clone(), dispatch));
|
||||
|
|
@ -168,7 +155,7 @@ impl Component for SettingsMinecraft {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn discrete_gpu_row(
|
||||
os_extra: Option<SettingsOsExtra>,
|
||||
dispatch: crate::Actions,
|
||||
|
|
@ -231,20 +218,6 @@ fn build_update(
|
|||
}
|
||||
}
|
||||
|
||||
/// A launcher setting rather than a profile field, so it bypasses [`build_update`]
|
||||
#[cfg(windows)]
|
||||
fn discrete_gpu_field(settings: LauncherSettings, dispatch: crate::Actions) -> impl IntoElement {
|
||||
let on = settings.use_discrete_gpu;
|
||||
let on_toggle: EventHandler<()> = (move |()| {
|
||||
let mut next = settings.clone();
|
||||
next.use_discrete_gpu = !on;
|
||||
dispatch.set_settings(next);
|
||||
})
|
||||
.into();
|
||||
|
||||
toggle_controlled(on, on_toggle)
|
||||
}
|
||||
|
||||
/// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states
|
||||
fn update_mode_field(
|
||||
selected: PackageUpdateMode,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ use super::{section_header, settings_page};
|
|||
use crate::components::{Button, Icon, IconType, open_folder_button};
|
||||
use crate::hooks::{
|
||||
StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report,
|
||||
use_storage_scan_progress,
|
||||
};
|
||||
use crate::state::StorageScanProgress;
|
||||
use crate::theme::colors;
|
||||
use crate::utils::plural;
|
||||
|
||||
|
|
@ -19,9 +21,12 @@ impl Component for SettingsStorage {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
// Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order
|
||||
let report_query = use_storage_report();
|
||||
let scan = use_storage_scan_progress();
|
||||
|
||||
let Some(report) = try_storage_report(&report_query) else {
|
||||
return settings_page().child(hero_placeholder()).into_element();
|
||||
return settings_page()
|
||||
.child(scan_card(scan.as_ref()))
|
||||
.into_element();
|
||||
};
|
||||
|
||||
let refresh = Button::new()
|
||||
|
|
@ -32,9 +37,13 @@ impl Component for SettingsStorage {
|
|||
})
|
||||
.child(label().text("Refresh"));
|
||||
|
||||
settings_page()
|
||||
.child(hero(&report, refresh.into_element()))
|
||||
.child(section_header("FREE UP SPACE"))
|
||||
let mut page = settings_page().child(hero(&report, refresh.into_element()));
|
||||
|
||||
if scan.is_some() {
|
||||
page = page.child(scan_card(scan.as_ref()));
|
||||
}
|
||||
|
||||
page.child(section_header("FREE UP SPACE"))
|
||||
.child(
|
||||
ReclaimRow {
|
||||
icon: IconType::FileX02,
|
||||
|
|
@ -123,18 +132,47 @@ fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement {
|
|||
.into_element()
|
||||
}
|
||||
|
||||
fn hero_placeholder() -> impl IntoElement {
|
||||
rect()
|
||||
// the first-load placeholder and the strip shown while a refresh rescans
|
||||
fn scan_card(scan: Option<&StorageScanProgress>) -> impl IntoElement {
|
||||
let counting = scan.filter(|scan| scan.total > 0);
|
||||
let fraction = counting.map_or(0.0, |scan| scan.current as f32 / scan.total as f32);
|
||||
|
||||
let mut header = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.padding(Gaps::new_symmetric(20., 16.))
|
||||
.content(Content::Flex)
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
rect().width(Size::flex(1.0)).child(
|
||||
label()
|
||||
.text(scan.map_or_else(
|
||||
|| "Measuring disk usage…".to_string(),
|
||||
|scan| format!("{}…", scan.label),
|
||||
))
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
);
|
||||
|
||||
if let Some(scan) = counting {
|
||||
header = header.child(
|
||||
label()
|
||||
.text(format!("{} / {}", scan.current, scan.total))
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
);
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.padding(Gaps::new_symmetric(16., 16.))
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.child(
|
||||
label()
|
||||
.text("Measuring disk usage…")
|
||||
.font_size(16.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(header)
|
||||
.child(proportion_bar(fraction, colors::brand()))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use oneclient_content::packages::ProviderId;
|
|||
use crate::components::{Icon, provider_badge};
|
||||
use crate::hooks::{loaded_image, use_cached_image};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::ui::{ImageFallbackExt, border_all_color};
|
||||
use crate::utils::format_size;
|
||||
|
||||
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
||||
|
|
@ -36,6 +36,7 @@ impl Component for OnboardingModCard {
|
|||
.height(Size::px(44.))
|
||||
.aspect_ratio(AspectRatio::Min)
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.fallback(icon_box(self.provider))
|
||||
.into_element(),
|
||||
|
||||
None => icon_box(self.provider).into_element(),
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ struct ClusterPlan {
|
|||
mc_version: String,
|
||||
/// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default
|
||||
overrides: Vec<(String, String, OverrideType)>,
|
||||
/// Manifest `predownload` flag versions without it fetch on first launch
|
||||
/// Manifest `predownload` flag versions without it fetch their content on first launch
|
||||
predownload: bool,
|
||||
}
|
||||
|
||||
|
|
@ -614,9 +614,6 @@ fn archive_overrides(
|
|||
overrides
|
||||
}
|
||||
|
||||
const GAME_SIZE_GUESS: u64 = 180_000_000;
|
||||
const JRE_SIZE_GUESS: u64 = 45_000_000;
|
||||
|
||||
fn rough_download_estimate(
|
||||
items: &[ClusterBundles],
|
||||
selected: &std::collections::HashSet<String>,
|
||||
|
|
@ -638,7 +635,6 @@ fn rough_download_estimate(
|
|||
}
|
||||
}
|
||||
}
|
||||
total += GAME_SIZE_GUESS + JRE_SIZE_GUESS;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
|
@ -736,10 +732,10 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
|
|||
if let Ok(state) = crate::launcher::state() {
|
||||
let mut sum = 0u64;
|
||||
for plan in plans.iter().filter(|p| p.predownload) {
|
||||
match oneclient_core::estimate_cluster_download(
|
||||
&state,
|
||||
match oneclient_content::bundles::enabled_bundle_bytes(
|
||||
plan.cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -814,39 +810,20 @@ async fn install_one(
|
|||
GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version));
|
||||
|
||||
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
|
||||
"Downloading Minecraft {}...",
|
||||
"Installing mods & content for {}...",
|
||||
plan.mc_version
|
||||
)));
|
||||
|
||||
let prepared = oneclient_core::clusters::prepare_cluster_locked(
|
||||
&state,
|
||||
let bundles_result = oneclient_core::install_cluster_bundles(
|
||||
plan.cluster_id,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
state.bundles.as_ref(),
|
||||
Some(&session),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let bundles_result = if prepared.is_ok() {
|
||||
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
|
||||
"Installing mods & content for {}...",
|
||||
plan.mc_version
|
||||
)));
|
||||
oneclient_core::install_cluster_bundles(
|
||||
plan.cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
Some(&session),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
session.finish();
|
||||
|
||||
prepared?;
|
||||
bundles_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1097,20 +1074,18 @@ mod tests {
|
|||
fn estimate_skips_declined_bundles_and_counts_opted_in_extras() {
|
||||
let sb = skyblock();
|
||||
let all = items(vec![sb.clone()]);
|
||||
let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS;
|
||||
|
||||
let declined = rough_download_estimate(&all, &HashSet::new(), &versions());
|
||||
assert_eq!(declined, baseline, "declined bundle should cost nothing");
|
||||
assert_eq!(declined, 0, "declined bundle should cost nothing");
|
||||
|
||||
let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions());
|
||||
assert_eq!(accepted, baseline + 2);
|
||||
assert_eq!(accepted, 2);
|
||||
|
||||
let with_extra = rough_download_estimate(
|
||||
&all,
|
||||
&keys(&sb, &["skyblock-main", "skycubed"]),
|
||||
&versions(),
|
||||
);
|
||||
assert_eq!(with_extra, baseline + 3);
|
||||
assert_eq!(with_extra, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use super::*;
|
|||
use crate::components::{Button, progress_track};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::utils::{format_duration_hms, format_size};
|
||||
use crate::utils::{format_durations, format_size};
|
||||
|
||||
pub(super) fn failure_panel(
|
||||
failures: &[InstallFailure],
|
||||
|
|
@ -244,7 +244,7 @@ fn build_stats(view: StatsView) -> Option<Element> {
|
|||
let elapsed = elapsed_secs?;
|
||||
let finished = !running && done >= total && total > 0;
|
||||
|
||||
let mut parts: Vec<String> = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))];
|
||||
let mut parts: Vec<String> = vec![format!("Elapsed {}", format_durations(elapsed as i64))];
|
||||
|
||||
if finished {
|
||||
parts.push("Complete".to_string());
|
||||
|
|
@ -253,7 +253,7 @@ fn build_stats(view: StatsView) -> Option<Element> {
|
|||
match transfer.and_then(|t| t.eta_secs) {
|
||||
Some(remaining) => parts.push(format!(
|
||||
"~{} left",
|
||||
format_duration_hms(remaining as i64)
|
||||
format_durations(remaining as i64)
|
||||
)),
|
||||
None => parts.push("~- left".to_string()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ pub(crate) fn predownload_toggle_row(predownload: State<bool>) -> impl IntoEleme
|
|||
.spacing(3.)
|
||||
.child(
|
||||
label()
|
||||
.text("Download content now")
|
||||
.text("Download mods now")
|
||||
.font_size(14.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.color(colors::fg_primary()),
|
||||
|
|
@ -233,9 +233,9 @@ pub(crate) fn predownload_toggle_row(predownload: State<bool>) -> impl IntoEleme
|
|||
.child(
|
||||
label()
|
||||
.text(
|
||||
"Downloads Minecraft and your chosen mods now, so you can play as \
|
||||
soon as setup finishes. Turn it off to download each version the \
|
||||
first time you play it.",
|
||||
"Downloads your chosen mods now. Minecraft itself is always \
|
||||
downloaded the first time you play a version. Turn this off to \
|
||||
leave the mods until then too.",
|
||||
)
|
||||
.font_size(11.)
|
||||
.color(colors::fg_secondary()),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,23 @@ use crate::stage::ClusterStage;
|
|||
|
||||
pub use oneclient_common::paths::DEDICATED_MARKER;
|
||||
|
||||
// takes a cluster out of the shared `mods` folder
|
||||
pub async fn remove_mods_link(folder_name: &str) {
|
||||
let Ok(link) = paths::shared_mods_link(folder_name) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match polyio::symlink_metadata(&link).await {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
if let Err(err) = polyio::remove_symlink_dir(&link).await {
|
||||
tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link");
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cluster {
|
||||
pub id: ClusterId,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ pub enum ClusterError {
|
|||
#[error("invalid settings profile: {reason}")]
|
||||
InvalidProfile { reason: String },
|
||||
|
||||
#[error("invalid minecraft version: {0}")]
|
||||
InvalidVersion(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Logs(#[from] crate::logs::LogsError),
|
||||
|
||||
|
|
@ -46,9 +49,6 @@ pub enum ClusterError {
|
|||
#[error("cluster is in {0} stage")]
|
||||
Busy(ClusterStage),
|
||||
|
||||
#[error("invalid Minecraft version '{0}'")]
|
||||
InvalidVersion(String),
|
||||
|
||||
#[error("cluster is missing a required Java version")]
|
||||
MissingJavaVersion,
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub mod logs;
|
|||
pub mod profiles;
|
||||
pub mod screenshots;
|
||||
|
||||
pub use cluster::{Cluster, ClusterLinkTarget};
|
||||
pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link};
|
||||
pub use error::{ClusterError, ClusterResult};
|
||||
pub use manager::ClusterManager;
|
||||
pub use options::{ClusterUpdate, CreateClusterOptions};
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::error::ClusterResult;
|
|||
use oneclient_db::DbPool;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::cluster::Cluster;
|
||||
use crate::cluster::{Cluster, remove_mods_link};
|
||||
use crate::error::ClusterError;
|
||||
use crate::options::{ClusterUpdate, CreateClusterOptions};
|
||||
use crate::stage::ClusterStage;
|
||||
|
|
@ -168,6 +168,8 @@ impl ClusterManager {
|
|||
return Err(ClusterError::NotFound(cluster_id));
|
||||
}
|
||||
|
||||
remove_mods_link(&cluster.folder_name).await;
|
||||
|
||||
if remove_files {
|
||||
let path = cluster.dir()?;
|
||||
if path.exists() {
|
||||
|
|
|
|||
|
|
@ -26,37 +26,25 @@ pub struct GameSettingsProfile {
|
|||
pub use oneclient_common::Resolution;
|
||||
pub use oneclient_common::domain::PackageUpdateMode;
|
||||
|
||||
cfg_select! {
|
||||
target_os = "linux" => {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SettingsOsExtra {
|
||||
pub enable_gamemode: Option<bool>,
|
||||
pub use_discrete_gpu: Option<bool>,
|
||||
/// Keys this build has no field for, kept verbatim so a profile edited
|
||||
/// on one OS does not clear another OS's settings
|
||||
#[serde(flatten)]
|
||||
pub unknown: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SettingsOsExtra {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub enable_gamemode: Option<bool>,
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use_discrete_gpu: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
pub unknown: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for SettingsOsExtra {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_gamemode: Some(true),
|
||||
use_discrete_gpu: Some(false),
|
||||
unknown: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SettingsOsExtra {
|
||||
/// Keys this build has no field for, kept verbatim so a profile edited
|
||||
/// on one OS does not clear another OS's settings
|
||||
#[serde(flatten)]
|
||||
pub unknown: serde_json::Map<String, serde_json::Value>,
|
||||
impl Default for SettingsOsExtra {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_gamemode: Some(true),
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
use_discrete_gpu: Some(true),
|
||||
unknown: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +67,17 @@ impl GameSettingsProfile {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn use_discrete_gpu(&self) -> bool {
|
||||
cfg_select! {
|
||||
any(target_os = "linux", target_os = "windows") => self
|
||||
.os_extra
|
||||
.as_ref()
|
||||
.and_then(|extra| extra.use_discrete_gpu)
|
||||
.unwrap_or(false),
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_global(&self) -> bool {
|
||||
self.name == GLOBAL_PROFILE_NAME
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ pub async fn create_profile_from_global(
|
|||
let mut profile = global.clone();
|
||||
|
||||
profile.name = name.to_string();
|
||||
if let Some(mem) = mem_max {
|
||||
profile.mem_max = Some(mem);
|
||||
}
|
||||
profile.mem_max = mem_max;
|
||||
|
||||
if let Some(fullscreen) = force_fullscreen {
|
||||
profile.force_fullscreen = Some(fullscreen);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,12 @@ impl ContentType {
|
|||
}
|
||||
}
|
||||
|
||||
// installed once for the whole launcher rather than per cluster
|
||||
#[must_use]
|
||||
pub const fn is_global(self) -> bool {
|
||||
matches!(self, Self::ResourcePack | Self::Shader)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reloads_in_game(self) -> bool {
|
||||
matches!(self, Self::ResourcePack | Self::Shader)
|
||||
|
|
@ -197,7 +203,7 @@ pub enum GameLoader {
|
|||
NeoForge = 2,
|
||||
Quilt = 3,
|
||||
Fabric = 4,
|
||||
LegacyFabric = 5,
|
||||
Ornithe = 5,
|
||||
}
|
||||
|
||||
impl GameLoader {
|
||||
|
|
@ -212,7 +218,7 @@ impl GameLoader {
|
|||
Self::NeoForge => interfrost::api::modded::CURRENT_NEOFORGE_FORMAT_VERSION,
|
||||
Self::Quilt => interfrost::api::modded::CURRENT_QUILT_FORMAT_VERSION,
|
||||
Self::Fabric => interfrost::api::modded::CURRENT_FABRIC_FORMAT_VERSION,
|
||||
Self::LegacyFabric => interfrost::api::modded::CURRENT_LEGACY_FABRIC_FORMAT_VERSION,
|
||||
Self::Ornithe => interfrost::api::modded::CURRENT_ORNITHE_FORMAT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,12 +226,19 @@ impl GameLoader {
|
|||
match self {
|
||||
Self::Vanilla => String::from("minecraft"),
|
||||
Self::NeoForge => String::from("neo"),
|
||||
Self::Ornithe => String::from("ornithe"),
|
||||
_ => self.to_string().to_lowercase().replace(['_', '.', ' '], ""),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn modded_loaders() -> &'static [Self] {
|
||||
&[Self::Forge, Self::NeoForge, Self::Quilt, Self::Fabric]
|
||||
&[
|
||||
Self::Forge,
|
||||
Self::NeoForge,
|
||||
Self::Quilt,
|
||||
Self::Fabric,
|
||||
Self::Ornithe,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn compatible_with(self, other: Self) -> bool {
|
||||
|
|
@ -235,7 +248,7 @@ impl GameLoader {
|
|||
Self::NeoForge => other == Self::NeoForge,
|
||||
Self::Quilt => matches!(other, Self::Quilt | Self::Fabric),
|
||||
Self::Fabric => other == Self::Fabric,
|
||||
Self::LegacyFabric => other == Self::LegacyFabric,
|
||||
Self::Ornithe => other == Self::Ornithe,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +259,7 @@ impl GameLoader {
|
|||
Self::NeoForge => "neoforge",
|
||||
Self::Quilt => "quilt",
|
||||
Self::Fabric => "fabric",
|
||||
Self::LegacyFabric => "legacy-fabric",
|
||||
Self::Ornithe => "ornithe",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,7 +275,7 @@ impl FromStr for GameLoader {
|
|||
"neoforge" | "neo" => Self::NeoForge,
|
||||
"quilt" | "quiltloader" => Self::Quilt,
|
||||
"fabric" | "fabricloader" => Self::Fabric,
|
||||
"legacyfabric" => Self::LegacyFabric,
|
||||
"ornithe" => Self::Ornithe,
|
||||
_ => return Err(format!("'{s}' is not a valid game loader")),
|
||||
},
|
||||
)
|
||||
|
|
@ -277,7 +290,7 @@ impl Display for GameLoader {
|
|||
Self::NeoForge => "NeoForge",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::Fabric => "Fabric",
|
||||
Self::LegacyFabric => "Legacy Fabric",
|
||||
Self::Ornithe => "Fabric (Ornithe)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -302,3 +315,19 @@ impl Resolution {
|
|||
Self { width, height }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_display_name_is_not_a_metadata_path() {
|
||||
assert_eq!(GameLoader::Ornithe.to_string(), "Fabric (Ornithe)");
|
||||
assert_eq!(GameLoader::Ornithe.get_format_name(), "ornithe");
|
||||
assert_eq!(GameLoader::Vanilla.get_format_name(), "minecraft");
|
||||
assert_eq!(GameLoader::NeoForge.get_format_name(), "neo");
|
||||
assert_eq!(GameLoader::Fabric.get_format_name(), "fabric");
|
||||
assert_eq!(GameLoader::Quilt.get_format_name(), "quilt");
|
||||
assert_eq!(GameLoader::Forge.get_format_name(), "forge");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,22 @@ pub fn cluster_game_dir(folder_name: &str) -> PathsResult<PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn cluster_mods_dir(folder_name: &str) -> PathsResult<PathBuf> {
|
||||
Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name()))
|
||||
}
|
||||
|
||||
pub fn shared_mods_dir() -> PathsResult<PathBuf> {
|
||||
Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name()))
|
||||
}
|
||||
|
||||
pub fn global_content_dir(content_type: ContentType) -> PathsResult<PathBuf> {
|
||||
Ok(shared_minecraft_dir()?.join(content_type.folder_name()))
|
||||
}
|
||||
|
||||
pub fn shared_mods_link(folder_name: &str) -> PathsResult<PathBuf> {
|
||||
Ok(shared_mods_dir()?.join(folder_name))
|
||||
}
|
||||
|
||||
pub fn packages_cache_dir() -> PathsResult<PathBuf> {
|
||||
Ok(data_dir()?.join("metadata").join("packages"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use futures_util::StreamExt;
|
||||
use oneclient_db::dao::applied_migration as migration_dao;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::cluster as cluster_dao;
|
||||
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||
use oneclient_db::models::ClusterRow;
|
||||
use oneclient_db::models::OverrideType;
|
||||
|
|
@ -201,12 +203,54 @@ pub(crate) fn disable_was_deliberate(suppression: Option<OverrideType>) -> bool
|
|||
}
|
||||
}
|
||||
|
||||
const DUPLICATE_DISABLE_REPAIR: &str = "repair-hidden-duplicate-disables";
|
||||
|
||||
async fn clear_reconciler_disables(
|
||||
cluster_id: i64,
|
||||
archives: &[BundleArchive],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
let id = format!("{DUPLICATE_DISABLE_REPAIR}:{cluster_id}");
|
||||
if migration_dao::is_applied(&ctx.db, &id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hidden: std::collections::HashSet<String> = archives
|
||||
.iter()
|
||||
.flat_map(|archive| &archive.manifest.files)
|
||||
.filter(|file| file.hidden)
|
||||
.map(|file| file.kind.package_id())
|
||||
.collect();
|
||||
|
||||
if hidden.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut cleared = 0u64;
|
||||
for package_id in &hidden {
|
||||
cleared += bundle_dao::clear_disabled_overrides(&ctx.db, cluster_id, package_id).await?;
|
||||
}
|
||||
|
||||
if cleared > 0 {
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
cleared,
|
||||
"cleared disabled overrides on hidden bundle files that no user was ever shown"
|
||||
);
|
||||
}
|
||||
|
||||
migration_dao::mark_applied(&ctx.db, &id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(archives, ctx))]
|
||||
pub async fn heal_bundle_activity(
|
||||
cluster_id: i64,
|
||||
archives: &[BundleArchive],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
clear_reconciler_disables(cluster_id, archives, ctx).await?;
|
||||
|
||||
let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?;
|
||||
if tracked.iter().all(|row| row.enabled != 0) {
|
||||
return Ok(());
|
||||
|
|
@ -225,10 +269,20 @@ pub async fn heal_bundle_activity(
|
|||
|
||||
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
|
||||
|
||||
let mut live_packages: std::collections::HashSet<String> = tracked
|
||||
.iter()
|
||||
.filter(|row| row.enabled != 0)
|
||||
.filter_map(|row| row.package_id.clone())
|
||||
.collect();
|
||||
|
||||
for row in tracked.iter().filter(|row| row.enabled == 0) {
|
||||
let (Some(bundle_name), Some(package_id)) = (&row.bundle_name, &row.package_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if live_packages.contains(package_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(is_hidden) = hidden
|
||||
.get(&(bundle_name.as_str(), package_id.clone()))
|
||||
.copied()
|
||||
|
|
@ -260,6 +314,8 @@ pub async fn heal_bundle_activity(
|
|||
tracing::warn!(hash = %row.hash, error = %err, "failed to re-enable bundle content");
|
||||
continue;
|
||||
}
|
||||
|
||||
live_packages.insert(package_id.clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -583,27 +639,45 @@ pub async fn set_artifact_enabled_to(
|
|||
Ok(live)
|
||||
}
|
||||
|
||||
/// Writes the override alongside the flag
|
||||
/// without it the losing bundle copy looks disabled-by-nobody and
|
||||
/// heal_bundle_activity re-enables it every launch
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn reconcile_duplicate_activity(
|
||||
// which clusters have to record what the user just did
|
||||
async fn override_scope(
|
||||
cluster_id: i64,
|
||||
hash: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
for hash in crate::packages::reconcile_duplicate_activity(cluster_id, ctx).await? {
|
||||
on_user_disable_artifact(cluster_id, &hash, ctx).await?;
|
||||
) -> ContentResult<Vec<i64>> {
|
||||
let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash)
|
||||
.await?
|
||||
.and_then(|artifact| ContentType::from_repr(artifact.content_type as u8))
|
||||
.is_some_and(ContentType::is_global);
|
||||
|
||||
if !global {
|
||||
return Ok(vec![cluster_id]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let mut ids: Vec<i64> = cluster_dao::list_all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| row.id)
|
||||
.collect();
|
||||
|
||||
if !ids.contains(&cluster_id) {
|
||||
ids.push(cluster_id);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn on_user_disable_artifact(
|
||||
cluster_id: i64,
|
||||
hash: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await
|
||||
for id in override_scope(cluster_id, hash, ctx).await? {
|
||||
handle_user_artifact_action(id, hash, ctx, OverrideType::Disabled).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
|
|
@ -612,10 +686,13 @@ pub async fn on_user_enable_artifact(
|
|||
hash: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?
|
||||
&& let Some(package_id) = tracked.package_id {
|
||||
clear_suppressing_overrides(cluster_id, &package_id, ctx).await?;
|
||||
}
|
||||
for id in override_scope(cluster_id, hash, ctx).await? {
|
||||
if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await?
|
||||
&& let Some(package_id) = tracked.package_id {
|
||||
clear_suppressing_overrides(id, &package_id, ctx).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -672,13 +749,19 @@ pub async fn remove_artifact_from_cluster(
|
|||
artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||
|
||||
// Best-effort folder cleanup failure here is not an error
|
||||
if let (Some(content_type), Some(link)) = (target, link) {
|
||||
try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await;
|
||||
}
|
||||
let deferred = match (target, link) {
|
||||
(Some(content_type), Some(link)) => {
|
||||
try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await
|
||||
== LiveSync::Deferred
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// The package actually lives in the cache
|
||||
// `evict_if_unused` drops it only once no other cluster still needs it
|
||||
if let Err(err) = evict_if_unused(hash, ctx).await {
|
||||
if !deferred
|
||||
&& let Err(err) = evict_if_unused(hash, ctx).await
|
||||
{
|
||||
tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ pub use install::{
|
|||
install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle,
|
||||
list_cluster_bundle_overrides,
|
||||
on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact,
|
||||
reconcile_duplicate_activity,
|
||||
remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled,
|
||||
set_bundle_package_opt_in,
|
||||
set_bundle_package_override, set_bundle_package_overrides,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,17 @@ pub struct ApplyBundleUpdatesResult {
|
|||
pub updates_failed: Vec<String>,
|
||||
pub removals_failed: Vec<String>,
|
||||
pub additions_failed: Vec<String>,
|
||||
pub stopped_early: bool,
|
||||
}
|
||||
|
||||
impl ApplyBundleUpdatesResult {
|
||||
#[must_use]
|
||||
pub fn settled(&self) -> bool {
|
||||
!self.stopped_early
|
||||
&& self.updates_failed.is_empty()
|
||||
&& self.removals_failed.is_empty()
|
||||
&& self.additions_failed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::cluster as cluster_dao;
|
||||
|
|
@ -418,16 +419,21 @@ pub async fn apply_bundle_updates(
|
|||
cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
deadline: Option<Instant>,
|
||||
) -> ContentResult<ApplyBundleUpdatesResult> {
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&ctx.events,
|
||||
"Updating bundle content",
|
||||
);
|
||||
let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await;
|
||||
let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await;
|
||||
session.finish();
|
||||
result
|
||||
}
|
||||
|
||||
fn past(deadline: Option<Instant>) -> bool {
|
||||
deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
}
|
||||
|
||||
/// The caller owns `session`'s lifetime
|
||||
/// sharing one across clusters makes a batch surface as a single notification
|
||||
#[tracing::instrument(skip(bundles, ctx, session))]
|
||||
|
|
@ -436,6 +442,7 @@ pub async fn apply_bundle_updates_with(
|
|||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
session: Option<&oneclient_events::GroupedProgressSession>,
|
||||
deadline: Option<Instant>,
|
||||
) -> ContentResult<ApplyBundleUpdatesResult> {
|
||||
let lock = cluster_lock(cluster_id);
|
||||
let _guard = lock.lock().await;
|
||||
|
|
@ -465,6 +472,11 @@ pub async fn apply_bundle_updates_with(
|
|||
let mut result = ApplyBundleUpdatesResult::default();
|
||||
|
||||
for removal in check.removals_available {
|
||||
if past(deadline) {
|
||||
result.stopped_early = true;
|
||||
break;
|
||||
}
|
||||
|
||||
match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await {
|
||||
Ok(()) => result.removals_applied.push(removal),
|
||||
Err(err) => result
|
||||
|
|
@ -486,11 +498,13 @@ pub async fn apply_bundle_updates_with(
|
|||
s.expect(oneclient_events::TaskCategory::Packages, count, bytes);
|
||||
}
|
||||
|
||||
// Fetch concurrently reconcile in order
|
||||
// splitting the two keeps the fan-out safe since no artifact is unlinked
|
||||
// while another download still needs it
|
||||
let overrides_ref = &overrides;
|
||||
let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map(
|
||||
|update| async move {
|
||||
if past(deadline) {
|
||||
return (update, None);
|
||||
}
|
||||
|
||||
let child = session.map(|s| {
|
||||
let c = s.child(
|
||||
update.new_file.display_name(),
|
||||
|
|
@ -509,29 +523,37 @@ pub async fn apply_bundle_updates_with(
|
|||
ctx,
|
||||
)
|
||||
.await;
|
||||
if let Some(child) = child {
|
||||
if let Some(child) = &child {
|
||||
child.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
}
|
||||
let applied = match installed {
|
||||
Ok(hash) => reconcile_update(&update, &hash, overrides_ref, ctx).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Some(child) = child {
|
||||
child.finish();
|
||||
}
|
||||
(update, installed)
|
||||
(update, Some(applied))
|
||||
},
|
||||
))
|
||||
.buffer_unordered(BUNDLE_INSTALL_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for (update, installed) in fetched_updates {
|
||||
match installed {
|
||||
Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await {
|
||||
Ok(()) => result.updates_applied.push(update),
|
||||
Err(err) => result.updates_failed.push(err.to_string()),
|
||||
},
|
||||
Err(err) => result.updates_failed.push(err.to_string()),
|
||||
for (update, applied) in fetched_updates {
|
||||
match applied {
|
||||
Some(Ok(())) => result.updates_applied.push(update),
|
||||
Some(Err(err)) => result.updates_failed.push(err.to_string()),
|
||||
None => result.stopped_early = true,
|
||||
}
|
||||
}
|
||||
|
||||
let fetched_additions = futures_util::stream::iter(check.additions_available.into_iter().map(
|
||||
|addition| async move {
|
||||
if past(deadline) {
|
||||
return (addition, None);
|
||||
}
|
||||
|
||||
let child = session.map(|s| {
|
||||
let c = s.child(
|
||||
addition.new_file.display_name(),
|
||||
|
|
@ -550,27 +572,31 @@ pub async fn apply_bundle_updates_with(
|
|||
ctx,
|
||||
)
|
||||
.await;
|
||||
if let Some(child) = child {
|
||||
if let Some(child) = &child {
|
||||
child.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
}
|
||||
let applied = match installed {
|
||||
Ok(hash) => reconcile_addition(&addition, &hash, overrides_ref, ctx).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Some(child) = child {
|
||||
child.finish();
|
||||
}
|
||||
(addition, installed)
|
||||
(addition, Some(applied))
|
||||
},
|
||||
))
|
||||
.buffer_unordered(BUNDLE_INSTALL_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for (addition, installed) in fetched_additions {
|
||||
let file_id = addition.new_file.kind.package_id();
|
||||
match installed {
|
||||
Ok(hash) => {
|
||||
match reconcile_addition(&addition, &hash, &overrides, ctx).await {
|
||||
Ok(()) => result.additions_applied.push(addition),
|
||||
Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")),
|
||||
}
|
||||
for (addition, applied) in fetched_additions {
|
||||
match applied {
|
||||
Some(Ok(())) => result.additions_applied.push(addition),
|
||||
Some(Err(err)) => {
|
||||
let file_id = addition.new_file.kind.package_id();
|
||||
result.additions_failed.push(format!("{file_id}: {err:#}"));
|
||||
}
|
||||
Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")),
|
||||
None => result.stopped_early = true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -884,7 +910,7 @@ pub async fn apply_bundle_updates_for_all_clusters(
|
|||
) -> ContentResult<Vec<(i64, ApplyBundleUpdatesResult)>> {
|
||||
let mut changed = Vec::new();
|
||||
for cluster in cluster_dao::list_all(&ctx.db).await? {
|
||||
match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await {
|
||||
match apply_bundle_updates_with(cluster.id, bundles, ctx, session, None).await {
|
||||
Ok(result) => {
|
||||
if !result.updates_applied.is_empty()
|
||||
|| !result.additions_applied.is_empty()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use oneclient_common::domain::ProviderId;
|
||||
use oneclient_common::domain::{ContentType, ProviderId};
|
||||
use oneclient_db::dao::applied_migration as migration_dao;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
|
|
@ -89,6 +91,91 @@ fn group_duplicates(
|
|||
by_project.into_iter().collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn disable_foreign_game_versions(
|
||||
cluster_id: i64,
|
||||
mc_version: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Vec<String>> {
|
||||
if mc_version.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
|
||||
let mut switched_off = Vec::new();
|
||||
|
||||
for info in linked
|
||||
.iter()
|
||||
.filter(|info| info.enabled && info.content_type == ContentType::Mod)
|
||||
{
|
||||
match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await {
|
||||
Ok(true) => switched_off.push(
|
||||
info.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| info.file_name.clone()),
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(err) => tracing::warn!(
|
||||
cluster_id,
|
||||
hash = %info.hash,
|
||||
%err,
|
||||
"could not switch off a mod built for another game version"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(switched_off)
|
||||
}
|
||||
|
||||
const FOREIGN_VERSION_REPAIR: &str = "disable-foreign-game-version";
|
||||
|
||||
async fn switch_off_if_foreign(
|
||||
cluster_id: i64,
|
||||
info: &LinkedArtifactInfo,
|
||||
mc_version: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<bool> {
|
||||
let repair_id = format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{}", info.hash);
|
||||
if migration_dao::is_applied(&ctx.db, &repair_id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let stated = artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await?;
|
||||
if !built_for_another_game_version(&stated, mc_version) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
hash = %info.hash,
|
||||
built_for = ?stated,
|
||||
"switching off a mod built for another game version"
|
||||
);
|
||||
|
||||
PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, ctx).await?;
|
||||
migration_dao::mark_applied(&ctx.db, &repair_id).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn built_for_another_game_version(stated: &[String], mc_version: &str) -> bool {
|
||||
!stated.is_empty()
|
||||
&& !stated.iter().any(|versions| {
|
||||
let built_for: Vec<String> = serde_json::from_str(versions).unwrap_or_default();
|
||||
covers_game_version(&built_for, mc_version)
|
||||
})
|
||||
}
|
||||
|
||||
fn covers_game_version(stated: &[String], mc_version: &str) -> bool {
|
||||
stated.is_empty()
|
||||
|| stated.iter().any(|version| {
|
||||
version == mc_version
|
||||
|| mc_version
|
||||
.strip_prefix(version.as_str())
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
})
|
||||
}
|
||||
|
||||
/// `published_at` is RFC 3339 so string order matches time order
|
||||
/// undated copies lose to dated ones and an all-undated group still picks one
|
||||
fn newest<'a>(copies: impl IntoIterator<Item = &'a Copy>) -> Option<String> {
|
||||
|
|
@ -165,6 +252,41 @@ mod tests {
|
|||
assert!(picked.is_some(), "a group with no dates must not go unresolved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stated_mismatch_switches_a_mod_off() {
|
||||
let foreign = |rows: &[&str]| {
|
||||
built_for_another_game_version(
|
||||
&rows.iter().map(|r| (*r).to_string()).collect::<Vec<_>>(),
|
||||
"26.1.2",
|
||||
)
|
||||
};
|
||||
|
||||
assert!(!foreign(&[]), "a jar no provider ever described is left alone");
|
||||
assert!(!foreign(&["[]"]), "an empty list is the provider saying nothing");
|
||||
assert!(!foreign(&["[\"1.21.11"]), "a list that will not parse says nothing");
|
||||
assert!(!foreign(&["[\"26.1.2\"]"]));
|
||||
assert!(
|
||||
!foreign(&["[\"1.21.11\",\"26.1.2\"]"]),
|
||||
"a multi-version build stays"
|
||||
);
|
||||
assert!(
|
||||
!foreign(&["[\"1.21.11\"]", "[\"26.1.2\"]"]),
|
||||
"one release row out of two is enough to keep it"
|
||||
);
|
||||
assert!(
|
||||
foreign(&["[\"1.21.11\"]"]),
|
||||
"the jars the old stash dragged in have to go"
|
||||
);
|
||||
assert!(
|
||||
!foreign(&["[\"26.1\"]"]),
|
||||
"a build for the release this cluster was migrated from stays"
|
||||
);
|
||||
assert!(
|
||||
foreign(&["[\"1.21.1\"]"]),
|
||||
"a shorter version that is not a component prefix is still foreign"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_projects_with_several_copies_are_reconciled() {
|
||||
let groups = group_duplicates(&[
|
||||
|
|
|
|||
|
|
@ -193,15 +193,13 @@ fn choose_version(
|
|||
.cloned()
|
||||
}
|
||||
|
||||
/// Mirrors the store's install-time compatibility check
|
||||
/// must stay in sync with it
|
||||
#[must_use]
|
||||
pub(crate) fn supports_game_version(game_versions: &[String], mc_version: &str) -> bool {
|
||||
game_versions.is_empty() || game_versions.iter().any(|v| v == mc_version)
|
||||
}
|
||||
|
||||
fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool {
|
||||
if !version.game_versions.is_empty()
|
||||
&& !version
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|v| cluster.mc_version.contains(v))
|
||||
{
|
||||
if !supports_game_version(&version.game_versions, &cluster.mc_version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub use metadata_cache::{
|
|||
CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached,
|
||||
read_cached_package_meta,
|
||||
};
|
||||
pub use activity::reconcile_duplicate_activity;
|
||||
pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity};
|
||||
pub use dependencies::{
|
||||
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue