diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5e37d275fa6..c22921c8af1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -169,11 +169,11 @@ jobs: include: # Linux on arm64: NEON is a first-class target, and this is the # one row where GCC compiles the SIMD layer for it. - - runner: ubuntu-24.04-arm + - runner: ubuntu-26.04-arm configuration: release arch: arm64 build_variant: Viewer - - runner: ubuntu-24.04-arm + - runner: ubuntu-26.04-arm configuration: release arch: arm64 build_variant: Tests @@ -209,7 +209,7 @@ jobs: # Linux runner gets one from Mesa's llvmpipe through SDL's offscreen # driver; the Windows runner has no GPU driver, and macOS is unproven, # so they build those suites and register them disabled. - AL_ENABLE_GL_TESTS: ${{ runner.os == 'Linux' }} + AL_ENABLE_GL_TESTS: ${{ startsWith(matrix.runner, 'ubuntu-') }} LIBGL_ALWAYS_SOFTWARE: 1 # Build Config BUILD_ARCH: ${{ matrix.arch }} @@ -232,8 +232,8 @@ jobs: # vcpkg Setup VCPKG_ROOT: ${{ github.workspace }}/vcpkg VCPKG_USERNAME: ${{ github.repository_owner }} - VCPKG_FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json - VCPKG_BINARY_SOURCES: "clear;nuget,https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json,readwrite" + # VCPKG_FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json + VCPKG_BINARY_SOURCES: "clear;default,readwrite" steps: - name: Linux Dependency Install and Disk Cleanup if: runner.os == 'Linux' @@ -303,33 +303,63 @@ jobs: run: | ./vcpkg/bootstrap-vcpkg.sh - - name: Set up vcpkg nuget caching - shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]] - then - $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ - sources add \ - -Source "${{ env.VCPKG_FEED_URL }}" \ - -StorePasswordInClearText \ - -Name GitHubPackages \ - -UserName "${{ env.VCPKG_USERNAME }}" \ - -Password "${{ secrets.GITHUB_TOKEN }}" - $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ - setapikey "${{ secrets.GITHUB_TOKEN }}" \ - -Source "${{ env.VCPKG_FEED_URL }}" - else - mono $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ - sources add \ - -Source "${{ env.VCPKG_FEED_URL }}" \ - -StorePasswordInClearText \ - -Name GitHubPackages \ - -UserName "${{ env.VCPKG_USERNAME }}" \ - -Password "${{ secrets.GITHUB_TOKEN }}" - mono $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ - setapikey "${{ secrets.GITHUB_TOKEN }}" \ - -Source "${{ env.VCPKG_FEED_URL }}" - fi + - name: Install AWS CLI on macOS + if: >- + vars.VCPKG_R2_ENABLED == 'true' && runner.os == 'macOS' && + ((github.ref_protected && github.event_name != 'pull_request') || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) + run: brew install awscli + + - name: Configure R2 binary cache for trusted builds + if: vars.VCPKG_R2_ENABLED == 'true' && github.ref_protected && github.event_name != 'pull_request' + env: + R2_CACHE_MODE: readwrite + R2_ENDPOINT_URL: ${{ vars.VCPKG_R2_ENDPOINT_URL }} + R2_BUCKET: ${{ vars.VCPKG_R2_BUCKET }} + R2_ACCESS_KEY_ID: ${{ secrets.VCPKG_R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.VCPKG_R2_SECRET_ACCESS_KEY }} + run: python scripts/vcpkg/configure_r2_cache.py + + - name: Configure R2 binary cache for same-repository PRs + if: >- + vars.VCPKG_R2_ENABLED == 'true' && github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + # Temporary cache population test; restore read mode and reader secrets afterward. + R2_CACHE_MODE: readwrite + R2_ENDPOINT_URL: ${{ vars.VCPKG_R2_ENDPOINT_URL }} + R2_BUCKET: ${{ vars.VCPKG_R2_BUCKET }} + R2_ACCESS_KEY_ID: ${{ secrets.VCPKG_R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.VCPKG_R2_SECRET_ACCESS_KEY }} + run: python scripts/vcpkg/configure_r2_cache.py + + # - name: Set up vcpkg nuget caching + # shell: bash + # run: | + # if [[ "$RUNNER_OS" == "Windows" ]] + # then + # $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ + # sources add \ + # -Source "${{ env.VCPKG_FEED_URL }}" \ + # -StorePasswordInClearText \ + # -Name GitHubPackages \ + # -UserName "${{ env.VCPKG_USERNAME }}" \ + # -Password "${{ secrets.GITHUB_TOKEN }}" + # $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ + # setapikey "${{ secrets.GITHUB_TOKEN }}" \ + # -Source "${{ env.VCPKG_FEED_URL }}" + # else + # mono $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ + # sources add \ + # -Source "${{ env.VCPKG_FEED_URL }}" \ + # -StorePasswordInClearText \ + # -Name GitHubPackages \ + # -UserName "${{ env.VCPKG_USERNAME }}" \ + # -Password "${{ secrets.GITHUB_TOKEN }}" + # mono $(${VCPKG_ROOT}/vcpkg fetch nuget | tail -n 1) \ + # setapikey "${{ secrets.GITHUB_TOKEN }}" \ + # -Source "${{ env.VCPKG_FEED_URL }}" + # fi - name: Cache Rust Dependencies uses: actions/cache@v6 @@ -361,17 +391,17 @@ jobs: SIGNING=() case "$RUNNER_OS" in Windows) - cmake_preset_name="vs2026$cmake_preset_postfix" + cmake_preset_name="vs2026${cmake_preset_postfix}-fullopt" build_directory="build-Windows-$cmake_preset_name" vcpkg_triplet="x64-windows-alchemy-avx2-release" ;; macOS) - cmake_preset_name="ninja$cmake_preset_postfix-$BUILD_ARCH" + cmake_preset_name="ninja${cmake_preset_postfix}-fullopt-$BUILD_ARCH" build_directory="build-Darwin-$cmake_preset_name" vcpkg_triplet="$BUILD_ARCH-osx-alchemy-release" ;; Linux) - cmake_preset_name="ninja$cmake_preset_postfix" + cmake_preset_name="ninja${cmake_preset_postfix}-fullopt" build_directory="build-Linux-$cmake_preset_name" if [[ "$BUILD_ARCH" == "arm64" ]] then diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 685656155be..e7c605ba535 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -88,8 +88,10 @@ The deferred rendering pipeline is orchestrated by `LLPipeline` (`indra/newview/ 2. **Shadows** (`renderGeomShadow()`) — 4 sun shadow cascades + 2 spot light shadow maps 3. **Deferred Lighting** (`renderDeferredLighting()`) — Sun, point lights (up to 16 batched), spot lights, with reflection probe influence 4. **Post-Deferred** (`renderGeomPostDeferred()`) — Alpha, water, atmospheric haze -5. **Post-Processing** — Luminance/exposure, bloom, DoF, tonemapping, color grading, AA, screen effects -6. **Finalize** (`renderFinalize()`) — Final blit with vignette, film grain, dithering, chromatic aberration +5. **Post-Processing** — Luminance/exposure, depth of field, bloom, then one pass doing tonemapping, color grading and the bloom composite together, then AA and CAS +6. **Finalize** (`renderFinalize()`) — Final blit with lens distortion, vignette, film grain, dithering, CVD compensation + +Depth of field runs **before** bloom and before the tonemapper, on linear HDR. Gathering over display-space values gathers over already-compressed highlights, and bloom-after-defocus is the optical order, so a defocused highlight blooms as a soft disc rather than a sharp core on a blurred background. **GBuffer layout** (MRT attachments on `deferredScreen`): - frag_data[0]: Base color (GL_RGBA) @@ -101,18 +103,23 @@ The deferred rendering pipeline is orchestrated by `LLPipeline` (`indra/newview/ - `mMainRT` — Full resolution for main scene - `mAuxillaryRT` — 512×512 for reflection probes and dynamic texture bakes - `mHeroProbeRT` — High-res hero probe rendering -- Additional targets: `mSceneMap` (SSR input), `mLuminanceMap`/`mExposureMap` (auto-exposure), `mPostPingMap`/`mPostPongMap` (post-process ping-pong), `mFXAAMap`, `mSMAABlendBuffer`, `mGlow[3]` (bloom pyramid), `mWaterDis` (refraction), `mSpotShadow[2]`, `mPbrBrdfLut` +- Additional targets: `mSceneMap` (SSR input), `mLuminanceMap`/`mExposureMap` (auto-exposure), `postPingMap`/`postPongMap` (post-process ping-pong), `mFXAAMap`, `mSMAABlendBuffer`, `bloomMip[BLOOM_MAX_MIPS]` (HDR bloom pyramid, up to 7 levels, live count in `bloomMipCount`), `mWaterDis` (refraction; also lends its quadrants to the cross-screen filter's two scratch buffers and accumulator, and its whole to depth of field's sharp copy, while it is idle after the water pass — `crossFilterReady`/`crossFilterWidth`/`crossFilterHeight` tell `colorCorrect` where the accumulator is), `mSpotShadow[2]`, `mPbrBrdfLut` +- `mGlow[3]` is the **legacy non-HDR glow chain**, not the bloom pyramid, and lives outside the pack. So does `mLensDirtMap`, the generated lens dirt plate — an `LLRenderTarget` allocated the first frame the effect is enabled and released when it is switched off. **Post-processing chain:** - **Auto-exposure:** Progressive histogram (`gLuminanceProgram`, `gExposureProgram`) with history fade -- **Bloom:** Bright-area extraction → 3-level glow pyramid (`mGlow[3]`) with warmth correction -- **Depth of Field:** Circle-of-confusion via `gDeferredCoFProgram`, combine via `gDeferredDoFCombineProgram`. Settings: `CameraFNumber`, `CameraFocalLength`, `CameraMaxCoF` +- **Lens flare sun state:** a 2x1 temporal pass (`gLensFlareStateProgram`, `lensFlareStateF.glsl`) run just before colour correction measures how much of the sun disc is unoccluded and its colour, and filters both under a rate limit; `computeLensFlare` in `postEffectUtilsF.glsl` reads one texel of it. The shader is the source of truth for the filter; `scripts/content_tools/check_lens_flare_state.py` mirrors it and is its regression test +- **Bloom:** Bright-area extraction → downsample/upsample pyramid over `bloomMip[]` with warmth correction and optional halation carried in alpha. The additive composite is **not** a pass of its own: it is folded into `colorCorrect`'s `BLOOM_COMPOSITE` permutation. `compositeBloomHDR` is a standalone equivalent that nothing currently calls +- **Cross-screen (star) filter:** `gCrossFilterProgram` streaks every thresholded highlight, seeded from `bloomMip[0]` and accumulated through three quadrants of `mWaterDis`, which the two shaders address through `uCrossRegion`/`uCrossClamp` so a tap at the quadrant's edge clamps exactly as it did at a dedicated target's edge. One strictly one-sided three-pass chain per arm, at strides that tile the reachable offsets exactly once — the tiling is load-bearing and the shader explains why. Composited in `colorCorrect` alongside the pyramid, so it inherits bloom strength +- **Lens dirt:** `gLensDirtGenProgram` draws the grime plate into `mLensDirtMap` — dust motes, wipe smudges, stray fibres, fine grit and optional scratches, built from hashes and distance fields rather than loaded from an image. Not a per-frame pass: it runs when a generation parameter moves or the window resizes, guarded by the cached parameter set in `mLensDirtParams`, which is also what makes a failed allocation stop retrying. Generated at the frame's own resolution, so nothing has to fit a square plate to a wide window. A rebuild is held off while `mLensDirtSliderHeld` is raised — the Lightbox raises it on a generation slider's mouse-down and lowers it on mouse-up, so a drag costs one plate on release rather than one per frame. Everything that is not a drag (typed values, resets, applying a Look, undo, window resizes) rebuilds immediately, which is what a settle timer would have delayed for no reason. `colorCorrect` multiplies the accumulated bloom and flare terms by it +- **Depth of Field:** three passes — circle of confusion (`gDeferredCoFProgram`, writing sharp linear colour plus signed CoF in alpha), a gather blur at `CameraDoFResScale`, and a combine (`gDeferredDoFCombineProgram`) back over `mRT->screen` under `setColorMask(true, false)` so the legacy prim-glow alpha tag survives. It owns no targets: the sharp copy borrows `mWaterDis`, which the water passes refill each frame and have finished with, and the blur borrows `bloomMip[0]` between the luminance read and the extract pass (`postPongMap` without HDR). The blur viewport is clamped to what mip 0 can hold when `RenderBloomResolutionScale` shrinks it, and the combine is told the fraction it actually covers via `res_scale` and where it sits via `dof_uv_scale`. The gather has four compile-time variants over two axes, `FRONT_BLUR` × `DOF_SHAPED`: `gDeferredPostProgram`, `gDeferredPostProgramNoNear`, `gDeferredPostProgramShaped`, `gDeferredPostProgramNoNearShaped`. Optics: `CameraFNumber`, `CameraFocalLength`, `CameraFieldOfView`, `CameraMaxCoF`, `CameraDoFResScale` +- **Bokeh shaping** (inside the `DOF_SHAPED` gather): polygonal aperture (`RenderBokehApertureBlades`, `Rotation`, `Curvature`), anamorphic squeeze, cat's-eye optical vignetting, defocus fringing, and the lens aberrations — spherical (`RenderBokehSphericalAberration`), field stretch for swirl and coma (`RenderBokehFieldStretch`, `FieldFalloff`) and comatic asymmetry. The CPU picks a shaped variant only when one of them is actually doing something - **Screen Space Reflections:** Class 3+ feature, iterative ray marching - **Tonemapping:** ACES, Reinhard, Filmic, AGX — selectable via `AlchemyRenderTonemapType` -- **Color Grading:** 3D LUT-based (`gDeferredPostGammaCorrectCGLutProgram`, `mCGLut`) +- **Color Grading:** 3D LUT-based (`gDeferredPostGammaCorrectCGLutProgram`, `mCGLut`), plus a CPU-baked 1D tone curve LUT (`mToneCurveLut`, baked from `ALToneCurveSet` on a dirty flag) and precomputed split-tone ramps; the helpers live in `colorGradeUtilF.glsl` under `shaders/class1/alchemy/` - **Anti-aliasing:** FXAA (1-pass, `gFXAAProgram[4]`) or SMAA (3-pass edge detect → blend weights → neighborhood blend, `gSMAAEdgeDetectProgram[4]`/`gSMAABlendWeightsProgram[4]`/`gSMAANeighborhoodBlendProgram[4]`). CAS (Contrast Adaptive Sharpening) via `gCASProgram` -- **Final blit effects** (`blitWithEffectsF.glsl` in `shaders/class1/alchemy/`): Vignette (configurable shape/softness/color), film grain (luma/color/coarse/photon styles), TPDF dithering, CVD compensation/preview -- **Chromatic aberration** (`colorCorrectF.glsl` in `shaders/class1/alchemy/`): Per-channel offset with amount, falloff, angle, anisotropy controls +- **Final blit effects** (`blitWithEffectsF.glsl` in `shaders/class1/alchemy/`): geometric lens distortion (Brown-Conrady radial `k1`/`k2` and tangential `p1`/`p2`, anamorphic squeeze, decentring, with the auto-fit rescale solved on the CPU and out-of-frame samples masked to black), vignette (configurable shape/softness/color), film grain (luma/color/coarse/photon styles), TPDF dithering, CVD compensation/preview. Distortion warps the world view only — overlays drawn after the blit do not follow it +- **Effects inside `colorCorrectF.glsl`** (`shaders/class1/alchemy/`), which runs in every variant including the no-post ones and so gates them itself: chromatic aberration (per-channel offset with amount, falloff, angle, anisotropy), the five-component lens flare, the bloom and cross-filter composite, and lens dirt — the generated grime plate above, multiplied by the accumulated flare and bloom terms, so it only lights up where something already is Post-processing settings are exposed in the Lightbox floater (`ALFloaterLightBox`); see `doc/LIGHTBOX.md` for how to add UI sections for new effects. @@ -126,9 +133,9 @@ GLSL shaders live in `indra/newview/app_settings/shaders/` organized by quality - **`class1/`** — Base shaders (all hardware) - **`class2/`** — Mid-tier features - **`class3/`** — Advanced features (SSR, high-quality lighting) -- Categories: `deferred/`, `objects/`, `environment/`, `alchemy/` (Alchemy post-processing), `windlight/`, `avatar/`, `interface/` +- Categories: `deferred/`, `objects/`, `environment/`, `alchemy/` (Alchemy post-processing), `effects/` (glow, the bloom pyramid, the cross-screen filter), `windlight/`, `avatar/`, `interface/` -Key shader groups: GBuffer write (`gDeferredDiffuseProgram`, `gDeferredPBROpaqueProgram`, `gDeferredBumpProgram`, `gDeferredMaterialProgram[]`, etc.), deferred lighting (`gDeferredSunProgram`, `gDeferredLightProgram`, `gDeferredMultiLightProgram[16]`, `gDeferredSpotLightProgram`), shadows (`gDeferredShadowProgram` and variants), environment (`gDeferredWLSkyProgram`, `gWaterProgram`, `gHazeProgram`), post-processing (tonemap, FXAA, SMAA, CAS, bloom, DoF programs). +Key shader groups: GBuffer write (`gDeferredDiffuseProgram`, `gDeferredPBROpaqueProgram`, `gDeferredBumpProgram`, `gDeferredMaterialProgram[]`, etc.), deferred lighting (`gDeferredSunProgram`, `gDeferredLightProgram`, `gDeferredMultiLightProgram[16]`, `gDeferredSpotLightProgram`), shadows (`gDeferredShadowProgram` and variants), environment (`gDeferredWLSkyProgram`, `gWaterProgram`, `gHazeProgram`), post-processing (tonemap, FXAA, SMAA, CAS, the bloom pyramid programs, `gCrossFilterProgram`, and the four-variant DoF gather set). ### Draw Pool & Spatial System diff --git a/doc/BUILD.md b/doc/BUILD.md index afb871ed973..3bdb8cf53f9 100644 --- a/doc/BUILD.md +++ b/doc/BUILD.md @@ -218,6 +218,10 @@ This creates a build tree at `build--/` next to the source The first configure run downloads and builds every vcpkg dependency from source. Expect **30–60+ minutes** and several GB of disk; subsequent configures finish in seconds. +An optional [R2 binary cache](VCPKG-R2.md) can restore matching dependencies. +The setup guide covers pipeline environment variables, read-only developer +access, retention, and rollout checks. + #### Platform notes - **macOS** — `xcode-os` and `ninja-os` (no arch suffix) pick the host architecture. Use the explicit `-arm64` / `-x64` preset to cross-build (e.g. an arm64 bundle from an Intel Mac). diff --git a/doc/LIGHTBOX.md b/doc/LIGHTBOX.md index 50dde645fe0..1d22c56f32a 100644 --- a/doc/LIGHTBOX.md +++ b/doc/LIGHTBOX.md @@ -26,17 +26,20 @@ statics), so edits preview live with no glue code. | Sky tab (environment + client-side sky effects) | `.../panel_lightbox_sky.xml` | | Day cycle landmark search | `indra/newview/aldaycyclelandmarks.{h,cpp}` | | The C++ (callbacks, Vec3 binder, section reset, Looks bar) | `indra/newview/alfloaterlightbox.{h,cpp}` | +| Where every section and setting is, found once | `indra/newview/allightboxdirectory.{h,cpp}` | | Looks preset system + whitelist | `indra/newview/llpresetsmanager.{h,cpp}` | | Bundled starter Looks | `indra/newview/app_settings/looks/` | | Colour wheel widget + its maths | `indra/newview/alcolorwheel{ctrl,model}.{h,cpp}` | -| Curve/band graph widget + its maths | `indra/newview/alcurve{editorctrl,model}.{h,cpp}` | +| Curve/band graph widget + its maths (the spline, the split-tone ramps, and the `ALToneCurveSet` the renderer bakes) | `indra/newview/alcurve{editorctrl,model}.{h,cpp}` | | Scopes floater + measurement | `indra/newview/alfloaterscopes.{h,cpp}`, `alscopedata.{h,cpp}` | | Scope pane assignment menu | `.../menu_scopes_pane.xml` | +| Tone curve preset menu | `.../menu_lightbox_curve_presets.xml` | | Undo/redo stack | `indra/newview/algradehistory.{h,cpp}` | | White-balance map and its inverse | `indra/newview/alwhitebalancesolver.{h,cpp}` | | Scene colour picker tool | `indra/newview/altoolscenepicker.{h,cpp}` | | 3D LUT (.cube) parser | `indra/newview/lutcube.{h,cpp}` | | Header checkbox on `accordion_tab` | `indra/llui/llaccordionctrltab.{h,cpp}` | +| A swatch handing its picker to its owner | `indra/newview/llcolorswatch.{h,cpp}` | | Anti-aliased 2D polyline and fill | `indra/llrender/llrender2dutils.{h,cpp}` | Seven of those have unit tests, and the tests are the reason the maths in them @@ -53,17 +56,20 @@ the viewer's widget set was touched. | Thing | Written as | Where | |---|---|---| +| One setting on one line, with its reset | `` | §4 | | Colour wheel | `` | §4b | | Curve / band graph | `` | §4b | | Checkbox on an accordion header | `` | §3b | | Anti-aliased polyline and area fill | `gl_polyline_2d`, `gl_polyfill_2d` | §4b | -| Floater top bar (Looks, history, scopes) | ordinary buttons, `Floater.Toggle` | §4g | +| Floater top bar (Looks, history, scopes, find) and the tab strip's pop-out | ordinary buttons, `Floater.Toggle` | §4g | | Scopes window | its own floater | §4d | | Sky tab (day cycle freeze) | ordinary rows, `LLEnvironment` behind them | §4h | -`accordion_tab` is the only **stock** widget altered, and the change is additive: -a tab that does not ask for `header_check_box` gets exactly the header it always -had. That mattered rather a lot — 26 other files in the English skin alone +`accordion_tab` was the first **stock** widget altered, and the change is +additive: a tab that does not ask for `header_check_box` gets exactly the header +it always had. `color_swatch` is the second, and additive the same way: it can +hand its picker to an owner (`setPickerOverride`), which only the Lightbox does, +so every other swatch opens the colour picker floater exactly as before. That mattered rather a lot — 26 other files in the English skin alone declare accordion tabs, 75 of them, and every one is outfit editing, profiles, preferences or the About box. Which is the standard to hold anything else here to: if the Lightbox needs something from a shared widget, it asks for it by an @@ -81,6 +87,8 @@ That is the real contract, and the distinction matters when you plan work: The floater's C++ provides: - `LightBox.ResetControlDefault` — per-row reset; `parameter` = setting name. + Dropdown, colour and vector rows call it from their reset buttons; setting + rows reach the same function through `setResetHandler`, set in `postBuild`. - `LightBox.ResetSection` — data-driven section reset; `parameter` = `sec_`. It walks the panels named `sec_` and `sec__adv`, collects every descendant's bound control (plus settings named by `vec3_*` spinners), and @@ -96,15 +104,52 @@ The floater's C++ provides: glance that the warm end has been pulled and the cool end left alone. Eight spinners read as a form to be filled in. - `LightBox.CommitToneCurve` / `LightBox.RefreshToneCurve` — the tone curve - graph's handle commit and its channel-selector refresh. -- `LightBox.CommitSplitToneGraph` — the split-tone band graph's handle, which - writes `RenderSplitToneBalance`. + graph's commit, which interprets a drag, an added point or a removed point + against the curve the channel combo selects and writes that one setting, + and the combo's refresh. +- `LightBox.ResetToneCurveChannel` / `LightBox.ToneCurvePreset` — the reset + glyph beside the combo (the selected curve only) and the preset menu + (`parameter` = preset id), each one write to the selected curve. +- `LightBox.CommitSplitToneGraph` — the split-tone band graph's handles: the + middle one writes `RenderSplitToneBalance`, the two edges write + `RenderSplitToneShadowWidth` and `RenderSplitToneHighlightWidth`. - `LightBox.PickWhiteBalance` — arms the eyedropper. +- `LightBox.OpenLUTFolder` — reveals the user's colour-LUT folder, creating it + on first use. +- `LightBox.Find` — the Find popover (§4j). +- `LightBox.History` — the undo history popover (§4f). +- `LightBox.PopOut` — the tab that is up, out into a window of its own or back + (§4k). - The Looks bar and tonemapper-row greying (effect-specific, already done). -The last four are examples of the per-control cost: a graph or a tool needs -something to interpret its input, so it gets one callback and one `setup*` -call in `postBuild`. Both graphs follow the same shape — `setupX` connects to +**Asset-picker rows are a third kind of dropdown**, distinct from the enum +recipe below. They bind a `combo_box allow_text_entry="true"` to a *string* +setting naming a file, and are filled from C++ by +`populateAssetCombo(combo_name, dir_name, extensions, setting_name)`: bundled +entries from `app_settings/` first, then the user's own from +`user_settings/` behind a separator, matching the order the renderer +itself resolves names in. Four things are not optional. The XUI carries one +literal `` and nothing else. `postBuild` +must call the populate helper, because the list does not exist until it does. +The helper's closing `selectByValue` is load-bearing rather than cosmetic — +with `allow_text_entry` nothing else restores the saved value when the floater +opens. And the extension whitelist must list only what the loader can actually +decode, or the picker offers files that silently fail. Pair it with an +`openUserAssetFolder(dir_name)` button so the folder is discoverable. + +The colour LUT is currently the only picker — the lens dirt plate that shared +these helpers is generated now. Both stay parameterised by directory anyway, +because the shape is the shared part and collapsing them back to a constant +only has to be undone for the next asset. Before reaching for a picker at all, +ask whether the asset could be generated instead: a texture a shader can draw +into a render target once needs no file, no packaging entry, no extension +whitelist and no fitting to the window, and it can be put on sliders. See +`generateLensDirt` in pipeline.cpp for the shape — gate, cached parameter set, +allocate, draw, release when the effect goes off. + +The graph, eyedropper and picker callbacks are examples of the per-control +cost: a graph or a tool needs something to interpret its input, so it gets one +callback and one `setup*` call in `postBuild`. Both graphs follow the same shape — `setupX` connects to the settings' signals and calls `refreshX`; `refreshX` rebuilds the plot and handles from the settings; `onCommitX` writes the setting and calls `refreshX` again behind a re-entry guard. Copy that shape rather than inventing another. @@ -228,25 +273,51 @@ worth knowing before you use it elsewhere: ### 4. Rows -First row uses `top="8"`; later rows chain with `top_pad`. Every value row gets -an 18px reset glyph. Copy these verbatim and edit names/keys/ranges: +First row uses `top="8"` (`top="7"` for a setting row, which is two pixels +taller than a slider); later rows chain with `top_pad`. Every value row gets an +18px reset glyph. Copy these verbatim and edit names/keys/ranges: -**Scalar (slider) row** — `top_pad="9"` between slider rows: +**Scalar row** — one `setting_row`, `top_pad="8"` between setting rows: ```xml - - + ``` +`setting_row` (`indra/llui/alsettingrow.{h,cpp}`, tested by +`alsettingrow_test`) is the slider and its reset glyph as one widget, and it +takes the slider's attributes by the slider's names. What it does for you: + +- **The reset glyph is built in** and shows only while the setting differs from + its default *as shown* — rounded to the row's `decimal_digits`, so a drag that + comes back to 0.7 and lands on 0.69999999 leaves nothing lit. Its slot is kept + while it is hidden, so nothing moves when it appears. In the Lightbox its reset + comes through `onClickResetControlDefault` (the floater takes it with + `setResetHandler` in `postBuild`), so it is a named undo step like any other. +- **Only the row is bound.** Reset All's walk finds one key per row, a commit is + one write, and `enabled_control` / `disabled_control` grey the slider, its + label and value *and* the reset glyph together. +- **The value box sizes itself** where a slider's would cut the number short — + `max_val` under 1, at or below 0, or a `min_val` wider than `max_val` — so §7 + no longer applies to it. `text_width` still wins where it is written. +- `label_width="140"` and `can_edit_text="true"` are its defaults and need not + be written. +- **It says when a drag begins and ends**, by the slider's own names: child + elements `setting_row.mouse_down_callback` and `setting_row.mouse_up_callback`. + A setting that is dear to apply uses them to leave its work for the release — + the seven lens dirt generation rows raise and lower the pipeline's + slider-held flag this way, so the plate is drawn once per drag. + +Every scalar row on the four tabs is a `setting_row`. The form they replaced — a +`slider` (`right="-32" height="16"`) followed by a separate reset `button` +(`top_pad="-17"`, `LightBox.ResetControlDefault` with the setting repeated as +its `parameter`) — still works, but do not add one: it is two widgets to lay out +against each other and a setting name to keep in step by hand. A slider that is +*not* a scalar row (a vector bank's component, or one driven from C++ like the +day cycle's time) stays a plain `slider`. + **Checkbox row** (no reset glyph): `check_box` with `control_name`, `top_pad="10"` after a button, `top_pad="8"` after another checkbox. @@ -259,7 +330,13 @@ work only with values whose `%lg` stringification is exact ("2", "1", "0.5"). at `left_delta="140" top_pad="-18" width="60" height="24"` with `can_apply_immediately="true"` and **`label_height="0"`** (without it the default label strip leaves ~1px of color) + reset at `top_pad="-21"`. Color3 -alpha handling lives in the widget — nothing else needed. +alpha handling lives in the widget — nothing else needed. A swatch bound to a +**Color3** setting picks inline without any XUI to say so (§4j): `postBuild` +hands every such swatch's picker to the floater, and the popover it opens under +the swatch previews live, puts the colour back on Escape, and records the whole +pick as one undo step. Its "Full picker..." button is the way on to the +viewer's own picker, for typed values and the eyedropper. Keep tints within +0-1: the inline picker's tracks stop there. **Vector row**: label `text` (width 110, `top_pad="10"`) + spinners named `vec3__<0|1|2>` at `left_delta="110" top_pad="-16" width="68" @@ -269,6 +346,15 @@ height="18"` (then `left_delta="72" top_delta="0"`), each with per-axis contain no underscores, so the name parse is unambiguous. Omit components that are unused — label the ones you keep by meaning. +Give each spinner its channel letter as `label` (`label_width="10"`) and +`scrub="true"`: dragging the letter sideways changes the value, 4px per +increment, with Shift for 0.01×, Ctrl for 0.1× and Alt for 10×. Undo folds a +whole scrub into one step on its own, because every move writes the same +setting. Leave `scrub` off a spinner **without** a label: the drag then moves +onto its arrow buttons, and they lose hold-to-repeat. The two SSAO spinners on +the Scene tab have no label, their caption being the text beside them, so they +do not scrub. + A **bank** of the same component across many settings is the other shape this supports, and it wants ordinary slider rows rather than the compact spinner layout: one section per component, one slider per setting, named @@ -314,7 +400,8 @@ editable channel fields, all driving one Vector3 setting. **`curve_editor`** — a graph with draggable handles, used for the tone curve and the split-tone bands. It owns no curve: a consumer hands it a sampling function -and a handle list, which is why one widget serves both. +and a handle list, which is why one widget serves both. The tone curve also +lets the user add and remove points; that is a param, not a second widget: ```xml Environment > Midnight, and without +the claim it sets the sky to midnight instead of redoing. The Lightbox is an +`ALStudioFloater` (llui), which claims them, and whose `handleUndoKeys` answers +Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z through the floater's `undo()` / `redo()`. A key +the floater does not handle falls through to the menus as before. The one thing +the menu used to add, Edit > Undo for a text control with an edit history, the +base offers itself: a focused text control inside the floater that +`canUndo()`/`canRedo()` gets the key before the grade does. Any new shortcut +here has to be checked against `menu_viewer.xml`, because it is the Lightbox +that wins the clash. The Undo and Redo buttons in the top bar are the visible half of that. Both, and the reference row, are greyed from `draw()` rather than from a signal — the undo stack moves on every commit, every undo and every Look apply, and hanging a refresh off each of those is more places to forget than a polled compare costs. -### 4g. The top bar - -`lightbox_topbar` in `floater_lightbox_settings.xml` is an ordinary `panel` of -ordinary widgets, and worth reading before you add to it, because it is the one -part of this floater with a fixed width budget: **412px at `min_width`** — the -floater's 420 less its own `left="4"`/`right="-4"`, and nothing else, because -the bar sits directly in the floater. A row inside an accordion section starts -from the same 420 and loses far more (§5); the two budgets are different on -purpose. The bar currently spends 342 of its 412. - -Left to right: the Looks `combo_box`, then Save / Save As / Delete / Revert, -then Undo / Redo, then Scopes. Three groups, separated by 12px where the -adjacent buttons inside a group are separated by 4. **That gap is the only thing -that says they are different kinds of thing** — the first group acts on the -Look, the second on the grade's own history, the third opens another window — so -keep it if you add a fourth kind, and use 4px if you are extending a group. - -Everything after the combo is an **18px icon with an empty label**, and the -tooltip carries the name. That is not decoration. Four text labels cost 192px of -the 412; the same four icons cost 80. It also sidesteps §5's silent clipping the +### 4g. The top bar and the tab strip + +**The bar** (`lightbox_topbar` in `floater_lightbox_settings.xml`) holds every +button the floater has but one. Left to right: the Looks `combo_box`, then Save +/ Save As / Delete / Revert, then Undo / Redo / History, then Scopes, then +Find. **The one button that is not there is Pop-out**, at the right end of the +tab strip: it acts on the tab that is up, so it sits at the end of the tabs. +**Only a button about a tab belongs on the strip**; Scopes and Find were tried +there and read as out of place, since neither is about a tab. The bar used to +hold Pop-out as well, and at `min_width` it had spent all 422px it had, which +had pushed `min_width` from 420 to 430. With Pop-out on the strip, `min_width` +is back to 420, and the bar has room to spare, which the Looks combo takes: it +stretches with the floater instead of being the fixed 150px that was left over. +It is 148 at `min_width` and 188 at the default 460. + +The row was sized in XUI Studio: the bar is `left="6" top="16"`, 26 tall, and +everything in it is 24 tall at `top="0"`. The buttons are 24×24. The combo +takes `left="1" right="-261"` and follows the right edge; `left="1"` puts it at +the floater's x 7, over the tab container's left edge. The nine buttons follow +`top|right` and are placed by `right` rather than `left_pad`: the last is 6 in +from the edge, each one before it 25 further in (24 and a gap of 1), and 10 more +where the group changes. **Four groups, separated by 11px where the buttons +inside a group are separated by 1.** That gap is the only thing that says they +are different kinds of thing: the first group acts on the Look, the second on +the grade's own history, the third opens another window, and the fourth finds a +place in this one. So keep it if you add another kind, and use 1px if you are +extending a group. A new button here comes out of the combo's width: add 25 to +the 261, or 35 if it starts a group of its own. + +**Every `top` in this file is measured from under the title bar**, not from the +window's top edge. The floater sets no `legacy_header_height`, so once its +widgets are built, `LLFloater::initFloaterXML` grows the window upward by the +whole `header_height` (25px in the default skin), and `LLView::setRect` moves +none of the widgets. So the bar's `top="16"` is 16px below the title bar. A +floater that sets `legacy_header_height="18"`, as most of the viewer's do, only +grows by 7px, so there a `top` under 18 lands in the title bar. + +**The strip's button is a child of the `tab_container` itself,** after the four +pages. That is load-bearing three ways: + +- A `tab_container` makes a tab of every *panel* it is given + (`LLTabContainer::addChild`) and keeps any other child as it is, so a strip + button must be a direct child. Wrapped in a panel, it would become a fifth + tab. +- `tab_padding_right="37"` keeps the strip's right end clear. `fill_width` + shares out only what is left (`stripRoom`), so the tabs stop 8px short of the + button. The padding is the button's width plus its 6px margin plus that 8px, + less the page border; resize the button and it has to change with it. The tabs' mouse capture (`tab_rect` in `handleMouseDown`) ends where + the padding begins, so a button there gets its own clicks. Put a button inside + the tabs' span and the container captures the mouse on press, and the button + never sees its release. `lltabcontainer_test` test 28 pins all three. +- Laid over the strip as a sibling of the `tab_container` instead, it would + overlap its rect, and XUI Studio's lint reports every pair of visible + siblings that intersect. + +Pop-out and Find both sit at `right="-6"`, and the bar and the tab container +both end at `right="-4"`, so the right edge of the two rows is one column. The +tab container starts at `left="7"`, one pixel in from the bar's `left="6"`, so +that its left edge lines up with the combo's. + +**Pop-out is 24×24 like the bar's buttons, and that was judged by eye.** The +strip is 23 tall (`tab_height`; the page starts right under it), so by the +numbers a 24px button at `top="0"` reaches a pixel into the page. In the +viewer, though, 24 reads as the same height as the tabs. 23, the strip's exact +height, was tried and looked misaligned. XUI Studio's lint only reports +siblings that overlap by 2px or more, so it says nothing about this one. Change +this size by looking at it, not by arithmetic. + +Everything but the combo is an **18px icon on a button with an empty label**, +and the tooltip carries the name. That is not decoration. Four text labels cost +192px of the bar; the same four icons cost 99. It also sidesteps §5's silent clipping the day this floater is translated and "Save As" becomes "Speichern unter" — a bar of labels has no reflow and no scrollbar to save it. Take the overlays from the viewer's existing set (`Script_Save`, `Conv_toolbar_plus`, `TrashItem_Off`, -`Refresh_Off`, `Script_Undo`, `Script_Redo`, `Command_Stats_Icon`) rather than -adding art; picking a glyph that already means the right thing elsewhere is most -of the work. +`Refresh_Off`, `Script_Undo`, `Script_Redo`, `Conv_toolbar_call_log`, +`Command_Stats_Icon`, `Command_Search_Icon`, `Conv_toolbar_arrow_ne`/`_sw`) +rather than adding art; picking a glyph that already means the right thing +elsewhere is most of the work. The pop-out pair is the IM window's tear-off +icon, and swaps with the state of the tab that is up. **A button that opens another floater needs no C++ at all** — `Floater.Toggle` is a global commit callback in `llui.cpp`, with the floater's registered name as `parameter`. Use that one and not `Floater.ToggleOrBringToFront`; §4d explains why the second can only ever open. +**The tab strip** below the bar is a stock `tab_container` with `fill_width`, so +the four tabs share its whole width rather than bunching at the left. A tab's +tooltip is written on its page element in the floater XML: `addTabPanel` copies +a page's `tool_tip` onto its tab button, and `postBuild` then clears it from the +page. The second half matters. `LLView::handleToolTip` offers a parent's tooltip +before its children's, so a tooltip left on the page pops up over every caption, +gap and header on it that has no tooltip of its own. A fifth tab gets both +behaviours by being declared the same way. + ### 4h. The Sky tab, and changing the world The Sky tab holds two kinds of thing and they do not behave alike. @@ -747,28 +1012,190 @@ which is what lets `aldaycyclelandmarks_test` exercise it with a sine wave and no viewer around it. Sampling costs ninety-six blends, so it is cached against the day it was computed from and never runs on the frame path. +### 4i. Finding widgets: the directory + +**After `postBuild`, nothing in the floater looks a widget up by name.** +`ALLightboxDirectory` walks the four pages once, at the top of `postBuild`, and +from then on everything the floater needs is held by pointer: each section +(`sec_`, its accordion tab, its accordion, the page it is on) and each bound +setting (the control, and what its row calls it). Reset All finds its panels +there, the tonemapper rows and the Looks bar buttons are cached beside it, and +the only `getChild`/`findChild` calls left are in `postBuild` and the `setup*` +functions it calls — `grep -nE "getChild|findChild" alfloaterlightbox.cpp` is +the check. + +The reason is that a page can leave the floater. A tab taken out into a window +of its own takes its widgets with it, and a search from the floater's root no +longer reaches them; a pointer keeps working wherever the widget goes. So a new +callback that needs a widget should take it from the directory, or cache it in +`postBuild`, never search for it when it runs. + +The directory also reads each setting's **caption**, which is what finding a +setting by name and naming an undo step both show, so a new row should caption +itself in one of the ways it understands, in this order: + +1. The control's own label — a slider's `label`, a checkbox's `label`, a colour + wheel's `label` (a text box the control holds and names `...label`). +2. Otherwise a `text` on the same line to its left: the row's middle falls + within the text's height and the text ends at the control's left edge. This + is how dropdown, colour and vector rows are captioned already. A vector + row's spinners are captioned this way even though they have labels, because + their labels are channel letters. +3. Otherwise, for a switch on a section header, the section's title. +4. Otherwise the setting's key made into words — the `Render`/`Alchemy` prefix + dropped and the words split, so `RenderReferenceWipeMode` reads "Reference + wipe mode". Serviceable, but a sign the row should caption itself properly. + Of the 170 settings on the four tabs, only the reference still's mode + dropdown ends up here, because it shares its line with the Grab and Clear + buttons. + +A trailing colon is dropped, so `Radius:` reads as `Radius`. + +### 4j. Popovers, and finding a setting + +**Find** (the search button, or Ctrl+F) lists every section and every setting +from the directory, ranked against what is typed by XUI Studio's `ALQuickOpen`, +in a popover hanging from the top bar. A setting's second column is its +section's title, which is what tells the five Strength rows apart; sections are +listed as well as settings because the list matches what a row is *called*, and +"bloom" should reach the Bloom section though none of its rows says bloom. +Return goes to the choice: its tab is chosen, its section opened (only its own +accordion tab, since an Advanced section is a sibling of its essentials, not +inside them), the accordion scrolled to the row, the row given the keyboard, and +its caption lit for a couple of seconds (`SearchableControl::setHighlighted`, +the same highlight Preferences search uses). A section is shown by its header. + +Popovers are XUI Studio's `ALPopover`: a chrome-less window under an anchor +that closes when it loses the keyboard. The floater wraps it in three rules, +which any new popover here should go through `showPopover` to get: + +- **One at a time.** Opening one closes the last (`mPopover`, `mPopoverKind`), + and `onPopoverClosed` ignores a popover that has already been replaced. +- **Handles, never `this`.** The popover is a top-level window and can outlive + the floater by a frame; its closed callback holds an `LLHandle` to the + floater. `onClose` settles any popover still up, and the destructor `die()`s + one as a fallback — `die()`, so nothing calls back into a floater half torn + down. +- **The floater's shortcuts come along.** A key pressed in a popover never + climbs to the Lightbox, since the popover is a window of its own, so + `ALPopover` sends home the keys it and its content do not take, to the + floater its anchor was in (and claims accelerators, for the reason in §4f). + Ctrl+Z / Ctrl+Y step the stack with History up; Return in the history list + goes to the selected step; Ctrl+F with Find up takes the keyboard back to the + field, keeping what was typed. + +Find is the studio base's `quickOpen`, hung from the top bar and as wide as it; +the base owns that popover, and only History and the colour picker go through +`showPopover`. + +Two more things learned the hard way. A popover given a **title** through +`ALPopover::show` lays its content over the title bar, so Find and History have +none; one that wants a title lays its own controls out under +`getHeaderHeight()`. And a popover whose content is an `LLPanel` never sees +Escape — the panel takes it and drops the keyboard, and the popover then closes +as *settled*, not escaped. A popover that has to tell Escape apart must hold its +controls directly rather than in a panel. + +**The colour popover** is both of those: `ALLightboxColorPopover` is titled with +the row's caption and holds XUI Studio's `ALColorPicker` directly, so Escape +reaches it. Its session is kept by the floater (`mColorKey`, `mColorOriginal`): + +- The picker writes the setting on every move, as every row here does, under + `mColorWriting`, which the undo recorder skips. The picker's own value is + *text* ("r, g, b, a"); the colour is read from `color()` and written as three + numbers, the way the swatch writes a Color3. +- Escape writes the original back. Anything else that closes it records one + step, from the colour it opened on to the one it was left at, and none if + they are the same. +- No undo group is held open for the length of a pick. A popover can die + without saying it closed, and a group left open would swallow every later + write into itself. +- A pick still open when the stack is stepped is put back first, since the + stack has not heard of it yet. + +**Its size is the ring's size.** `ALColorPicker` gives its channel sliders the +first 218px of the width, and the ring whatever is left, up to the picker's +height. So the picker's width is the ring plus 218. It opens 440 by 220, a ring +as tall as the picker, and can be dragged down to 330 by 150. It started at 300 +wide, and the ring was 74px across. The popover is resizable, the picker grows +with it, and the next colour opens at the size the last one was left at (for +the session: `sWidth`/`sHeight`, the way XUI Studio's colour field keeps +its size). Anything added to the popover must not eat into that width, or the +ring is what pays for it. + +### 4k. Tabs in windows of their own + +The pop-out button (at the end of the tab strip) takes the tab that is up out into a window of +its own, so Look and Lens can be open side by side, or a tab parked on another +monitor. Each page is wrapped in XUI Studio's `ALDockPanel` at the very end of +`postBuild`; popping out *moves* the page's contents into an `ALPanelFloater` +titled "Lightbox: Look", and the page left behind shows an `ALEmptyState` with a +"Put it back" button. Closing the window puts the page back too, and so does +the pop-out button, whose icon and tooltip follow the state of the tab that is +up. Find reaches into a page that is out by raising its window. + +What this asks of the rest of the floater: + +- **Nothing may look a widget up by name after `postBuild`** (§4i). A search + from this floater's root does not reach a page that is out, and it is not an + error when it fails — it just finds nothing, and Reset All quietly resets + nothing. That was the reason for the directory. +- **Pages go back before the floater closes.** The pages are regions of the + studio base's `ALPaneFolds` (`mFolds`), bound at the end of `postBuild`. The + base's `onClose` saves which are out and where, then docks them all: a page + left in another window would outlive the callbacks it is wired to. It does + so even when the viewer is quitting, because windows are closed in no + particular order then and `onClose` is the last point at which both this + floater and every torn-off window are sure to be whole. The destructor docks + again as a fallback. +- **Keys still reach the floater.** `ALPanelFloater` claims accelerators and + passes a key it does not handle to the floater it came from, so Ctrl+Z, + Ctrl+Y and Ctrl+F work from a torn-off tab. +- **The keyboard is not taken along.** `ALDockPanel::popOut` lets a focused + control go before its page leaves, and makes the window forget it was its + last focus, so no keystroke lands in a window nobody is looking at. +- **Where they were is remembered**: `ALLightboxState` holds, per page name, + whether it was out (`out_`) and its window's rect (`rect_`), and + the Lightbox's own rect; `loadState` at the end of `postBuild` takes the + pages out again. `refreshPaneRow` (polled from `draw()`, since a torn-off + window's close box docks without telling anyone) saves whenever the set of + pages out changes. + ### 5. Height math (the part everyone gets wrong) +**The floater now does this sum itself.** `fitSections()` runs in `postBuild` +and sizes every `sec_*` panel to its lowest row's bottom + 8, and its +accordion tab to that + 29, by the same `size_changes` notification anything +that grows inside an accordion sends (the tab takes the new height if it is +open, or remembers it for when it opens; `llaccordionctrltab_test` pins that). +So a section whose declared heights are wrong is no longer clipped or padded in +the viewer. **Keep the declared heights right anyway**: they are what the first +layout pass and XUI Studio's preview use, and XUI Studio's lint measures +against them. The rules below are how. + - `accordion_tab` height **must be** inner panel height **+ 29** (25px header + 2+2 padding). The tab's rect *is* its expand height and `fit_panel` squeezes the panel into what remains; an undersized tab clips the bottom rows and the overflow draws over the sections below (panels do not clip children). - Compute the panel height by walking the `top_pad` chain to the **last - widget's bottom**, then add 8. `top_pad` chains from the *previous widget*, - which for a slider row is its reset button (18px tall, hanging 1px below the - 16px slider) — so slider+reset rows pitch **26px**, not 25. -- Worked example: slider row at `top="8"` (slider 8-24, button 7-25), second - slider `top_pad="9"` (34-50, button 33-51), Reset All `top_pad="10"` - (61-79) → panel height 87, tab height 116. + widget's bottom**, then add 8. `top_pad` chains from the *previous widget*; + a setting row is 18px tall at `top_pad="8"`, so rows pitch **26px**. (The old + slider rows pitched 26 too — their reset button hung 1px below the 16px + slider, and it was the button the next row chained from. A setting row is + exactly that button's rect, which is why converting a row moved nothing.) +- Worked example: setting row at `top="7"` (7-25), second row `top_pad="8"` + (33-51), Reset All `top_pad="10"` (61-79) → panel height 87, tab height 116. - **Side-by-side widgets break the chain.** A bank of three wheels all use `top="8"`, so the bank's bottom is *one* wheel's height, not three; the next row's `top_pad` chains from the last one declared. Get this wrong and the panel is either 350px too tall or clipped. - **A row of buttons has to be sized for `min_width`, not for the default.** The arithmetic that matters is 420 − 28 for the chrome − 15 for the accordion - scrollbar − 16 for `left="8"`/`right="-8"`, which leaves **361px**. The Light - tab's four presets are 85 wide with 6px gaps and end at 366 of 369. Laid out + scrollbar − 16 for `left="8"`/`right="-8"`, which leaves **361px**. (For a + while `min_width` was 430, for the top bar, §4g. The rows here were all laid + out to 361 throughout.) + The Sky tab's four presets are 85 wide with 6px gaps and end at 366. Laid out against the 460 default they looked fine and lost their last button the moment the floater was narrowed. - Widths never reflow. Usable inner width is the floater's width − 28, and @@ -791,9 +1218,23 @@ the day it was computed from and never runs on the frame path. widget greys it live (they connect to the control's signal). Boolean controls only; apply per row, not on the parent panel. Reference patterns: -- HDR fork: bloom rows `enabled_control="RenderHDREnabled"`, legacy glow rows - `disabled_control="RenderHDREnabled"` — greying, not visibility, so the - layout never gets holes and both modes stay discoverable. +- **HDR fork: only the side the renderer runs is shown.** Bloom (HDR) and its + Advanced section apply while `RenderHDREnabled` is on (on GL above 4.05, the + pipeline's own test), and so do Cross Filter and its Advanced section, which + streak what the bloom pyramid holds; Glow (Legacy) and its Advanced section + apply while it is off. An effect that only runs on one side of the fork + belongs in the table in `refreshBloomSections()`, not just behind an + `enabled_control`. `refreshBloomSections()` hides the other side's accordion tabs + and re-arranges the accordion, live on the setting's signal. A tab's + visibility alone does not re-lay an accordion out; `arrange()` does. Find + leaves hidden sections out. The rows still grey on the same setting + (`enabled_control` on bloom and the cross filter, `disabled_control` on glow), which is all that + anything building the panel without the floater gets. This used to be + greying alone, to keep both modes discoverable. The cost of hiding is that + the other mode's sections are out of sight, and `RenderHDREnabled` is + deliberately not switchable from the Lightbox (it resets probes, + reallocates buffers and rebuilds shaders). It follows "HDR and Emissive" + (`RenderDisableVintageMode`) in Preferences > Graphics > Advanced. - **`RenderColorGrade` is the master switch for the entire grading suite** (LUT *and* Basic, White Balance, Split Toning, Lift/Gamma/Gain, Tone Curve). Any new grading control must gate on it or it will look inert. It lives on @@ -810,14 +1251,24 @@ only; apply per row, not on the parent panel. Reference patterns: - **`gSnapshotNoPost` gates the renderer, not the UI, and it is easy to miss.** The snapshot floater's "No post-processing" box has to mean it, so a new *print* effect must check it wherever its strength is uploaded — there are - `clean_plate` gates in **two** places, because post-grade is two passes. - Effects in the final blit (vignette, grain, CVD, the preview modes) gate in - `renderFinalize`; effects applied *inside* the colorCorrect program - (chromatic aberration, lens flare) gate in `colorCorrect`, because they run - in every variant including the no-post ones — those two leaked through the - first time for exactly that reason. The one deliberate exception is dither, + `clean_plate` gates in **three** places, because post-grade is two passes + and the flare keeps history. Effects in the final blit (lens distortion, + vignette, grain, CVD, the preview modes) gate in `renderFinalize`; effects + applied *inside* the colorCorrect program (chromatic aberration, lens flare, + lens dirt, the cross-filter composite) gate in `colorCorrect`, because they + run in every variant including the no-post ones — the first two leaked + through the first time for exactly that reason; and `generateLensFlareState`, + which owns the flare's history, skips its update on a no-post frame rather + than clearing it, so the flare does not blink after the capture. The one + deliberate exception is dither, which is a quantisation aid rather than a look and which an 8-bit PNG wants either way. +- **A generation pass is a third case, and gating it is a mistake.** + `generateLensDirt` deliberately ignores `gSnapshotNoPost`: the flag is true + for the single frame a no-post snapshot is taken, so releasing the plate for + it would buy a full regeneration on the very next frame — a hitch every time + someone takes one. What has to be gated is the *use* of the plate, in + `colorCorrect`, not its production. Two things that only show up on screen, both of which did: @@ -833,9 +1284,11 @@ Two things that only show up on screen, both of which did: ### 7. Slider text width -Any slider with `max_val` below 1.0 (or ≤ 0) **must** set an explicit +Any plain `slider` with `max_val` below 1.0 (or ≤ 0) **must** set an explicit `text_width` (56 fits a signed 4-decimal value). Without it `LLSliderCtrl` -auto-sizes the value box from `log10(max_value)` and truncates the number. +auto-sizes the value box from `log10(max_value)` and truncates the number. A +`setting_row` works the width out itself in exactly those cases (§4), so this +is only for sliders that are not rows — vector-bank sliders, for instance. ### 8. Cadence and tooltips @@ -854,16 +1307,32 @@ source of truth for save, dirty-watching, and the whitelist-filtered apply. Skip it and Looks silently won't carry the effect. Do **not** add: Scene-tab keys, `Persist=0` keys, structural buffer-shape knobs, or debug toggles. A startup `LL_WARNS("Presets")` fires for whitelist names that stop existing, -so renames get caught. +so renames get caught. LLSD-typed keys (the tone curve point lists) round-trip +as arrays like any other value; Debug Settings shows them read-only as +notation, so the graph, the presets and Looks are their only editors. + +`audit_bundled_looks()` runs from the `LLPresetsManager` constructor and checks +the bundled Looks two ways: any whitelisted key a Look is missing, and any key +whose stored `Comment` no longer matches the live setting's. The second is a +maintenance rule worth stating plainly — **rewording a setting's `Comment` in +`settings_alchemy.xml` obliges you to re-save the three bundled Looks**, which +each carry their own copy that nothing reads. Five keys had already drifted +that way before the check existed, silently, because presence was all anything +verified. Bundled starter Looks live in `app_settings/looks/` as full whitelist snapshots ({Comment, Persist, Type, Value} per key, URI-escaped filenames). -**Add your keys to all three at their defaults**, or applying a bundled Look -will leave the new effect at whatever the user had rather than clearing it — -`loadLooksPreset` writes only keys present in *both* the whitelist and the file, -so a missing key is a silent no-op and "Neutral" stops meaning neutral. To -refresh them after tuning: save the Look in the viewer, then copy the saved file -from `/presets/looks/` over the bundled one. +**Add your keys to all three at their defaults**, so the files stay complete +snapshots of the whitelist. `loadLooksPreset` applies the whitelisted keys the +file carries and resets to default every whitelisted key it does not mention, +so a Look saved before a key existed applies that key at its default — which +is what keeps "Neutral" neutral for a user whose seeded copies predate the +key. Three fences: only a file carrying at least half the whitelist counts as +a snapshot (a truncated or hand-trimmed file applies what it has and touches +nothing else), a key that is present but malformed is skipped as it always +was, and the `RenderColorGrade` master switch is never reset by absence. To refresh the bundled +files after tuning: save the Look in the viewer, then copy the saved file from +`/presets/looks/` over the bundled one. A Look whose settings have since changed is shown as `Name *` in the combo, via the `look_name_modified` string — **on the name, not beside it**. A detached @@ -882,6 +1351,10 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. - XML well-formedness before launching (any XML-capable tool). - Two-way binding: move the row, watch the key in Debug Settings; edit the key there, watch the row follow. +- For a setting row: drag it off its default and watch its reset glyph appear; + drag it back and watch it go; press it and confirm the default returns as one + undo step called "Reset ". With the row's gate off, the glyph greys + with the slider. - Gating flips live; section Reset All touches exactly the section's keys (including its Advanced sibling); the tab opens to full height with nothing clipped or drawing over the next section. @@ -892,14 +1365,27 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. confirm it rides the reachable boundary rather than freezing or jumping, and that the number shown is the clamped one. - For a graph: drag each handle to its limit and confirm the setting clamps and - the handle is put back where the setting actually landed. + the handle is put back where the setting actually landed. A plain click on a + handle must not mark the Look `*` or add an undo step. +- For the tone curve: double-click empty space to add a point and drag it; + double-click it to remove it; confirm both end points refuse removal and the + 17th point is refused; confirm a drag, an add, a remove and a preset are each + one Ctrl+Z step; switch channel and confirm the handles and ghosts swap; + change graphics preset with a curve active and confirm the image keeps it + (the texture is recreated and re-baked); untick the section header and set + Amount to 0 and confirm both bypass. +- For the split-tone edges: drag each past the balance handle and confirm the + width clamps at the slider's range and the handle is put back; at a low + balance with a wide shadow ramp the left handle is held at the plot edge, + locked and dimmed, while the slider keeps the true width — the slider is + the control for it, and dragging the held handle writes nothing. - For anything measured (scopes, vectorscope): change the thing it measures and confirm the readout moves the way the control says it should. - For a section switch: **untick** it (ticked is on) and confirm the image - changes **and the Looks `*` does not appear**. Then close the Lightbox with it - still unticked and confirm the render comes back — state parked outside the - floater is the failure mode here, and it looks like a renderer bug rather than - a UI one. + changes **and the Looks `*` does not appear**, and that the Look tab's badge + counts it. Then close the Lightbox with it still unticked and confirm the + render comes back — state parked outside the floater is the failure mode here, + and it looks like a renderer bug rather than a UI one. - For the Sky tab's day cycle: freeze, wait past the point the sky would have moved, and confirm it has not. Then untick and confirm you get back *what you had*, not the region default — set a Personal Lighting sky first, since that @@ -908,6 +1394,17 @@ deleted stays deleted. Nothing is ever copied over a file that already exists. Finally, confirm the presets land somewhere plausible on a region whose day cycle is not the default one, because a hardcoded fraction would also look right on a default region. +- For anything that acts on a section or a row from C++: do it once with the + tab in place and once with it popped out, since a lookup by name after + `postBuild` fails silently only in the second case. +- For a colour row: click the swatch, drag, and watch the render follow; press + Escape and confirm the colour comes back with no undo step; pick again and + click away, and confirm it is one step. +- For a new discrete action: do it, open History, and confirm it is one step + under its own name, and that double-clicking the step before it undoes it. +- For a new row: Ctrl+F, type its caption, and confirm it is listed under the + name its row shows (not its setting key — §4i says what a row needs for that) + and that Return lands on it, scrolled into view with its caption lit. - For anything on an accordion header: click it and confirm the section does **not** expand or collapse, then hover it with the section expanded and confirm its own tooltip appears rather than the title's. Those are the two diff --git a/doc/VCPKG-R2.md b/doc/VCPKG-R2.md new file mode 100644 index 00000000000..d6ed88d2ce4 --- /dev/null +++ b/doc/VCPKG-R2.md @@ -0,0 +1,168 @@ +# vcpkg binary cache on R2 + +The build workflow can use a private Cloudflare R2 bucket through vcpkg's +experimental `x-aws` provider. Transfers go directly to the R2 S3 endpoint. +The local cache stays enabled. This branch leaves GitHub NuGet caching disabled. + +## Pipeline configuration + +Create a dedicated private R2 bucket using Standard storage. Configure these +GitHub Actions repository variables and secrets before enabling the integration. + +| Actions setting | Kind | Pipeline environment variable | Value | +| --- | --- | --- | --- | +| `VCPKG_R2_ENABLED` | Variable | Workflow condition | `true` to enable | +| `VCPKG_R2_ENDPOINT_URL` | Variable | `R2_ENDPOINT_URL` | `https://.r2.cloudflarestorage.com` | +| `VCPKG_R2_BUCKET` | Variable | `R2_BUCKET` | Bucket name without a path | +| `VCPKG_R2_ACCESS_KEY_ID` | Secret | `R2_ACCESS_KEY_ID` | Writer's R2 S3 access key ID | +| `VCPKG_R2_SECRET_ACCESS_KEY` | Secret | `R2_SECRET_ACCESS_KEY` | Writer's R2 S3 secret access key | +| `VCPKG_R2_READ_ACCESS_KEY_ID` | Secret | `R2_ACCESS_KEY_ID` | Reader's R2 S3 access key ID, reserved for restoring read-only PR access | +| `VCPKG_R2_READ_SECRET_ACCESS_KEY` | Secret | `R2_SECRET_ACCESS_KEY` | Reader's R2 S3 secret access key, reserved for restoring read-only PR access | + +No account, bucket, or credential is embedded in the repository. Jurisdictional +`.eu.r2.cloudflarestorage.com` and +`.fedramp.r2.cloudflarestorage.com` endpoints are also accepted. +Objects use `cache/.zip`. The prefix is fixed so build configuration and +retention rules address the same objects. + +For the temporary cache population test, protected non-PR runs and +same-repository PRs both use `R2_CACHE_MODE=readwrite` and the bucket-scoped +Object Read & Write credentials. After testing, restore the PR step to +`R2_CACHE_MODE=read` and switch both credential references back to +`VCPKG_R2_READ_ACCESS_KEY_ID` and `VCPKG_R2_READ_SECRET_ACCESS_KEY`. +The script still defaults to `read` when the mode is omitted. The reader +credential must enforce Object Read permissions at R2; setting vcpkg to `read` +does not restrict a credential that can write. + +Protect the branches and tags that should publish cache entries. A manual +dispatch on an unprotected branch skips R2. Fork PRs also skip R2 to keep the +bucket private without exposing credentials. All PRs retain local read/write +caching. Same-repository Dependabot PRs need the credentials selected by the PR +step under Dependabot secrets as well, using the same names. +Missing credentials fail setup when R2 is enabled for a same-repository +PR. Do not put R2 credentials in job-wide environment variables or expose them +to fork builds. +Anyone able to change a workflow that receives write credentials can publish +cache binaries, so limit that access to trusted maintainers. + +Windows and Linux hosted runners supply AWS CLI v2. The workflow installs it +with Homebrew on macOS. Custom runners must install AWS CLI v2.13.0 or newer. +The setup script calls the repository's `vcpkg fetch aws` to find the executable +vcpkg will use, checks its version, and lists at most one object under `cache/`. +This read preflight uses `AWS_ENDPOINT_URL_S3`, without a command-line endpoint +override. A missing credential, CLI, or inaccessible bucket fails setup before +changing the job environment. It does not prove upload or restore behavior. + +After preflight, the script exports R2 credentials, the S3 endpoint, region +`auto`, and the extended `VCPKG_BINARY_SOURCES` through `GITHUB_ENV`. Protected +non-PR builds and same-repository PRs can write to R2 during this test. +Packages restored from the local cache +are not automatically copied into R2. Seed required ABIs explicitly or allow +R2 to fill as dependencies rebuild. + +## Retention + +No lifecycle rule or Worker is deployed by this integration. Choose retention +on the bucket separately. A starting policy is expiration after 90 days under +`cache/`, with incomplete multipart uploads aborted after seven days. Age-based +expiration can delete frequently used packages. It does not measure usage or +enforce a storage quota. Retain release dependencies separately if rebuilding +them later is unacceptable. + +Use a scheduled Worker only when a concrete policy needs more than native +expiration. Tracking dependencies used by successful builds also accounts for +local cache hits; remote read logs alone do not. + +## Developer access + +Install AWS CLI v2.13.0 or newer and obtain bucket-scoped Object Read credentials. +Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` through your credential +manager, or select a dedicated `AWS_PROFILE`. Then, in PowerShell: + +```powershell +$env:AWS_ENDPOINT_URL_S3 = $env:R2_ENDPOINT_URL +$env:AWS_REGION = 'auto' +$env:AWS_DEFAULT_REGION = 'auto' +$env:AWS_IGNORE_CONFIGURED_ENDPOINT_URLS = 'false' +$env:AWS_PAGER = '' +$env:VCPKG_BINARY_SOURCES = "clear;default,readwrite;x-aws,s3://$($env:R2_BUCKET)/cache/,read" +cmake -S indra --preset vs2026-os +``` + +Set `R2_ENDPOINT_URL` and `R2_BUCKET` in the shell environment first. On macOS +or Linux, export the same values and use the appropriate CMake preset. Read-only +credentials must enforce permissions at R2; the vcpkg `read` mode alone is not +an access control. Avoid stale `AWS_SESSION_TOKEN` values when using permanent +R2 credentials. + +## Live acceptance checks + +Run these before relying on R2 across the build matrix. The supplied +`logs_96086559729.zip` confirmed authenticated read preflights on all ten build +jobs, with zero restored packages and R2 in read-only mode. Uploads and +successful restores still need validation. + +1. On each runner platform, confirm the setup log identifies the vcpkg-selected + AWS CLI and passes the read preflight. Check a same-repository PR reports + `readwrite` mode and uses the writer credential during this test. After + reverting the temporary change, expect `read` and the reader credential. + Check a fork PR skips both R2 + setup steps, and a protected non-PR run reports `readwrite` mode. +2. Pick a real Alchemy cache ZIP larger than 100 MiB. Upload it using `aws s3 cp` + to a unique `validation//` prefix, download it to a different local + path, and compare SHA-256 hashes. Keep this separate from `cache/`. Do not + infer integrity from multipart ETags. +3. Demonstrate a vcpkg restore with only R2 configured. Use a new build directory + and a new install directory. For example, after a trusted producer has + populated matching ABIs on Windows: + + ```powershell + $run = [guid]::NewGuid().ToString('N') + $build = Join-Path $PWD "build-r2-verify-$run" + $env:VCPKG_BINARY_SOURCES = "clear;x-aws,s3://$($env:R2_BUCKET)/cache/,read" + cmake -S indra --preset vs2026-os -B $build ` + -DVCPKG_TARGET_TRIPLET=x64-windows-alchemy-avx2-release ` + "-DVCPKG_INSTALLED_DIR=$build/vcpkg_installed" ` + -DVCPKG_INSTALL_OPTIONS=--only-binarycaching + if ($LASTEXITCODE -ne 0) { throw 'Remote-only restore failed' } + ``` + + Match the producer's compiler, vcpkg revision, features, and triplets. Require + the vcpkg log to report packages restored from AWS and a successful configure. + `--only-binarycaching` makes a missing binary fail instead of quietly building + from source. The fresh directories avoid Alchemy's install stamp and existing + installed packages. `clear` removes local and NuGet cache reads. +4. With a separate empty validation prefix and another fresh build directory, + run without `--only-binarycaching` and confirm a cache miss builds successfully. + Use `readwrite` with the writer credential and then repeat with a fresh install + directory and `read` to demonstrate the resulting upload and restore. Do not + delete production cache entries to manufacture a miss. +5. With the consumer credential, confirm a known object downloads and an upload + to a unique validation key fails with `AccessDenied`. A network failure is not + evidence of read-only permissions. Use an authorized writer to remove only + the validation prefix after checking it contains the intended test objects. + +## Rollout and rollback + +This branch already disables NuGet caching and its authentication step. R2 is +added alongside the local cache. Run the remote-only checks on each platform; +a successful build with local caching enabled does not prove that R2 served a +package. Disabling `VCPKG_R2_ENABLED` restores local-only caching immediately. +It leaves bucket contents and retention policy untouched. Cache misses build +from source. + +Local setup checks: + +```text +python -m unittest discover -s scripts/vcpkg -p test_*.py -v +``` + +## References + +- [vcpkg binary caching providers](https://learn.microsoft.com/en-us/vcpkg/reference/binarycaching) +- [R2 AWS CLI setup](https://developers.cloudflare.com/r2/examples/aws/aws-cli/) +- [AWS endpoint configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-endpoints.html) +- [R2 lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) +- [GitHub runner software inventories](https://github.com/actions/runner-images/tree/main/images) +- [GitHub Actions secret availability](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) +- [Dependabot secrets in Actions](https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions) diff --git a/indra/cmake/Attribution.cmake b/indra/cmake/Attribution.cmake index e0303439e48..1e0bcffcd89 100644 --- a/indra/cmake/Attribution.cmake +++ b/indra/cmake/Attribution.cmake @@ -86,7 +86,11 @@ function(al_holder_line copyright out) set(line "") if(lines) list(GET lines 0 line) - string(REGEX REPLACE "^[ \t/*#-]*(SPDX-FileCopyrightText:[ \t]*)?" "" line "${line}") + # The prefix may be absent, and string(REGEX REPLACE) rejects an empty + # match before CMake 4.1; a capture takes the remainder either way. + if(line MATCHES "^[ \t/*#-]*(SPDX-FileCopyrightText:[ \t]*)?(.*)$") + set(line "${CMAKE_MATCH_2}") + endif() string(REGEX REPLACE "
[ \t]*$" "" line "${line}") string(STRIP "${line}" line) endif() diff --git a/indra/llcommon/llprocessor.cpp b/indra/llcommon/llprocessor.cpp index bd94f89dd14..3aa6ccf8985 100644 --- a/indra/llcommon/llprocessor.cpp +++ b/indra/llcommon/llprocessor.cpp @@ -186,6 +186,7 @@ namespace "SVE Instructions", // 47 }; +#ifndef LL_ARM64 std::string intel_CPUFamilyName(int composed_family) { switch(composed_family) @@ -241,7 +242,7 @@ namespace } return STRINGIZE("Unrecognized CPU vendor <" << cpu_vendor << ">"); } - +#endif } // end unnamed namespace // The base class for implementations. diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 52f0595d0ea..6493d58a79e 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -1973,8 +1973,6 @@ LLVolume::LLVolume(const LLVolumeParams ¶ms, const F32 detail, const bool ge mNumHullIndices = 0; // set defaults - mSculptValidationCache.fill(LLSculptValidationState::Unvalidated); - if (mParams.getPathParams().getCurveType() == LL_PCODE_PATH_FLEXIBLE) { mPathp = new LLDynamicPath(); @@ -2002,13 +2000,6 @@ void LLVolume::resizePath(S32 length) setDirty(); } -void LLVolume::setDirty() -{ - mPathp->setDirty(); - mProfilep->setDirty(); - mSculptValidationCache.fill(LLSculptValidationState::Unvalidated); -} - void LLVolume::regen() { generate(); @@ -3211,100 +3202,10 @@ S32 sculpt_sides(F32 detail) } } -static bool validate_sculpt_geometry(const LLVolume* volume) -{ - if (!volume || volume->getNumVolumeFaces() == 0) - { - return true; - } - - // Validate each face in the sculpt - for (S32 face_idx = 0; face_idx < volume->getNumVolumeFaces(); ++face_idx) - { - const LLVolumeFace& face = volume->getVolumeFace(face_idx); - - // Skip validation for degenerate faces - if (face.mNumVertices < 3 || face.mNumIndices < 3) - { - continue; - } - - // Calculate 'bounding' surface - LLVector4a extent_size; - extent_size.setSub(face.mExtents[1], face.mExtents[0]); - - F32 width = extent_size[0]; - F32 height = extent_size[1]; - F32 depth = extent_size[2]; - - F32 bounding_surface = width * height + width * depth + height * depth; - bounding_surface *= 2; - - if (!llfinite(bounding_surface) || bounding_surface <= FLT_EPSILON) - { - return false; - } - - // Calculate total surface area of all triangles - F32 total_triangle_area = 0.0f; - S32 num_triangles = face.mNumIndices / 3; - - for (S32 i = 0; i < num_triangles; ++i) - { - S32 idx_base = i * 3; - U16 idx0 = face.mIndices[idx_base]; - U16 idx1 = face.mIndices[idx_base + 1]; - U16 idx2 = face.mIndices[idx_base + 2]; - - // Validate indices - if (idx0 >= face.mNumVertices || idx1 >= face.mNumVertices || idx2 >= face.mNumVertices) - { - continue; - } - - const LLVector4a& v0 = face.mPositions[idx0]; - const LLVector4a& v1 = face.mPositions[idx1]; - const LLVector4a& v2 = face.mPositions[idx2]; - - // Calculate triangle area - LLVector4a edge1, edge2, cross; - edge1.setSub(v1, v0); - edge2.setSub(v2, v0); - cross.setCross3(edge1, edge2); - - F32 triangle_area = cross.getLength3().getF32() * 0.5f; - total_triangle_area += triangle_area; - } - - if (!llfinite(total_triangle_area)) - { - return false; - } - - // The idea here is that the total area of a sculpt is normally comparable - // to the area of a bounding box. But a random overlapping collection of - // triangles has a significantly larger area than a sculpt normally has. - F32 area_ratio = total_triangle_area / bounding_surface; - constexpr F32 MAX_AREA_TO_AREA_RATIO = 16.0f; - if (area_ratio > MAX_AREA_TO_AREA_RATIO) - { - LL_DEBUGS("LLVOLUME") << "Sculpt rejected: excessive triangle area." - << " Face index: " << face_idx - << " Total triangle area: " << total_triangle_area << " sq m" - << " Bounding surface area: " << bounding_surface << " sq m" - << " Ratio: " << area_ratio - << " Triangle count: " << num_triangles - << " Bounds: [" << width << " x " << height << " x " << depth << "]" << LL_ENDL; - return false; - } - } - - return true; -} // determine the number of vertices in both s and t direction for this sculpt -static void sculpt_calc_mesh_resolution(U16 width, U16 height, U8 type, F32 detail, S32& s, S32& t) +void sculpt_calc_mesh_resolution(U16 width, U16 height, U8 type, F32 detail, S32& s, S32& t) { // this code has the following properties: // 1) the aspect ratio of the mesh is as close as possible to the ratio of the map @@ -3414,62 +3315,10 @@ void LLVolume::sculpt(U16 sculpt_width, U16 sculpt_height, S8 sculpt_components, mSculptLevel = sculpt_level; - LLSculptValidationState cached_state = LLSculptValidationState::Valid; - if (!data_is_empty && (sculpt_level >= 0) && sculpt_level < SCULPT_CACHE_SIZE) - { - // validation might be expensive, so we do it only once per lod. - cached_state = mSculptValidationCache[sculpt_level]; - } - - switch (cached_state) - { - case LLSculptValidationState::Valid: - { - // Delete any existing faces, then regenerate - mVolumeFaces.clear(); - createVolumeFaces(); - break; - } - case LLSculptValidationState::Invalid: - { - // Invalid geometry - // Regenerate mesh with an empty placeholder - mVolumeFaces.clear(); - sculptGenerateEmptyPlaceholder(); - createVolumeFaces(); - break; - } - case LLSculptValidationState::Unvalidated: - { - // First time at this LOD - mVolumeFaces.clear(); - createVolumeFaces(); - - bool valid_geometry = true; - - // Todo: can this be backed into createVolumeFaces? - // Or calculated without having to call createVolumeFaces first? - // Todo 2: should the same be done for meshes? - valid_geometry = validate_sculpt_geometry(this); - mSculptValidationCache[sculpt_level] = valid_geometry ? LLSculptValidationState::Valid : LLSculptValidationState::Invalid; - - if (!valid_geometry) - { - LL_WARNS("LLVOLUME") << "Sculpt failed geometry validation - either invalid image or malicious. " - << "Replacing with placeholder. Image: " << mParams.getSculptID() << LL_ENDL; - - // Clear the malicious faces - mVolumeFaces.clear(); - - // Regenerate mesh with empty placeholder - sculptGenerateEmptyPlaceholder(); + // Delete any existing faces so that they get regenerated + mVolumeFaces.clear(); - // Recreate faces with placeholder geometry - createVolumeFaces(); - } - break; - } - } //switch (cached_state) + createVolumeFaces(); } diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index a4efcb2fb8b..fa68420dec9 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -60,8 +60,6 @@ class LLVolumeOctree; #include "llalignedarray.h" #include "llrigginginfo.h" -#include - //============================================================================ constexpr S32 MIN_DETAIL_FACES = 6; @@ -1033,7 +1031,7 @@ class LLVolume : public LLRefCount const LLVector4a& getMeshPt(const U32 i) const { return mMesh[i]; } - void setDirty(); + void setDirty() { mPathp->setDirty(); mProfilep->setDirty(); } void regen(); void genTangents(S32 face); @@ -1124,11 +1122,6 @@ class LLVolume : public LLRefCount bool mIsMeshAssetLoaded; bool mIsMeshAssetUnavaliable; - // Cache for sculpt geometry validation results - static constexpr S32 SCULPT_CACHE_SIZE = 6; // 0 to 5 inclusive - enum class LLSculptValidationState : S8 { Unvalidated, Valid, Invalid }; - std::array mSculptValidationCache; - const LLVolumeParams mParams; LLPath *mPathp; LLProfile *mProfilep; diff --git a/indra/llrender/llrender2dutils.cpp b/indra/llrender/llrender2dutils.cpp index 1824011e81e..fb0412d00db 100644 --- a/indra/llrender/llrender2dutils.cpp +++ b/indra/llrender/llrender2dutils.cpp @@ -1136,13 +1136,26 @@ void gl_rect_2d_checkerboard(const LLRect& rect, GLfloat alpha) //polygon stipple is deprecated, use "Checker" texture LLPointer img = LLRender2D::getInstance()->getUIImage("Checker"); // Per-binding: the Checker image is a shared UI texture, so wrap+point must not follow - // it to other users. + // it to other users. The quad is emitted here rather than through + // gl_draw_scaled_image, which binds the texture again with a clamped + // bilinear sampler and turns the tiled checks into one smeared cell. gGL.getTextureSlot(0)->bindSampled(img->getImage(), ALSamplers::PointWrap); - LLColor4 color(1.f, 1.f, 1.f, alpha); - LLRectf uv_rect(0, 0, rect.getWidth()/32.f, rect.getHeight()/32.f); + const F32 u = rect.getWidth() / 32.f; + const F32 v = rect.getHeight() / 32.f; - gl_draw_scaled_image(rect.mLeft, rect.mBottom, rect.getWidth(), rect.getHeight(), img->getImage(), color, uv_rect); + gGL.color4f(1.f, 1.f, 1.f, alpha); + gGL.begin(LLRender::TRIANGLES); + { + gGL.texCoord2f(u, v); gGL.vertex2i(rect.mRight, rect.mTop); + gGL.texCoord2f(0.f, v); gGL.vertex2i(rect.mLeft, rect.mTop); + gGL.texCoord2f(0.f, 0.f); gGL.vertex2i(rect.mLeft, rect.mBottom); + + gGL.texCoord2f(u, v); gGL.vertex2i(rect.mRight, rect.mTop); + gGL.texCoord2f(0.f, 0.f); gGL.vertex2i(rect.mLeft, rect.mBottom); + gGL.texCoord2f(u, 0.f); gGL.vertex2i(rect.mRight, rect.mBottom); + } + gGL.end(); gGL.flush(); } diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp index 3bb182a0c42..da9f3620397 100644 --- a/indra/llrender/llshadermgr.cpp +++ b/indra/llrender/llshadermgr.cpp @@ -1731,6 +1731,7 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("res_scale"); mReservedUniforms.push_back("dof_width"); mReservedUniforms.push_back("dof_height"); + mReservedUniforms.push_back("dof_uv_scale"); mReservedUniforms.push_back("depthMap"); mReservedUniforms.push_back("shadowMap0"); @@ -1978,8 +1979,9 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("uLensFlareStarburstSpikes"); mReservedUniforms.push_back("uLensFlareStarburstSharpness"); mReservedUniforms.push_back("uLensFlareStarburstFalloff"); - mReservedUniforms.push_back("uLensFlareOcclusionTaps"); mReservedUniforms.push_back("uLensFlareLightColor"); + mReservedUniforms.push_back("uLensFlareStateMap"); + mReservedUniforms.push_back("uLensFlareFadeTime"); // Color Correction LUT mReservedUniforms.push_back("uColorGradeLut"); @@ -1997,7 +1999,8 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("uHighlightRatio"); mReservedUniforms.push_back("uMidtoneRatio"); mReservedUniforms.push_back("uMidtoneAmount"); - mReservedUniforms.push_back("uSplitToneMid"); + mReservedUniforms.push_back("uSplitToneShadowRamp"); + mReservedUniforms.push_back("uSplitToneHighlightRamp"); mReservedUniforms.push_back("uToneAmount"); // Display-space grading @@ -2011,10 +2014,10 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("uVibrance"); mReservedUniforms.push_back("uHueShiftNorm"); - // Per-channel filmic curves - mReservedUniforms.push_back("uCurveToe"); - mReservedUniforms.push_back("uCurveInvRange"); - mReservedUniforms.push_back("uCurveStrength"); + // Tone curve LUT + mReservedUniforms.push_back("uToneCurveLut"); + mReservedUniforms.push_back("uToneCurveLutScale"); + mReservedUniforms.push_back("uToneCurveAmount"); // Vignette mReservedUniforms.push_back("uVignetteAmount"); @@ -2053,6 +2056,62 @@ void LLShaderMgr::initAttribsAndUniforms() mReservedUniforms.push_back("uRefWipeMode"); mReservedUniforms.push_back("uRefWipePos"); + // Geometric lens distortion + mReservedUniforms.push_back("uLensDistortAmount"); + mReservedUniforms.push_back("uLensDistortK"); + mReservedUniforms.push_back("uLensDistortScale"); + mReservedUniforms.push_back("uLensDistortSqueeze"); + mReservedUniforms.push_back("uLensDistortCenter"); + mReservedUniforms.push_back("uLensDistortTangential"); + + // Bokeh + mReservedUniforms.push_back("uBokehHighlightThreshold"); + mReservedUniforms.push_back("uBokehHighlightGain"); + mReservedUniforms.push_back("uBokehHighlightClamp"); + mReservedUniforms.push_back("uBokehBlades"); + mReservedUniforms.push_back("uBokehApertureRotation"); + mReservedUniforms.push_back("uBokehApertureCurvature"); + mReservedUniforms.push_back("uBokehApertureConst"); + mReservedUniforms.push_back("uBokehAnamorphic"); + mReservedUniforms.push_back("uBokehCatEye"); + mReservedUniforms.push_back("uBokehFringeAmount"); + mReservedUniforms.push_back("uBokehFringeNearTint"); + mReservedUniforms.push_back("uBokehFringeFarTint"); + + // Lens dirt + mReservedUniforms.push_back("uLensDirtMap"); + mReservedUniforms.push_back("uLensDirtStrength"); + mReservedUniforms.push_back("uLensDirtBloomResponse"); + mReservedUniforms.push_back("uLensDirtFlareResponse"); + + // Lens dirt generation + mReservedUniforms.push_back("uDirtResolution"); + mReservedUniforms.push_back("uDirtSeed"); + mReservedUniforms.push_back("uDirtGrime"); + mReservedUniforms.push_back("uDirtMoteScale"); + mReservedUniforms.push_back("uDirtSmudge"); + mReservedUniforms.push_back("uDirtScratches"); + mReservedUniforms.push_back("uDirtToe"); + mReservedUniforms.push_back("uDirtGain"); + + // Cross-screen filter + mReservedUniforms.push_back("uCrossTexel"); + mReservedUniforms.push_back("uCrossDir"); + mReservedUniforms.push_back("uCrossLength"); + mReservedUniforms.push_back("uCrossFalloff"); + mReservedUniforms.push_back("uCrossChromatic"); + mReservedUniforms.push_back("uCrossPassScale"); + mReservedUniforms.push_back("uCrossStrength"); + mReservedUniforms.push_back("uCrossRegion"); + mReservedUniforms.push_back("uCrossClamp"); + + mReservedUniforms.push_back("uBokehSpherical"); + mReservedUniforms.push_back("uBokehFieldStretch"); + mReservedUniforms.push_back("uBokehFieldFalloff"); + mReservedUniforms.push_back("uBokehComaAsymmetry"); + + mReservedUniforms.push_back("crossFilterMap"); + // Text Shadow mReservedUniforms.push_back("textShadowMode"); diff --git a/indra/llrender/llshadermgr.h b/indra/llrender/llshadermgr.h index 0f94d35c957..69bb4c045d4 100644 --- a/indra/llrender/llshadermgr.h +++ b/indra/llrender/llshadermgr.h @@ -216,6 +216,7 @@ class LLShaderMgr DOF_RES_SCALE, // "res_scale" DOF_WIDTH, // "dof_width" DOF_HEIGHT, // "dof_height" + DOF_UV_SCALE, // "dof_uv_scale" DEFERRED_DEPTH, // "depthMap" DEFERRED_SHADOW0, // "shadowMap0" @@ -436,7 +437,7 @@ class LLShaderMgr // Lens Flare LENS_FLARE_STRENGTH, // "uLensFlareStrength" LENS_FLARE_SUN_POS, // "uLensFlareSunPos" - LENS_FLARE_SUN_VISIBILITY, // "uLensFlareSunVisibility" + LENS_FLARE_SUN_VISIBILITY, // "uLensFlareSunVisibility" CPU screen-edge fade, an input to the state pass LENS_FLARE_STREAK_LENGTH, // "uLensFlareStreakLength" LENS_FLARE_STREAK_FALLOFF, // "uLensFlareStreakFalloff" LENS_FLARE_STREAK_WIDTH, // "uLensFlareStreakWidth" @@ -452,13 +453,14 @@ class LLShaderMgr LENS_FLARE_HALO_RADIUS, // "uLensFlareHaloRadius" LENS_FLARE_HALO_WIDTH, // "uLensFlareHaloWidth" LENS_FLARE_HALO, // "uLensFlareHalo" - LENS_FLARE_OCCLUSION_RADIUS, // "uLensFlareOcclusionRadius" + LENS_FLARE_OCCLUSION_RADIUS, // "uLensFlareOcclusionRadius" probe radius, fraction of screen height LENS_FLARE_STARBURST, // "uLensFlareStarburst" LENS_FLARE_STARBURST_SPIKES, // "uLensFlareStarburstSpikes" LENS_FLARE_STARBURST_SHARPNESS, // "uLensFlareStarburstSharpness" LENS_FLARE_STARBURST_FALLOFF, // "uLensFlareStarburstFalloff" - LENS_FLARE_OCCLUSION_TAPS, // "uLensFlareOcclusionTaps" LENS_FLARE_LIGHT_COLOR, // "uLensFlareLightColor" + LENS_FLARE_STATE_MAP, // "uLensFlareStateMap" 2x1 written by generateLensFlareState + LENS_FLARE_FADE_TIME, // "uLensFlareFadeTime" RenderLensFlareFadeTime, seconds; the shader derives its rates // Color Correction LUT COLOR_GRADE_LUT, // "uColorGradeLut" @@ -476,7 +478,8 @@ class LLShaderMgr SPLIT_TONE_HIGHLIGHT_RATIO, // "uHighlightRatio" SPLIT_TONE_MIDTONE_RATIO, // "uMidtoneRatio" SPLIT_TONE_MIDTONE_AMOUNT, // "uMidtoneAmount" - SPLIT_TONE_MID, // "uSplitToneMid" (0.5 + balance * 0.4) + SPLIT_TONE_SHADOW_RAMP, // "uSplitToneShadowRamp" (1/ws, -(mid - ws)/ws) + SPLIT_TONE_HIGHLIGHT_RAMP, // "uSplitToneHighlightRamp" (1/wh, -mid/wh) SPLIT_TONE_AMOUNT, // "uToneAmount" // Display-space grading — all CPU-precomputed to scale/bias pairs @@ -490,10 +493,10 @@ class LLShaderMgr COLOR_GRADE_VIBRANCE, // "uVibrance" COLOR_GRADE_HUE_SHIFT_NORM, // "uHueShiftNorm" (degrees / 360) - // Per-channel filmic curves - COLOR_GRADE_CURVE_TOE, // "uCurveToe" - COLOR_GRADE_CURVE_INV_RANGE, // "uCurveInvRange" (1 / (shoulder - toe)) - COLOR_GRADE_CURVE_STRENGTH, // "uCurveStrength" + // Tone curve, baked to a 1D LUT by LLPipeline::bakeToneCurveLut + COLOR_GRADE_CURVE_LUT, // "uToneCurveLut" + COLOR_GRADE_CURVE_LUT_SCALE, // "uToneCurveLutScale" (1 - 1/N, 0.5/N) + COLOR_GRADE_CURVE_AMOUNT, // "uToneCurveAmount" // Vignette VIGNETTE_AMOUNT, // "uVignetteAmount" @@ -532,6 +535,67 @@ class LLShaderMgr REFERENCE_WIPE_MODE, // "uRefWipeMode" REFERENCE_WIPE_POS, // "uRefWipePos" + // Geometric lens distortion — Brown-Conrady, applied in the final blit + LENS_DISTORT_AMOUNT, // "uLensDistortAmount" master gate; 0 = off + LENS_DISTORT_K, // "uLensDistortK" (k1, k2) pre-multiplied by amount on CPU + LENS_DISTORT_SCALE, // "uLensDistortScale" auto-fit rescale (direct multiplier), solved on CPU + LENS_DISTORT_SQUEEZE, // "uLensDistortSqueeze" (1 / squeeze, 1) pre-reciprocated on CPU + LENS_DISTORT_CENTER, // "uLensDistortCenter" decentering offset from frame centre + LENS_DISTORT_TANGENTIAL, // "uLensDistortTangential" (p1, p2) pre-multiplied by amount on CPU + + // Bokeh — depth of field gather weighting (postDeferredF) + BOKEH_HIGHLIGHT_THRESHOLD, // "uBokehHighlightThreshold" luma where the boost starts + BOKEH_HIGHLIGHT_GAIN, // "uBokehHighlightGain" 0 = plain average, the fast path + BOKEH_HIGHLIGHT_CLAMP, // "uBokehHighlightClamp" per-sample radiance ceiling; <= 0 disables + + // Bokeh — shaped aperture and defocus fringing (DOF_SHAPED builds only) + BOKEH_BLADES, // "uBokehBlades" 0 = circular, 3..11 = polygon + BOKEH_APERTURE_ROTATION, // "uBokehApertureRotation" radians, converted from degrees on CPU + BOKEH_APERTURE_CURVATURE, // "uBokehApertureCurvature" 0 straight blades, 1 fully round + BOKEH_APERTURE_CONST, // "uBokehApertureConst" (pi/N, 2pi/N, cos(pi/N)) baked on CPU + BOKEH_ANAMORPHIC, // "uBokehAnamorphic" area-preserving (x, y) stretch baked on CPU + BOKEH_CAT_EYE, // "uBokehCatEye" optical vignetting strength + BOKEH_FRINGE_AMOUNT, // "uBokehFringeAmount" + BOKEH_FRINGE_NEAR_TINT, // "uBokehFringeNearTint" + BOKEH_FRINGE_FAR_TINT, // "uBokehFringeFarTint" + + // Lens dirt — grime on the front element, lit by bloom and flare + LENS_DIRT_MAP, // "uLensDirtMap" + LENS_DIRT_STRENGTH, // "uLensDirtStrength" 0 disables; forced to 0 when no plate loaded + LENS_DIRT_BLOOM_RESPONSE, // "uLensDirtBloomResponse" + LENS_DIRT_FLARE_RESPONSE, // "uLensDirtFlareResponse" + + // Lens dirt generation — read only by the plate generator, which runs + // when a parameter moves rather than per frame + LENS_DIRT_RESOLUTION, // "uDirtResolution" plate size; only the ratio is read + LENS_DIRT_SEED, // "uDirtSeed" + LENS_DIRT_GRIME, // "uDirtGrime" master density + LENS_DIRT_MOTE_SCALE, // "uDirtMoteScale" + LENS_DIRT_SMUDGE, // "uDirtSmudge" + LENS_DIRT_SCRATCHES, // "uDirtScratches" 0 for undamaged glass + LENS_DIRT_TOE, // "uDirtToe" tone curve exponent + LENS_DIRT_GAIN, // "uDirtGain" tone curve gain + + // Cross-screen (star) filter — streaks every thresholded highlight + CROSS_TEXEL, // "uCrossTexel" 1 / source size + CROSS_DIR, // "uCrossDir" unit arm direction, one chain per arm + CROSS_LENGTH, // "uCrossLength" + CROSS_FALLOFF, // "uCrossFalloff" + CROSS_CHROMATIC, // "uCrossChromatic" + CROSS_PASS_SCALE, // "uCrossPassScale" 1, 4, 16 across the passes + CROSS_STRENGTH, // "uCrossStrength" 1.0 until the final composite + CROSS_REGION, // "uCrossRegion" xy origin, zw scale of the source inside its texture + CROSS_CLAMP, // "uCrossClamp" half a source texel in region units; 0 for a whole texture + + // Lens aberrations -- bokeh shape contributed by the glass rather than + // by the iris, so these sit alongside the aperture controls above + BOKEH_SPHERICAL, // "uBokehSpherical" signed; the sign flips across focus + BOKEH_FIELD_STRETCH, // "uBokehFieldStretch" + tangential (swirl), - radial (coma) + BOKEH_FIELD_FALLOFF, // "uBokehFieldFalloff" exponent on normalised field radius + BOKEH_COMA_ASYMMETRY, // "uBokehComaAsymmetry" + + CROSS_FILTER_MAP, // "crossFilterMap" streak accumulator, composited in colorCorrect + // End Alchemy Effects Stack TEXT_SHADOW_MODE, // "textShadowMode" diff --git a/indra/llui/CMakeLists.txt b/indra/llui/CMakeLists.txt index 91181763680..43626d9e5c9 100644 --- a/indra/llui/CMakeLists.txt +++ b/indra/llui/CMakeLists.txt @@ -5,6 +5,10 @@ add_library(llui STATIC) target_sources( llui PRIVATE + alangledial.cpp + alcornerfield.cpp + aloffsetpad.cpp + alpanefolds.cpp fsvirtualtrackpad.cpp llaccordionctrl.cpp llaccordionctrltab.cpp @@ -111,17 +115,21 @@ target_sources( llxuiparser.cpp alcanvasview.cpp alcolorfield.cpp + alcolortablepanel.cpp alcolorsheet.cpp aldeferredrebuild.cpp aldockpanel.cpp alemptystate.cpp alflagsfield.cpp alhistorylist.cpp + alimagefield.cpp aljumpbar.cpp alpopover.cpp alquickopen.cpp alscopebar.cpp + alsettingrow.cpp alspecimenlist.cpp + alstudiofloater.cpp altabstrip.cpp alfollowscontrol.cpp alfontfield.cpp @@ -145,6 +153,10 @@ target_sources( PUBLIC FILE_SET HEADERS FILES + alangledial.h + alcornerfield.h + aloffsetpad.h + alpanefolds.h altextcaret.h alviewtype.h fsvirtualtrackpad.h @@ -267,17 +279,22 @@ target_sources( llxuiparser.h alcanvasview.h alcolorfield.h + alcolortablepanel.h alcolorsheet.h aldeferredrebuild.h aldockpanel.h alemptystate.h alflagsfield.h alhistorylist.h + alimagefield.h aljumpbar.h alpopover.h alquickopen.h alscopebar.h + alsettingrow.h alspecimenlist.h + alstudiofloater.h + alundostack.h alstringmatch.h altabstrip.h alfollowscontrol.h @@ -403,6 +420,8 @@ if(AL_BUILD_TESTS) llfloater alpropertygrid alcolorfield + alcolortablepanel + alimagefield alcolorpicker alfontfield alflagsfield @@ -410,6 +429,9 @@ if(AL_BUILD_TESTS) alfollowscontrol alcanvasview aldockpanel + alpanefolds + alangledial + aloffsetpad alemptystate alxuifill alviewtype @@ -429,8 +451,10 @@ if(AL_BUILD_TESTS) alxuiselection alxuisourcemap alxuitreemodel + alsettingrow alcolorsheet lluicolortable + alundostack ) al_add_test( ${test} diff --git a/indra/llui/alangledial.cpp b/indra/llui/alangledial.cpp new file mode 100644 index 00000000000..c0942f1a743 --- /dev/null +++ b/indra/llui/alangledial.cpp @@ -0,0 +1,223 @@ +/** + * @file alangledial.cpp + * @brief A direction chosen by turning a dial + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "alangledial.h" + +#include "llfocusmgr.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "llspinctrl.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +#include +#include +#include + +static LLDefaultChildRegistry::Register r("angle_dial"); + +namespace +{ + constexpr S32 BOX_WIDTH = 84; + constexpr S32 BOX_HEIGHT = 20; + constexpr S32 GAP = 6; + constexpr F32 DEG = 3.14159265f / 180.f; +} + +ALAngleDial::Params::Params() +{ +} + +ALAngleDial::ALAngleDial(const Params& p) : LLUICtrl(p) +{ + LLSpinCtrl::Params sp; + + sp.name = "degrees"; + sp.label = "deg"; + sp.label_width = 26; + sp.rect = LLRect(0, BOX_HEIGHT, BOX_WIDTH, 0); + sp.decimal_digits = 0; + sp.increment = 5.f; + sp.min_value = 0.f; + sp.max_value = 360.f; + mDegrees = LLUICtrlFactory::create(sp); + mDegrees->setCommitCallback([this](LLUICtrl*, const LLSD&) + { + mAngle = (F32)mDegrees->getValue().asReal(); + onCommit(); + }); + addChild(mDegrees); + layout(); +} + +void ALAngleDial::layout() +{ + const S32 height = getRect().getHeight(); + const S32 side = llmax(24, height - 4); + + mDial = LLRect(2, height - 2, 2 + side, height - 2 - side); + + const S32 left = mDial.mRight + GAP; + const S32 top = height - 2; + + mDegrees->setShape(LLRect(left, top, left + BOX_WIDTH, top - BOX_HEIGHT)); +} + +void ALAngleDial::reshape(S32 width, S32 height, bool called_from_parent) +{ + LLUICtrl::reshape(width, height, called_from_parent); + layout(); +} + +void ALAngleDial::setValue(const LLSD& value) +{ + std::istringstream in(value.asString()); + F32 x = -1.f; + F32 y = 1.f; + + in >> x >> y; + + if (x != 0.f || y != 0.f) + { + mAngle = std::atan2(y, x) / DEG; + + if (mAngle < 0.f) + { + mAngle += 360.f; + } + } + + showDegrees(); +} + +LLSD ALAngleDial::getValue() const +{ + return fmt::format("{:.4g} {:.4g}", std::cos(mAngle * DEG), std::sin(mAngle * DEG)); +} + +void ALAngleDial::showDegrees() +{ + mDegrees->setValue(LLSD(ll_round(mAngle))); +} + +void ALAngleDial::turn(S32 x, S32 y) +{ + const F32 cx = F32(mDial.mLeft + mDial.mRight) * 0.5f; + const F32 cy = F32(mDial.mTop + mDial.mBottom) * 0.5f; + + if (x == cx && y == cy) + { + return; + } + + mAngle = std::atan2(F32(y) - cy, F32(x) - cx) / DEG; + + if (mAngle < 0.f) + { + mAngle += 360.f; + } + + // Whole degrees, in steps of five while dragging: a light direction + // is chosen by eye. + mAngle = F32(ll_round(mAngle / 5.f) * 5) ; + + if (mAngle >= 360.f) + { + mAngle -= 360.f; + } + + showDegrees(); +} + +void ALAngleDial::draw() +{ + static const LLUIColor well = LLUIColorTable::instance().getColor("DefaultShadowLight", LLColor4::black); + static const LLUIColor rim = LLUIColorTable::instance().getColor("LabelDisabledColor", LLColor4::grey); + static const LLUIColor handle = LLUIColorTable::instance().getColor("EmphasisColor", LLColor4::yellow); + + const F32 cx = F32(mDial.mLeft + mDial.mRight) * 0.5f; + const F32 cy = F32(mDial.mTop + mDial.mBottom) * 0.5f; + const F32 radius = F32(mDial.getWidth()) * 0.5f - 2.f; + + gGL.color4fv((well.get() % 0.6f).mV); + gl_circle_2d(cx, cy, radius, 32, true); + gGL.color4fv((rim.get() % 0.7f).mV); + gl_circle_2d(cx, cy, radius, 32, false); + + const S32 hx = ll_round(cx + std::cos(mAngle * DEG) * (radius - 3.f)); + const S32 hy = ll_round(cy + std::sin(mAngle * DEG) * (radius - 3.f)); + + gl_line_2d(ll_round(cx), ll_round(cy), hx, hy, handle.get() % 0.7f); + gl_rect_2d(LLRect(hx - 3, hy + 3, hx + 3, hy - 3), handle.get(), true); + + LLUICtrl::draw(); +} + +bool ALAngleDial::handleMouseDown(S32 x, S32 y, MASK mask) +{ + if (!mDial.pointInRect(x, y)) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + mDragging = true; + gFocusMgr.setMouseCapture(this); + turn(x, y); + + return true; +} + +bool ALAngleDial::handleHover(S32 x, S32 y, MASK mask) +{ + if (mDragging) + { + turn(x, y); + + return true; + } + + return LLUICtrl::handleHover(x, y, mask); +} + +void ALAngleDial::onMouseCaptureLost() +{ + mDragging = false; + LLUICtrl::onMouseCaptureLost(); +} + +bool ALAngleDial::handleMouseUp(S32 x, S32 y, MASK mask) +{ + if (!mDragging) + { + return LLUICtrl::handleMouseUp(x, y, mask); + } + + mDragging = false; + gFocusMgr.setMouseCapture(nullptr); + turn(x, y); + onCommit(); + + return true; +} diff --git a/indra/llui/alangledial.h b/indra/llui/alangledial.h new file mode 100644 index 00000000000..826e63b195d --- /dev/null +++ b/indra/llui/alangledial.h @@ -0,0 +1,72 @@ +/** + * @file alangledial.h + * @brief A direction chosen by turning a dial + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluictrl.h" + +class LLSpinCtrl; + +// A circle with a handle on its rim where the direction points, turned by +// dragging, and the angle in degrees beside it. The value is the unit +// vector, x and y with a space between; a drag commits when it ends. +class ALAngleDial : public LLUICtrl +{ +public: + AL_VIEW_TYPE(ALAngleDial, LLUICtrl); + + static constexpr S32 HEIGHT = 64; + + struct Params : public LLInitParam::Block + { + Params(); + }; + + void setValue(const LLSD& value) override; + LLSD getValue() const override; + + void draw() override; + void reshape(S32 width, S32 height, bool called_from_parent = true) override; + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + // A drag ends with the button, or with the mouse taken away: either + // way the pointer stops turning the dial. + void onMouseCaptureLost() override; + +protected: + friend class LLUICtrlFactory; + ALAngleDial(const Params& p); + +private: + void layout(); + void turn(S32 x, S32 y); + void showDegrees(); + + LLSpinCtrl* mDegrees = nullptr; + LLRect mDial; + // Degrees anticlockwise from the right, as the value's x and y say. + F32 mAngle = 135.f; + bool mDragging = false; +}; diff --git a/indra/llui/alcanvasview.cpp b/indra/llui/alcanvasview.cpp index f31bb49dfbc..ddcf3c55bd8 100644 --- a/indra/llui/alcanvasview.cpp +++ b/indra/llui/alcanvasview.cpp @@ -26,9 +26,18 @@ #include "alcanvasview.h" +#include "llfocusmgr.h" +#include "llfontgl.h" #include "llkeyboard.h" +#include "llrender.h" #include "llrender2dutils.h" #include "llscrollcontainer.h" +#include "lluicolortable.h" +#include "llwindow.h" + +#include +#include +#include namespace { @@ -39,6 +48,29 @@ namespace // no area is one a container has nothing to place. constexpr S32 LEAST_WIDTH = 120; constexpr S32 LEAST_HEIGHT = 40; + // The steps the wheel goes through, within the zoom's bounds. + constexpr std::array ZOOM_STEPS = { 0.25f, 0.5f, 0.75f, 1.f, 1.5f, 2.f, 3.f, 4.f, 6.f, 8.f }; + + constexpr std::pair BACKDROPS[] = { + { "none", ALCanvasView::Backdrop::None }, + { "pattern", ALCanvasView::Backdrop::Pattern }, + { "checker", ALCanvasView::Backdrop::Checker }, + { "window", ALCanvasView::Backdrop::Window }, + { "light", ALCanvasView::Backdrop::Light }, + }; + + // The container's window where there is one: the room a surface is + // shown in is what the container leaves after its own scrollbars, and + // the parent's rect otherwise. + LLRect roomOf(const LLView* view) + { + if (LLScrollContainer* scroller = view->getParentByType()) + { + return scroller->getContentWindowRect(); + } + + return view->getParent() ? view->getParent()->getLocalRect() : view->getLocalRect(); + } } void ALCanvasView::setRoot(LLView* root) @@ -110,10 +142,18 @@ bool ALCanvasView::keptPlace(S32& left, S32& down) const return true; } -// Where anchorRoot would put it against where it is: the two differ exactly -// when something other than this surface moved it. +// A surface that is the one thing in its container fills the container, +// so what is on it sits in the middle of the room until it outgrows it; a +// surface in a row is given its share by the row. Then, where anchorRoot +// would put the root against where it is: the two differ exactly when +// something other than this surface moved it. void ALCanvasView::refresh() { + if (mSizable && getParent() && getParent()->as()) + { + const LLRect room = roomRect(); + setLeastSurface(room.getWidth(), room.getHeight()); + } if (!mRoot) { return; @@ -177,15 +217,19 @@ void ALCanvasView::setLeastSurface(S32 width, S32 height) } // The room is in drawn units and the surface is in its own, so the room is -// divided by the zoom to be compared with the rest. +// divided by the zoom to be compared with the rest -- and rounded down, +// never to nearest: a surface drawn a pixel or two past the room it was +// told to fill brings out a scrollbar, the scrollbar takes room, the +// smaller room rounds the other way and the scrollbar goes, and so on +// every frame. S32 ALCanvasView::surfaceWidth() const { - return llmax(mContentWidth, mNeedWidth, ll_round((F32)mLeastWidth / mZoom)); + return llmax(mContentWidth, mNeedWidth, (S32)std::floor((F32)mLeastWidth / mZoom)); } S32 ALCanvasView::surfaceHeight() const { - return llmax(mContentHeight, mNeedHeight, ll_round((F32)mLeastHeight / mZoom)); + return llmax(mContentHeight, mNeedHeight, (S32)std::floor((F32)mLeastHeight / mZoom)); } void ALCanvasView::resurface() @@ -200,7 +244,7 @@ void ALCanvasView::resurface() void ALCanvasView::setZoom(F32 zoom) { - zoom = llclamp(zoom, 0.2f, 4.f); + zoom = llclamp(zoom, MIN_ZOOM, MAX_ZOOM); if (mZoom != zoom) { mZoom = zoom; @@ -211,6 +255,471 @@ void ALCanvasView::setZoom(F32 zoom) } } +F32 ALCanvasView::steppedZoom(F32 from, S32 direction) +{ + if (direction > 0) + { + for (const F32 step : ZOOM_STEPS) + { + if (step > from + 0.001f) + { + return llclamp(step, MIN_ZOOM, MAX_ZOOM); + } + } + return MAX_ZOOM; + } + for (auto it = ZOOM_STEPS.rbegin(); it != ZOOM_STEPS.rend(); ++it) + { + if (*it < from - 0.001f) + { + return llclamp(*it, MIN_ZOOM, MAX_ZOOM); + } + } + return MIN_ZOOM; +} + +F32 ALCanvasView::fittingZoom() const +{ + const LLRect room = roomRect(); + const F32 width = (F32)mContentWidth; + const F32 height = (F32)mContentHeight; + F32 fit = ZOOM_STEPS.front(); + for (const F32 step : ZOOM_STEPS) + { + if (width * step <= (F32)room.getWidth() && height * step <= (F32)room.getHeight()) + { + fit = step; + } + } + return llclamp(fit, MIN_ZOOM, MAX_ZOOM); +} + +LLRect ALCanvasView::roomRect() const +{ + return roomOf(this); +} + +ALCanvasView::Backdrop ALCanvasView::backdropNamed(std::string_view name) +{ + for (const auto& [known, backdrop] : BACKDROPS) + { + if (name == known) + { + return backdrop; + } + } + return Backdrop::None; +} + +const char* ALCanvasView::backdropName(Backdrop backdrop) +{ + for (const auto& [name, known] : BACKDROPS) + { + if (known == backdrop) + { + return name; + } + } + return "none"; +} + +std::string ALCanvasView::legend() const +{ + return fmt::format("{}%", ll_round(mZoom * 100.f)); +} + +// The point under the pointer before, in the surface's own coordinates +// and in the window's; the zoom; then the container scrolled so that the +// same point of the surface is at the same place in the window. Whoever +// listens for the zoom is told between the two, since a row of surfaces +// lays itself out again on hearing it and the scroll is measured against +// what the container then holds. +void ALCanvasView::zoomAbout(F32 zoom, S32 x, S32 y) +{ + zoom = llclamp(zoom, MIN_ZOOM, MAX_ZOOM); + if (zoom == mZoom) + { + return; + } + LLScrollContainer* scroller = getParentByType(); + const F32 cx = (F32)x / mZoom; + const F32 cy = (F32)y / mZoom; + S32 wx = 0; + S32 wy = 0; + if (scroller) + { + localPointToOtherView(x, y, &wx, &wy, scroller); + const LLRect window = scroller->getContentWindowRect(); + wx -= window.mLeft; + wy -= window.mBottom; + } + setZoom(zoom); + mZoomChange(mZoom); + if (!scroller || !scroller->getScrolledView()) + { + return; + } + S32 dx = ll_round(cx * mZoom); + S32 dy = ll_round(cy * mZoom); + localPointToOtherView(dx, dy, &dx, &dy, scroller->getScrolledView()); + scroller->scrollToShowRect(LLRect(dx, dy + 1, dx + 1, dy), LLRect(wx, wy + 1, wx + 1, wy)); +} + +// A wheel event carries no modifiers of its own, so the keyboard is asked +// what is held. +bool ALCanvasView::handleScrollWheel(S32 x, S32 y, LLScrollDelta delta) +{ + const MASK held = gKeyboard ? gKeyboard->currentMask(false) : MASK_NONE; + if (!(held & MASK_CONTROL) || delta.mClicks == 0) + { + toContent(x, y); + return LLPanel::handleScrollWheel(x, y, delta); + } + zoomAbout(steppedZoom(delta.mClicks < 0 ? 1 : -1), x, y); + return true; +} + +// --------------------------------------------------------------------------- +// Panning +// --------------------------------------------------------------------------- + +bool ALCanvasView::panGesture() const +{ + return mDragPans || (gKeyboard && gKeyboard->getKeyDown(' ')); +} + +// Measured on the screen rather than on the surface, since the surface is +// what moves: a point of it is somewhere else once it has been scrolled. +void ALCanvasView::beginPan(S32 x, S32 y) +{ + localPointToScreen(x, y, &mPanScreenX, &mPanScreenY); + const LLScrollContainer* scroller = getParentByType(); + mPanDocX = scroller ? scroller->getDocPosHorizontal() : 0; + mPanDocY = scroller ? scroller->getDocPosVertical() : 0; + mPanning = true; + gFocusMgr.setMouseCapture(this); +} + +void ALCanvasView::panTo(S32 x, S32 y) +{ + if (!mPanning) + { + return; + } + S32 sx = 0; + S32 sy = 0; + localPointToScreen(x, y, &sx, &sy); + LLScrollContainer* scroller = getParentByType(); + if (!scroller) + { + return; + } + // The surface follows the hand: dragged right, what is seen moves + // right, which is the container scrolled left. + scroller->setDocPosHorizontal(mPanDocX - (sx - mPanScreenX)); + scroller->setDocPosVertical(mPanDocY + (sy - mPanScreenY)); +} + +void ALCanvasView::endPan() +{ + mPanning = false; + if (gFocusMgr.getMouseCapture() == this) + { + gFocusMgr.setMouseCapture(nullptr); + } +} + +void ALCanvasView::panBy(S32 dx, S32 dy) +{ + if (LLScrollContainer* scroller = getParentByType()) + { + scroller->setDocPosHorizontal(scroller->getDocPosHorizontal() - dx); + scroller->setDocPosVertical(scroller->getDocPosVertical() + dy); + } +} + +// The pointer arrives in drawn pixels and what is on the surface is laid +// out in its own, so the zoom is taken out here for everything under this +// -- a subclass with a pointer of its own takes it out itself and comes +// here with the drawn point for the rest. +bool ALCanvasView::handleMouseDown(S32 x, S32 y, MASK mask) +{ + if (panGesture()) + { + beginPan(x, y); + return true; + } + toContent(x, y); + return LLPanel::handleMouseDown(x, y, mask); +} + +bool ALCanvasView::handleMouseUp(S32 x, S32 y, MASK mask) +{ + if (mPanning) + { + endPan(); + return true; + } + toContent(x, y); + return LLPanel::handleMouseUp(x, y, mask); +} + +bool ALCanvasView::handleMiddleMouseDown(S32 x, S32 y, MASK mask) +{ + beginPan(x, y); + return true; +} + +bool ALCanvasView::handleMiddleMouseUp(S32 x, S32 y, MASK mask) +{ + if (mPanning) + { + endPan(); + return true; + } + toContent(x, y); + return LLPanel::handleMiddleMouseUp(x, y, mask); +} + +bool ALCanvasView::handleRightMouseDown(S32 x, S32 y, MASK mask) +{ + toContent(x, y); + return LLPanel::handleRightMouseDown(x, y, mask); +} + +bool ALCanvasView::handleRightMouseUp(S32 x, S32 y, MASK mask) +{ + toContent(x, y); + return LLPanel::handleRightMouseUp(x, y, mask); +} + +bool ALCanvasView::handleDoubleClick(S32 x, S32 y, MASK mask) +{ + toContent(x, y); + return LLPanel::handleDoubleClick(x, y, mask); +} + +bool ALCanvasView::handleToolTip(S32 x, S32 y, MASK mask) +{ + toContent(x, y); + return LLPanel::handleToolTip(x, y, mask); +} + +bool ALCanvasView::handleHover(S32 x, S32 y, MASK mask) +{ + if (mPanning) + { + panTo(x, y); + getWindow()->setCursor(UI_CURSOR_HAND); + return true; + } + if (panGesture()) + { + getWindow()->setCursor(UI_CURSOR_HAND); + return true; + } + toContent(x, y); + return LLPanel::handleHover(x, y, mask); +} + +void ALCanvasView::onMouseCaptureLost() +{ + mPanning = false; + LLPanel::onMouseCaptureLost(); +} + +// What the backdrop is, for the words over it: light or dark by its +// luminance, and busy where it is the pattern. The panel's own colour, +// where there is no backdrop, is taken as dark: every canvas region in +// the viewer is. +LLColor4 ALCanvasView::inkColor(bool quiet) const +{ + static const LLUIColor window = LLUIColorTable::instance().getColor("FloaterFocusBackgroundColor", LLColor4::grey4); + + F32 luminance = 0.2f; + switch (mBackdrop) + { + case Backdrop::None: luminance = 0.2f; break; + case Backdrop::Pattern: luminance = 0.5f; break; + case Backdrop::Checker: luminance = 0.85f; break; + case Backdrop::Light: luminance = 0.92f; break; + case Backdrop::Window: + { + const LLColor4 c = window.get(); + luminance = 0.2126f * c.mV[0] + 0.7152f * c.mV[1] + 0.0722f * c.mV[2]; + break; + } + } + const bool dark_ink = luminance > 0.6f; + const F32 tone = dark_ink ? (quiet ? 0.25f : 0.08f) : (quiet ? 0.8f : 1.f); + return LLColor4(tone, tone, tone, 1.f); +} + +LLFontGL::ShadowType ALCanvasView::inkShadow() const +{ + return mBackdrop == Backdrop::Pattern ? LLFontGL::DROP_SHADOW : LLFontGL::NO_SHADOW; +} + +void ALCanvasView::drawChrome() +{ + const LLRect seen = viewportRect(); + if (empty()) + { + if (!mHint.empty()) + { + LLFontGL::getFontSansSerif()->renderUTF8(mHint, 0, seen.getCenterX(), seen.getCenterY(), inkColor(true), + LLFontGL::HCENTER, LLFontGL::VCENTER, LLFontGL::NORMAL, inkShadow()); + } + return; + } + const std::string words = legend(); + if (!words.empty()) + { + LLFontGL::getFontSansSerifSmall()->renderUTF8(words, 0, seen.mRight - 6, seen.mBottom + 6, inkColor(true), + LLFontGL::RIGHT, LLFontGL::BOTTOM, LLFontGL::NORMAL, inkShadow()); + } + drawRulers(); +} + +LLRect ALCanvasView::rulerOrigin() const +{ + return mRoot && mRoot != this ? localRectOf(mRoot) : LLRect(0, surfaceHeight(), surfaceWidth(), 0); +} + +// The rules are drawn on the canvas and numbered on the surface: a mark +// for surface coordinate n goes at n times the zoom, from the corner of +// what the surface says they measure from. +void ALCanvasView::drawRulers() const +{ + static const LLUIColor ground = LLUIColorTable::instance().getColor("CanvasRulerGround", LLColor4(0.169f, 0.169f, 0.169f, 0.85f)); + static const LLUIColor marks_color = LLUIColorTable::instance().getColor("CanvasRulerInk", LLColor4::white); + static const LLUIColor highlight = LLUIColorTable::instance().getColor("CanvasRulerMark", LLColor4::red); + static constexpr S32 RULER = 14; + static constexpr S32 LABEL_EVERY = 50; + + if (!rulersShown() || empty()) + { + return; + } + const LLRect view = viewportRect(); + if (view.getWidth() <= RULER * 2 || view.getHeight() <= RULER * 2) + { + return; + } + const LLRect origin = rulerOrigin(); + const LLColor4 marks = marks_color.get(); + const S32 rule_bottom = view.mTop - RULER; + const S32 rule_right = view.mLeft + RULER; + const LLRect top(view.mLeft, view.mTop, view.mRight, rule_bottom); + const LLRect left(view.mLeft, rule_bottom, rule_right, view.mBottom); + gl_rect_2d(top, ground.get(), true); + gl_rect_2d(left, ground.get(), true); + gl_rect_2d(top, marks, false); + gl_rect_2d(left, marks, false); + + // The first mark at or after a coordinate. Rounding towards zero is + // not rounding down, and a rule that reaches left of what it measures + // has negative numbers on it. + const auto from = [](S32 value, S32 step) + { + const S32 n = value >= 0 ? (value + step - 1) / step : -((-value) / step); + return n * step; + }; + const S32 step = llmax(rulerStep(), 1); + // A mark every step, unless the steps are drawn too close to tell + // apart, and then every few of them. + S32 every = step; + while ((F32)every * mZoom < 4.f) + { + every += step; + } + const LLFontGL* font = LLFontGL::getFontSansSerifSmall(); + const auto onCanvas = [this](S32 content) { return ll_round((F32)content * mZoom); }; + + for (S32 fx = from(ll_round((F32)rule_right / mZoom) - origin.mLeft, every); + onCanvas(origin.mLeft + fx) <= view.mRight; fx += every) + { + const S32 x = onCanvas(origin.mLeft + fx); + const bool named = fx % LABEL_EVERY == 0; + gl_line_2d(x, rule_bottom, x, rule_bottom + (named ? 5 : 3), marks); + if (named) + { + font->renderUTF8(std::to_string(fx), 0, x + 2, rule_bottom + 3, marks, LLFontGL::LEFT, LLFontGL::BOTTOM); + } + } + for (S32 fy = from(origin.mTop - ll_round((F32)rule_bottom / mZoom), every); + onCanvas(origin.mTop - fy) >= view.mBottom; fy += every) + { + const S32 y = onCanvas(origin.mTop - fy); + const bool named = fy % LABEL_EVERY == 0; + gl_line_2d(view.mLeft, y, view.mLeft + (named ? 5 : 3), y, marks); + if (named) + { + font->renderUTF8(std::to_string(fy), 0, view.mLeft + 2, y - 10, marks, LLFontGL::LEFT, LLFontGL::BOTTOM); + } + } + + // Where the chosen thing sits, on both rules. + LLRect content; + if (rulerHighlight(content)) + { + const LLRect box(onCanvas(content.mLeft), onCanvas(content.mTop), onCanvas(content.mRight), onCanvas(content.mBottom)); + gl_rect_2d(LLRect(llmax(box.mLeft, rule_right), view.mTop, llmin(box.mRight, view.mRight), rule_bottom), + highlight.get(), false); + gl_rect_2d(LLRect(view.mLeft, llmin(box.mTop, rule_bottom), rule_right, llmax(box.mBottom, view.mBottom)), + highlight.get(), false); + } +} + +// Bands of colour, two discs and some text: enough contrast, edges and +// detail for blur, refraction, dispersion and a rim to each show as +// themselves. +static void drawPatternBackdrop(const LLRect& area) +{ + static const LLColor4 BANDS[] = { + { 0.86f, 0.25f, 0.24f, 1.f }, { 0.96f, 0.62f, 0.18f, 1.f }, { 0.93f, 0.86f, 0.30f, 1.f }, + { 0.30f, 0.72f, 0.42f, 1.f }, { 0.22f, 0.50f, 0.90f, 1.f }, { 0.56f, 0.36f, 0.80f, 1.f } + }; + constexpr S32 COUNT = 6; + for (S32 i = 0; i < COUNT; ++i) + { + const S32 left = area.mLeft + area.getWidth() * i / COUNT; + const S32 right = area.mLeft + area.getWidth() * (i + 1) / COUNT; + gl_rect_2d(LLRect(left, area.mTop, right, area.mBottom), BANDS[i], true); + } + const F32 radius = llmax(6.f, (F32)area.getHeight() * 0.16f); + gGL.color4f(1.f, 1.f, 1.f, 1.f); + gl_circle_2d((F32)area.mLeft + (F32)area.getWidth() * 0.28f, (F32)area.mBottom + (F32)area.getHeight() * 0.62f, radius, 32, true); + gGL.color4f(0.08f, 0.08f, 0.1f, 1.f); + gl_circle_2d((F32)area.mLeft + (F32)area.getWidth() * 0.68f, (F32)area.mBottom + (F32)area.getHeight() * 0.36f, radius, 32, true); + LLFontGL::getFontSansSerifHuge()->renderUTF8("Aa Bb", 0, (F32)area.mLeft + (F32)area.getWidth() * 0.5f, + (F32)area.mBottom + (F32)area.getHeight() * 0.5f, LLColor4::white, + LLFontGL::HCENTER, LLFontGL::VCENTER, LLFontGL::BOLD, LLFontGL::DROP_SHADOW); +} + +void ALCanvasView::drawBackdrop(const LLRect& area) const +{ + static const LLUIColor window = LLUIColorTable::instance().getColor("FloaterFocusBackgroundColor", LLColor4::grey4); + + switch (mBackdrop) + { + case Backdrop::None: + break; + case Backdrop::Pattern: + drawPatternBackdrop(area); + break; + case Backdrop::Checker: + gl_rect_2d_checkerboard(area, 1.f); + break; + case Backdrop::Window: + gl_rect_2d(area, window.get(), true); + break; + case Backdrop::Light: + gl_rect_2d(area, LLColor4(0.92f, 0.92f, 0.92f, 1.f), true); + break; + } +} + void ALCanvasView::toContent(S32& x, S32& y) const { if (mZoom != 1.f) @@ -258,6 +767,9 @@ void ALCanvasView::reshape(S32 width, S32 height, bool called_from_parent) void ALCanvasView::draw() { refresh(); + // The backdrop is drawn before the zoom is pushed: it is what is + // behind the window, not part of the window. + drawBackdrop(getLocalRect()); const bool zoomed = mZoom != 1.f; if (zoomed) { @@ -269,9 +781,41 @@ void ALCanvasView::draw() { LLRender2D::popMatrix(); } + if (mPixelGrid && mZoom >= PIXEL_GRID_ZOOM) + { + drawPixelGrid(); + } drawChrome(); } +// One batch of lines, at every multiple of the zoom across what can be +// seen: a line per pixel of the surface, in drawn pixels. +void ALCanvasView::drawPixelGrid() const +{ + const LLRect seen = viewportRect(); + const S32 first_x = (S32)std::floor((F32)seen.mLeft / mZoom); + const S32 last_x = (S32)std::ceil((F32)seen.mRight / mZoom); + const S32 first_y = (S32)std::floor((F32)seen.mBottom / mZoom); + const S32 last_y = (S32)std::ceil((F32)seen.mTop / mZoom); + + gGL.getTextureSlot(0)->unbind(); + gGL.color4f(0.5f, 0.5f, 0.5f, 0.35f); + gGL.begin(LLRender::LINES); + for (S32 x = first_x; x <= last_x; ++x) + { + const S32 at = ll_round((F32)x * mZoom); + gGL.vertex2i(at, seen.mBottom); + gGL.vertex2i(at, seen.mTop); + } + for (S32 y = first_y; y <= last_y; ++y) + { + const S32 at = ll_round((F32)y * mZoom); + gGL.vertex2i(seen.mLeft, at); + gGL.vertex2i(seen.mRight, at); + } + gGL.end(); +} + LLRect ALCanvasView::viewportRect() const { if (LLScrollContainer* scroller = getParentByType()) @@ -435,9 +979,5 @@ bool ALCanvasRow::handleScrollWheel(S32 x, S32 y, LLScrollDelta delta) LLRect ALCanvasRow::regionRect() { - if (LLScrollContainer* scroller = getParentByType()) - { - return scroller->getContentWindowRect(); - } - return getLocalRect(); + return roomOf(this); } diff --git a/indra/llui/alcanvasview.h b/indra/llui/alcanvasview.h index 894af15417a..5b3e6fcaed1 100644 --- a/indra/llui/alcanvasview.h +++ b/indra/llui/alcanvasview.h @@ -24,10 +24,15 @@ #pragma once +#include "llfontgl.h" #include "llpanel.h" +#include +#include #include +#include + // A surface something else is shown on: a view tree built from a file, a // preview, a page. The surface is drawn at a zoom, sized to whatever is on // it, and put in a scroll container, so what does not fit is scrolled to @@ -135,8 +140,107 @@ class ALCanvasView : public LLPanel // How much bigger than life it is drawn. What is on it keeps its own // numbers: what changes is the transform it is drawn through, the room // it needs, and what a pointer at a place means. + static constexpr F32 MIN_ZOOM = 0.2f; + static constexpr F32 MAX_ZOOM = 8.f; F32 zoom() const { return mZoom; } void setZoom(F32 zoom); + // The next of the steps the wheel goes through, up or down from the + // zoom now: a ladder that doubles and halves rather than a fixed + // amount, so a small thing and a large one both reach a useful size + // in a few turns. + F32 steppedZoom(S32 direction) const { return steppedZoom(mZoom, direction); } + static F32 steppedZoom(F32 from, S32 direction); + // The largest step of that ladder at which what is on the surface fits + // the room it is shown in. + F32 fittingZoom() const; + // The zoom changed with a point of the surface held still: what was + // under the pointer is under it afterwards, which is what a wheel over + // a canvas means by zooming. The point is in the surface's drawn + // coordinates; the container is scrolled to keep it where it was. + void zoomAbout(F32 zoom, S32 x, S32 y); + // The zoom the wheel came to, for whatever else shows the zoom. + typedef boost::signals2::signal zoom_signal_t; + boost::signals2::connection onZoomChange(const zoom_signal_t::slot_type& cb) { return mZoomChange.connect(cb); } + + // The surface moved under the pointer: the middle button drags it, + // and so does the left button with the space bar held, in every + // canvas; a surface with nothing on it to click drags by the left + // button alone when it says so. What moves is the container's scroll, + // by however far the pointer went in drawn pixels. + void setDragPans(bool pans) { mDragPans = pans; } + bool dragPans() const { return mDragPans; } + bool panning() const { return mPanning; } + // Whether a press now is the start of a pan: the space bar is held, + // or the surface drags by itself. + bool panGesture() const; + void beginPan(S32 x, S32 y); + void panTo(S32 x, S32 y); + void endPan(); + // The container scrolled by this much, in drawn pixels: positive + // moves what is seen right and up, as a drag that way would. + void panBy(S32 dx, S32 dy); + + // The rules along the top and left of what can be seen, numbered in + // the surface's own coordinates from the corner of what is on it, + // with a mark at every step and a number every fifty: a caller with a + // grid steps the rules by it. Drawn by the default chrome when asked + // for; a subclass says whether they show and what they measure. + void setRulers(bool shown) { mRulers = shown; } + virtual bool rulersShown() const { return mRulers; } + void setRulerStep(S32 step) { mRulerStep = llmax(1, step); } + virtual S32 rulerStep() const { return mRulerStep; } + + // The room the surface is shown in, in drawn pixels: the window of the + // container that scrolls it, or failing that the parent. + LLRect roomRect() const; + + // What is said in the middle while there is nothing on the surface, + // in the canvas's own words and size. + void setHint(std::string hint) { mHint = std::move(hint); } + // Whether there is anything on the surface to draw. A surface shows a + // view tree unless a subclass shows something else. + virtual bool empty() const { return !mRoot; } + // The words in the corner of what can be seen while there is + // something on the surface: how much bigger than life it is drawn. + virtual std::string legend() const; + + // What is drawn under everything on the surface, over the whole of + // it and at the canvas's own scale whatever the zoom -- it stands for + // what is behind a window, and that does not grow when the window is + // looked at closely: nothing, so the panel's own colour shows; a + // pattern of colour bands, discs and text that shows what a + // translucent window does to what is behind it; a checkerboard that + // shows edges and + // transparency; the colour of a window; or a light grey. + enum class Backdrop : U8 + { + None, + Pattern, + Checker, + Window, + Light + }; + + void setBackdrop(Backdrop backdrop) { mBackdrop = backdrop; } + Backdrop backdrop() const { return mBackdrop; } + // A backdrop by the name a menu or a saved state uses for it -- none, + // pattern, checker, window, light -- and the name back; an unknown + // name is none. + static Backdrop backdropNamed(std::string_view name); + static const char* backdropName(Backdrop backdrop); + + // The ink for words drawn over the backdrop, so they can be read on + // it: dark on a light one, light on a dark one, and with a shadow + // where the backdrop is busy. Quiet is for words that are said + // quietly -- a legend, a caption -- and is nearer the backdrop. + LLColor4 inkColor(bool quiet = false) const; + LLFontGL::ShadowType inkShadow() const; + + // A faint line along every pixel of what is on the surface, drawn + // over it once the zoom is enough for the pixels to be seen apart. + static constexpr F32 PIXEL_GRID_ZOOM = 4.f; + void setPixelGrid(bool shown) { mPixelGrid = shown; } + bool pixelGrid() const { return mPixelGrid; } // The pointer in the coordinates what is on the surface is laid out in. // Everything that finds, measures or moves anything works in those, so @@ -162,6 +266,25 @@ class ALCanvasView : public LLPanel void reshape(S32 width, S32 height, bool called_from_parent = true) override; void draw() override; + // Control and the wheel steps the zoom about the pointer; the wheel + // alone belongs to whatever is under it, and to the container when + // nothing wants it. + bool handleScrollWheel(S32 x, S32 y, LLScrollDelta delta) override; + // A pan by the middle button, or by the left one where the gesture + // says so; a subclass with a pointer of its own answers first and + // comes here for the rest, with the drawn point: everything else the + // pointer does is converted here, once, for whatever is on the + // surface. + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleMiddleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMiddleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleRightMouseDown(S32 x, S32 y, MASK mask) override; + bool handleRightMouseUp(S32 x, S32 y, MASK mask) override; + bool handleDoubleClick(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + bool handleToolTip(S32 x, S32 y, MASK mask) override; + void onMouseCaptureLost() override; protected: // What is drawn in the surface's own coordinates, through the zoom: @@ -170,8 +293,23 @@ class ALCanvasView : public LLPanel // What is drawn at its own size whatever the zoom, because it is the // canvas's own furniture rather than a part of what is shown: a rule - // along the edge of what can be seen, and its like. - virtual void drawChrome() {} + // along the edge of what can be seen, and its like. Unless a subclass + // has furniture of its own, it is the hint while the surface is empty + // and the legend in the corner while it is not. + virtual void drawChrome(); + + // The chosen backdrop over an area, in drawn pixels. + void drawBackdrop(const LLRect& area) const; + void drawPixelGrid() const; + // The rules, when they show: along the top and left of what can be + // seen, at their own size whatever the zoom. + void drawRulers() const; + // What the rules measure from: the rect of what is on the surface, in + // the surface's own coordinates. The root's, or the surface itself. + virtual LLRect rulerOrigin() const; + // A rect to mark on both rules, in the surface's own coordinates: the + // thing chosen. False for none. + virtual bool rulerHighlight(LLRect& content) const { return false; } F32 mZoom = 1.f; @@ -187,6 +325,20 @@ class ALCanvasView : public LLPanel S32 mNeedWidth = 0; // to the far side of what it draws, S32 mNeedHeight = 0; // from that same corner bool mPlaced = false; // something has been put on it + std::string mHint; + Backdrop mBackdrop = Backdrop::None; + bool mPixelGrid = false; + bool mRulers = false; + S32 mRulerStep = 4; + zoom_signal_t mZoomChange; + // A pan under way: where the pointer was on the screen when it began, + // and where the container was scrolled to. + bool mDragPans = false; + bool mPanning = false; + S32 mPanScreenX = 0; + S32 mPanScreenY = 0; + S32 mPanDocX = 0; + S32 mPanDocY = 0; }; // The surfaces of one document side by side, sharing the room they are shown diff --git a/indra/llui/alcolorfield.cpp b/indra/llui/alcolorfield.cpp index 2cfbf9e3ca8..15867cafcd1 100644 --- a/indra/llui/alcolorfield.cpp +++ b/indra/llui/alcolorfield.cpp @@ -53,11 +53,10 @@ namespace constexpr S32 ROW = 22; constexpr S32 MIN_WIDTH = 360; constexpr S32 MIN_HEIGHT = 340; - - // Where the popover opens, and where it opens next time: it is - // resizable, and a size someone chose once is the size they wanted. - S32 sWidth = 470; - S32 sHeight = 460; + // Where the popover opens the first time; it is resizable, and the + // popover remembers where it was left after that. + constexpr S32 WIDTH = 470; + constexpr S32 HEIGHT = 460; // Four numbers, which is the other thing a file writes for a colour. bool literalColor(const std::string& text, LLColor4& out) @@ -115,15 +114,31 @@ namespace LLColor4 color; }; - ALColorSwatchGrid(const LLPanel::Params& p, chose_t chose) + // The caller's own names first, as given; then every name the + // table has, the skin's and the ones a person added, each as the + // colour it comes to now rather than the one the skin shipped. + ALColorSwatchGrid(const LLPanel::Params& p, chose_t chose, const std::vector& first) : LLPanel(p), mChose(std::move(chose)) { - for (const auto& [name, color] : LLUIColorTable::instance().getLoadedColors()) + for (const ALColorField::Choice& choice : first) + { + mAll.push_back({ choice.name, choice.color }); + } + const size_t own = mAll.size(); + const LLUIColorTable& table = LLUIColorTable::instance(); + for (const auto& [name, color] : table.getLoadedColors()) { - mAll.push_back({ name, color.get() }); + mAll.push_back({ name, table.getColor(name).get() }); + } + for (const auto& [name, color] : table.getUserColors()) + { + if (!table.getLoadedColors().contains(name)) + { + mAll.push_back({ name, color.get() }); + } } - std::sort(mAll.begin(), mAll.end(), + std::sort(mAll.begin() + own, mAll.end(), [](const Entry& a, const Entry& b) { return a.name < b.name; }); filter(LLStringUtil::null); } @@ -253,9 +268,11 @@ namespace public: AL_VIEW_TYPE(ALColorPopover, ALPopover); - ALColorPopover(const LLFloater::Params& p, const std::string& value) + ALColorPopover(const LLFloater::Params& p, const std::string& value, ALColorField::resolver_t resolver, + const std::vector& choices) : ALPopover(p), - mValue(value) + mValue(value), + mResolver(std::move(resolver)) { const S32 width = getRect().getWidth(); // A floater draws its own title across the top of its rect, and @@ -304,7 +321,7 @@ namespace { chose(name); showInPicker(name); - }); + }, choices); mGrid->setChosen(mValue); scroll->addChild(mGrid); // A tab container picks its first tab when it is built, and this @@ -352,21 +369,13 @@ namespace const S32 top = getRect().getHeight() - getHeaderHeight(); const LLRect swatch(4, top - 4, 4 + HEADER - 8, top - 4 - (ROW - 2)); LLColor4 color; - if (resolve(mValue, color)) + if (comesTo(mValue, color)) { gl_rect_2d(swatch, color, true); } gl_rect_2d(swatch, edge.get(), false); } - // The size it is left at is the size it opens at next time. - void onClose(bool app_quitting) override - { - sWidth = getRect().getWidth(); - sHeight = getRect().getHeight(); - ALPopover::onClose(app_quitting); - } - private: void chose(const std::string& value) { @@ -386,13 +395,21 @@ namespace void showInPicker(const std::string& value) { LLColor4 color; - if (resolve(value, color)) + if (comesTo(value, color)) { mPicker->setColor(color); } } + // What a text comes to, asked of the field's own resolver first, + // the way the field asks. + bool comesTo(const std::string& value, LLColor4& color) const + { + return (mResolver && mResolver(value, color)) || resolve(value, color); + } + std::string mValue; + ALColorField::resolver_t mResolver; LLFilterEditor* mFilter = nullptr; ALColorSwatchGrid* mGrid = nullptr; ALColorPicker* mPicker = nullptr; @@ -438,7 +455,13 @@ void ALColorField::setValue(const LLSD& value) void ALColorField::setText(const std::string& text) { mText = text; - mHasColor = resolve(mText, mColor); + resolveText(); +} + +void ALColorField::resolveText() const +{ + mHasColor = (mResolver && mResolver(mText, mColor)) || resolve(mText, mColor); + mSeenGeneration = LLUIColorTable::instance().generation(); } LLSD ALColorField::getValue() const @@ -446,8 +469,30 @@ LLSD ALColorField::getValue() const return mText; } +bool ALColorField::textToColor(const std::string& text, LLColor4& color) +{ + return resolve(text, color); +} + +void ALColorField::setResolver(resolver_t resolver) +{ + mResolver = std::move(resolver); + setText(mText); +} + +void ALColorField::setChoices(choices_t choices) +{ + mChoices = std::move(choices); +} + +// A name means what the table says now: a colour changed elsewhere +// shows here without the text having changed. bool ALColorField::resolved(LLColor4& color) const { + if (mSeenGeneration != LLUIColorTable::instance().generation()) + { + resolveText(); + } color = mColor; return mHasColor; } @@ -493,10 +538,10 @@ void ALColorField::openPopover() { closePopover(); - LLFloater::Params p(ALPopover::paramsFor(sWidth, sHeight, mText, /*resizable=*/true)); + LLFloater::Params p(ALPopover::paramsRemembered("color_field", WIDTH, HEIGHT, mText)); p.min_width = MIN_WIDTH; p.min_height = MIN_HEIGHT; - ALColorPopover* popover = new ALColorPopover(p, mText); + ALColorPopover* popover = new ALColorPopover(p, mText, mResolver, mChoices ? mChoices() : std::vector()); mPopover = popover->getDerivedHandle(); // Told as it goes, while it still holds what was chosen; escaped is // the one way out that keeps what the field had. diff --git a/indra/llui/alcolorfield.h b/indra/llui/alcolorfield.h index 76e327a9a8f..a7597afe7eb 100644 --- a/indra/llui/alcolorfield.h +++ b/indra/llui/alcolorfield.h @@ -27,7 +27,9 @@ #include "lluictrl.h" #include "lluicolor.h" +#include #include +#include class ALPopover; class LLLineEditor; @@ -61,6 +63,28 @@ class ALColorField : public LLUICtrl // nothing, which is the lint's finding made visible. bool resolved(LLColor4& color) const; + // What a text comes to without a resolver: a name the colour table + // has, or three or four numbers. False for anything else. + static bool textToColor(const std::string& text, LLColor4& color); + + // Asked first what a text comes to, for a caller whose files write + // colours colors.xml knows nothing about; the names and the numbers + // are tried after it says no. The popover asks the same. + typedef std::function resolver_t; + void setResolver(resolver_t resolver); + + // Names of the caller's own, offered in the popover before the + // table's: a theme value written as $Name, with the colour it comes + // to. Asked when the popover opens, since what they come to + // changes. + struct Choice + { + std::string name; + LLColor4 color; + }; + typedef std::function()> choices_t; + void setChoices(choices_t choices); + void draw() override; bool handleMouseDown(S32 x, S32 y, MASK mask) override; @@ -77,12 +101,20 @@ class ALColorField : public LLUICtrl void onTextCommit(); std::string mText; - // What the text comes to, worked out when it changes. - LLColor4 mColor; - bool mHasColor = false; + // What the text comes to, worked out when it changes, or when the + // table a name is looked up in does. + mutable LLColor4 mColor; + mutable bool mHasColor = false; S32 mSwatchWidth; LLLineEditor* mEditor = nullptr; LLHandle mPopover; + resolver_t mResolver; + choices_t mChoices; + // The colour table as it was when the text was last resolved: a name + // means something else once the table has changed. + mutable U32 mSeenGeneration = 0; + + void resolveText() const; void setText(const std::string& text); }; diff --git a/indra/llui/alcolortablepanel.cpp b/indra/llui/alcolortablepanel.cpp new file mode 100644 index 00000000000..d5ad498ab3c --- /dev/null +++ b/indra/llui/alcolortablepanel.cpp @@ -0,0 +1,304 @@ +/** + * @file alcolortablepanel.cpp + * @brief The colour table as rows to edit: every named colour, whose it is, and the way back + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alcolortablepanel.h" + +#include "alcolorfield.h" +#include "alpropertygrid.h" +#include "llcheckboxctrl.h" +#include "llfiltereditor.h" +#include "lltrans.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +#include + +#include + +#include + +static LLDefaultChildRegistry::Register r("color_table_panel"); + +namespace +{ + constexpr S32 ROW = 22; + constexpr S32 GAP = 4; + // How long after the last change the colours are written: a slider + // dragged is one write, not one per pixel. + constexpr F32 WRITE_AFTER_SECONDS = 2.f; + + // What a file is called on a heading: the skin's folder and the file, + // which is what tells default/colors.xml from alchemy/colors.xml. + std::string headingFor(const std::string& path) + { + std::string named = path; + std::replace(named.begin(), named.end(), '\\', '/'); + const size_t slash = named.rfind('/'); + const size_t before = slash == std::string::npos ? std::string::npos : named.rfind('/', slash - 1); + return before == std::string::npos ? named : named.substr(before + 1); + } +} + +ALColorTablePanel::Params::Params() +: label_width("label_width", 180) +{ +} + +ALColorTablePanel::ALColorTablePanel(const Params& p) +: LLPanel(p) +{ + const S32 width = getRect().getWidth(); + const S32 height = getRect().getHeight(); + + LLFilterEditor::Params fp; + fp.name = "color_filter"; + fp.rect = LLRect(0, height, width, height - ROW); + fp.label = LLTrans::getString("ColorTableFilter"); + fp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT; + mFilter = LLUICtrlFactory::create(fp); + mFilter->setCommitCallback([this](LLUICtrl* ctrl, const LLSD&) { setFilter(ctrl->getValue().asString()); }); + addChild(mFilter); + + LLCheckBoxCtrl::Params cp; + cp.name = "only_changed"; + cp.rect = LLRect(0, height - ROW - GAP, width, height - ROW - GAP - 18); + cp.label = LLTrans::getString("ColorTableOnlyChanged"); + cp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP; + mOnlyChanged = LLUICtrlFactory::create(cp); + mOnlyChanged->setCommitCallback([this](LLUICtrl* ctrl, const LLSD&) { setOnlyChanged(ctrl->getValue().asBoolean()); }); + addChild(mOnlyChanged); + + ALPropertyGrid::Params gp; + gp.name = "color_grid"; + gp.rect = LLRect(0, height - ROW - GAP - 18 - GAP, width, 0); + gp.label_width = p.label_width; + gp.follows.flags = FOLLOWS_ALL; + mGrid = LLUICtrlFactory::create(gp); + + ALPropertyGrid::Tips tips; + tips.field = LLTrans::getString("ColorTableTipField"); + tips.fieldTyped = LLTrans::getString("ColorTableTipField"); + tips.source = LLTrans::getString("ColorTableTipSource"); + tips.unwritten = LLTrans::getString("ColorTableTipUnwritten"); + tips.remove = LLTrans::getString("ColorTableTipRemove"); + mGrid->setTips(tips); + mGrid->setNotices({ LLTrans::getString("ColorTableNothing"), LLTrans::getString("ColorTableNothingHow"), "" }, + { LLTrans::getString("ColorTableNothing"), LLTrans::getString("ColorTableNothingHow"), "" }, + { LLTrans::getString("ColorTableNoMatch"), LLTrans::getString("ColorTableNoMatchHow"), "" }); + // Every colour is one the skin chose, so no section arrives folded. + mGrid->setFoldsUnwritten(false); + mGrid->onFieldCommit([this](const std::string& name, const std::string& value) { onFieldCommit(name, value); }); + mGrid->onFieldRemove([this](const std::string& name) { onFieldRemove(name); }); + mGrid->onFieldGutter([this](const std::string& name) { mGutter(name); }); + addChild(mGrid); + + fill(); +} + +void ALColorTablePanel::setUsers(users_t users) +{ + mUsers = std::move(users); + fill(); +} + +LLView* ALColorTablePanel::gutterFor(const std::string& name) const +{ + return mGrid->gutterFor(name); +} + +std::string ALColorTablePanel::valueText(const LLColor4& color) +{ + return fmt::format("{:g}, {:g}, {:g}, {:g}", color.mV[0], color.mV[1], color.mV[2], color.mV[3]); +} + +void ALColorTablePanel::refresh() +{ + fill(); +} + +void ALColorTablePanel::setFilter(const std::string& text) +{ + if (mFilter->getValue().asString() != text) + { + mFilter->setValue(text); + } + mGrid->setFilter(text); +} + +void ALColorTablePanel::setOnlyChanged(bool only) +{ + if (mOnlyChanged->getValue().asBoolean() != only) + { + mOnlyChanged->setValue(only); + } + mGrid->setAuthoredOnly(only); +} + +bool ALColorTablePanel::onlyChanged() const +{ + return mGrid->authoredOnly(); +} + +void ALColorTablePanel::showColor(const std::string& name) +{ + setOnlyChanged(false); + setFilter(name); +} + +// The table read again once it has changed under the rows: the same rows +// take the new values in place, so a colour being picked keeps its +// popover. +void ALColorTablePanel::draw() +{ + if (mSeenGeneration != LLUIColorTable::instance().generation()) + { + fill(); + mUnwritten = true; + mSinceChange.reset(); + } + if (mUnwritten && mSinceChange.getElapsedTimeF32() > WRITE_AFTER_SECONDS) + { + mUnwritten = false; + LLUIColorTable::instance().saveUserSettings(); + } + LLPanel::draw(); +} + +// One row per name, under the file that declares it, with the changed +// ones under a heading of their own in front: what a person did to the +// theme reads as a list, and what the skin did reads by file. +void ALColorTablePanel::fill() +{ + const LLUIColorTable& table = LLUIColorTable::instance(); + const ALColorSheet& sheet = table.getLoadedSheet(); + + mSeenGeneration = table.generation(); + + std::vector files; + for (const std::string& file : sheet.files()) + { + files.push_back(headingFor(file)); + } + if (files != mFiles) + { + mFiles = files; + std::vector groups = { LLTrans::getString("ColorTableGroupChanged") }; + groups.insert(groups.end(), files.begin(), files.end()); + groups.push_back(LLTrans::getString("ColorTableGroupAdded")); + mGrid->setGroups(std::move(groups)); + } + const S32 added_group = S32(mFiles.size()) + 1; + + // What the sheet had to say about a name, for the row's sentence. + boost::unordered_flat_map complaints; + for (const ALColorSheet::Diagnostic& diagnostic : sheet.diagnostics()) + { + if (diagnostic.declaration >= 0) + { + const std::string& name = sheet.declarations()[diagnostic.declaration].name; + complaints[name] += (complaints[name].empty() ? "" : "\n") + diagnostic.message; + } + } + + std::vector fields; + const auto row = [&](const std::string& name, const LLColor4& color) + { + ALPropertyGrid::Field field; + field.name = name; + field.value = valueText(color); + field.type = "LLColor4"; + field.authored = !table.isDefault(name); + field.group = added_group; + + if (const ALColorSheet::Declaration* declared = sheet.declarationOf(name)) + { + field.source = fmt::format("{}({})", headingFor(std::string(sheet.fileOf(*declared))), declared->line); + field.group = S32(declared->file) + 1; + if (declared->kind == ALColorSheet::Declaration::Kind::Reference) + { + field.description = LLTrans::getString("ColorTableRefersTo", LLStringUtil::format_map_t{ { "[NAME]", declared->reference } }); + } + } + if (field.authored) + { + field.group = 0; + if (const LLColor4* skins = sheet.find(name)) + { + const std::string was = LLTrans::getString("ColorTableWas", LLStringUtil::format_map_t{ { "[VALUE]", valueText(*skins) } }); + field.description += (field.description.empty() ? "" : "\n") + was; + } + } + if (const auto complaint = complaints.find(name); complaint != complaints.end()) + { + field.description += (field.description.empty() ? "" : "\n") + complaint->second; + } + if (mUsers) + { + // The shapes painted with it, counted in the sentence and + // named on the mark. + field.alsoWritten = mUsers(name); + const std::string count = field.alsoWritten.empty() + ? LLTrans::getString("ColorTableUnused") + : LLTrans::getString("ColorTableUsers", LLStringUtil::format_map_t{ { "[COUNT]", std::to_string(field.alsoWritten.size()) } }); + field.description += (field.description.empty() ? "" : "\n") + count; + } + fields.push_back(std::move(field)); + }; + + for (const auto& [name, color] : table.getLoadedColors()) + { + row(name, table.getColor(name).get()); + } + for (const auto& [name, color] : table.getUserColors()) + { + if (!table.getLoadedColors().contains(name)) + { + row(name, color.get()); + } + } + + if (!mGrid->updateFields(fields)) + { + mGrid->setFields(std::move(fields)); + } +} + +void ALColorTablePanel::onFieldCommit(const std::string& name, const std::string& value) +{ + LLColor4 color; + if (!ALColorField::textToColor(value, color)) + { + return; + } + LLUIColorTable::instance().setColor(name, color); + mChanged(name); +} + +void ALColorTablePanel::onFieldRemove(const std::string& name) +{ + LLUIColorTable::instance().resetToDefault(name); + mChanged(name); +} diff --git a/indra/llui/alcolortablepanel.h b/indra/llui/alcolortablepanel.h new file mode 100644 index 00000000000..39fa7bfd599 --- /dev/null +++ b/indra/llui/alcolortablepanel.h @@ -0,0 +1,122 @@ +/** + * @file alcolortablepanel.h + * @brief The colour table as rows to edit: every named colour, whose it is, and the way back + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llframetimer.h" +#include "llpanel.h" +#include "v4color.h" + +#include +#include +#include + +#include + +class ALPropertyGrid; +class LLCheckBoxCtrl; +class LLFilterEditor; + +// The colour table as rows: every name the skins declare and every one a +// person added, with the colour it comes to, the file that declared it +// and the colour it refers to where it refers to one. A row a person +// changed is theirs, and the way back on it puts the skin's colour in +// force again. Under headings by the file that declares each, with the +// changed ones first, so a theme's own colours read as a list. +// +// The same rows wherever a theme's colours are edited: the colour +// settings window is this panel. +// The table is the document: this reads it, writes it one colour at a +// time, and says which one it wrote, so a window keeping undo can. And +// while the panel is up, a change to the table reaches the person's +// colours file a moment after the changing stops, so a crash loses +// nothing but the last moment. +class ALColorTablePanel : public LLPanel +{ +public: + AL_VIEW_TYPE(ALColorTablePanel, LLPanel); + + struct Params : public LLInitParam::Block + { + Optional label_width; + Params(); + }; + + // The rows read from the table again. Called by the panel itself when + // the table has changed since it last read; a caller with a reason to + // read sooner calls it. + void refresh() override; + + // The rows narrowed to the names carrying this, and to the ones a + // person changed. + void setFilter(const std::string& text); + void setOnlyChanged(bool only); + bool onlyChanged() const; + // A colour brought into view: the filter is set to its name. + void showColor(const std::string& name); + + // A colour set or put back by this panel, said after the table has it. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& cb) { return mChanged.connect(cb); } + + // Who paints with each colour, by the colour's name, for a caller + // that knows: the row says how many and marks them in its gutter, + // and the mark pressed is said, with the name, to whoever would show + // them. + typedef std::function(const std::string&)> users_t; + void setUsers(users_t users); + typedef boost::signals2::signal gutter_signal_t; + boost::signals2::connection onGutter(const gutter_signal_t::slot_type& cb) { return mGutter.connect(cb); } + // The mark beside a colour's row, for a menu to open beside. + LLView* gutterFor(const std::string& name) const; + + // How a colour is written as a row's value, and read back from one: + // four numbers with commas between, which is what the picker gives. + static std::string valueText(const LLColor4& color); + + void draw() override; + +protected: + friend class LLUICtrlFactory; + ALColorTablePanel(const Params& p); + +private: + void fill(); + void onFieldCommit(const std::string& name, const std::string& value); + void onFieldRemove(const std::string& name); + + ALPropertyGrid* mGrid = nullptr; + LLFilterEditor* mFilter = nullptr; + LLCheckBoxCtrl* mOnlyChanged = nullptr; + // The table as it was when the rows were last read. + U32 mSeenGeneration = 0; + // A change waiting to be written to disk, and since when. + bool mUnwritten = false; + LLFrameTimer mSinceChange; + // The headings, which are the files: read again when the files do. + std::vector mFiles; + changed_signal_t mChanged; + gutter_signal_t mGutter; + users_t mUsers; +}; diff --git a/indra/llui/alcornerfield.cpp b/indra/llui/alcornerfield.cpp new file mode 100644 index 00000000000..21d1f575d22 --- /dev/null +++ b/indra/llui/alcornerfield.cpp @@ -0,0 +1,227 @@ +/** + * @file alcornerfield.cpp + * @brief Four corner radii, drawn as the corners they round + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "alcornerfield.h" + +#include "llcheckboxctrl.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "llspinctrl.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +#include +#include +#include + +static LLDefaultChildRegistry::Register r("corner_field"); + +namespace +{ + constexpr S32 PICTURE_WIDTH = 72; + constexpr S32 BOX_WIDTH = 62; + constexpr S32 BOX_HEIGHT = 20; + constexpr S32 GAP = 4; + +} + +ALCornerField::Params::Params() +{ +} + +ALCornerField::ALCornerField(const Params& p) : LLUICtrl(p) +{ + // Top left and top right on the top row, bottom left and bottom right + // under them: the boxes sit where the corners are. + static const char* NAMES[4] = { "tl", "tr", "br", "bl" }; + + for (size_t i = 0; i < 4; ++i) + { + LLSpinCtrl::Params sp; + + sp.name = NAMES[i]; + sp.rect = LLRect(0, BOX_HEIGHT, BOX_WIDTH, 0); + sp.label_width = 0; + sp.decimal_digits = 1; + sp.increment = 0.5f; + sp.min_value = 0.f; + sp.max_value = 512.f; + mSpin[i] = LLUICtrlFactory::create(sp); + mSpin[i]->setCommitCallback([this, i](LLUICtrl*, const LLSD&) { onSpin(i); }); + addChild(mSpin[i]); + } + + LLCheckBoxCtrl::Params cp; + + cp.name = "link"; + cp.rect = LLRect(0, BOX_HEIGHT, 70, 0); + cp.label = "Same"; + cp.initial_value = true; + mLink = LLUICtrlFactory::create(cp); + addChild(mLink); + layout(); +} + +void ALCornerField::layout() +{ + const S32 height = getRect().getHeight(); + const S32 width = getRect().getWidth(); + + mPicture = LLRect(0, height, llmin(PICTURE_WIDTH, width), 0); + + const S32 left = mPicture.mRight + GAP; + const S32 top = height - 2; + const S32 bottom_row = top - BOX_HEIGHT - GAP; + + mSpin[0]->setShape(LLRect(left, top, left + BOX_WIDTH, top - BOX_HEIGHT)); + mSpin[1]->setShape(LLRect(left + BOX_WIDTH + GAP, top, left + 2 * BOX_WIDTH + GAP, top - BOX_HEIGHT)); + mSpin[3]->setShape(LLRect(left, bottom_row, left + BOX_WIDTH, bottom_row - BOX_HEIGHT)); + mSpin[2]->setShape(LLRect(left + BOX_WIDTH + GAP, bottom_row, left + 2 * BOX_WIDTH + GAP, bottom_row - BOX_HEIGHT)); + + const S32 link_left = left + 2 * BOX_WIDTH + 2 * GAP + 4; + + mLink->setShape(LLRect(link_left, top, link_left + 70, top - BOX_HEIGHT)); + mLink->setVisible(link_left + 40 <= width); +} + +void ALCornerField::reshape(S32 width, S32 height, bool called_from_parent) +{ + LLUICtrl::reshape(width, height, called_from_parent); + layout(); +} + +void ALCornerField::setRange(F32 minimum, F32 maximum, F32 step, S32 decimals) +{ + for (LLSpinCtrl* spin : mSpin) + { + spin->setMinValue(minimum); + spin->setMaxValue(maximum); + spin->setIncrement(step); + spin->setPrecision(decimals); + } +} + +void ALCornerField::setValue(const LLSD& value) +{ + std::istringstream in(value.asString()); + std::array radii{}; + size_t count = 0; + + for (F32 number; count < 4 && in >> number; ++count) + { + radii[count] = number; + } + + switch (count) + { + case 1: mRadii = { radii[0], radii[0], radii[0], radii[0] }; break; + case 2: mRadii = { radii[0], radii[1], radii[0], radii[1] }; break; + default: mRadii = radii; break; + } + + const bool same = mRadii[0] == mRadii[1] && mRadii[1] == mRadii[2] && mRadii[2] == mRadii[3]; + + mLink->set(same); + showRadii(); +} + +LLSD ALCornerField::getValue() const +{ + return fmt::format("{} {} {} {}", mRadii[0], mRadii[1], mRadii[2], mRadii[3]); +} + +void ALCornerField::showRadii() +{ + for (size_t i = 0; i < 4; ++i) + { + mSpin[i]->setValue(LLSD(mRadii[i])); + } +} + +void ALCornerField::onSpin(size_t corner) +{ + const F32 value = (F32)mSpin[corner]->getValue().asReal(); + + if (mLink->get()) + { + mRadii.fill(value); + showRadii(); + } + else + { + mRadii[corner] = value; + } + + onCommit(); +} + +// The rectangle with its corners rounded as the numbers say, scaled so the +// largest radius is a quarter of the picture: what is being chosen is the +// proportion, and the picture says it before the number does. +void ALCornerField::draw() +{ + static const LLUIColor ink = LLUIColorTable::instance().getColor("LabelTextColor", LLColor4::white); + static const LLUIColor edge = LLUIColorTable::instance().getColor("DefaultShadowLight", LLColor4::black); + + const LLRect box(mPicture.mLeft + 6, mPicture.mTop - 6, mPicture.mRight - 6, mPicture.mBottom + 6); + const F32 largest = llmax(1.f, *std::max_element(mRadii.begin(), mRadii.end())); + const F32 scale = llmin(F32(box.getWidth()), F32(box.getHeight())) * 0.45f / largest; + const std::array r = { llmin(mRadii[0] * scale, box.getHeight() * 0.5f), llmin(mRadii[1] * scale, box.getHeight() * 0.5f), + llmin(mRadii[2] * scale, box.getHeight() * 0.5f), llmin(mRadii[3] * scale, box.getHeight() * 0.5f) }; + + gl_rect_2d(mPicture, edge.get() % 0.5f, true); + + // Each corner is an arc from one straight edge to the next; the four + // arcs and the straights between them are one outline. + const auto arc = [&](F32 cx, F32 cy, F32 radius, F32 from, F32 to) + { + constexpr S32 STEPS = 8; + F32 px = cx + radius * std::cos(from); + F32 py = cy + radius * std::sin(from); + + for (S32 i = 1; i <= STEPS; ++i) + { + const F32 angle = from + (to - from) * F32(i) / STEPS; + const F32 x = cx + radius * std::cos(angle); + const F32 y = cy + radius * std::sin(angle); + + gl_line_2d(ll_round(px), ll_round(py), ll_round(x), ll_round(y), ink.get()); + px = x; + py = y; + } + }; + constexpr F32 PI_F = 3.14159265f; + + arc(box.mLeft + r[0], box.mTop - r[0], r[0], PI_F * 0.5f, PI_F); + arc(box.mRight - r[1], box.mTop - r[1], r[1], 0.f, PI_F * 0.5f); + arc(box.mRight - r[2], box.mBottom + r[2], r[2], -PI_F * 0.5f, 0.f); + arc(box.mLeft + r[3], box.mBottom + r[3], r[3], PI_F, PI_F * 1.5f); + gl_line_2d(ll_round(box.mLeft + r[0]), box.mTop, ll_round(box.mRight - r[1]), box.mTop, ink.get()); + gl_line_2d(box.mRight, ll_round(box.mTop - r[1]), box.mRight, ll_round(box.mBottom + r[2]), ink.get()); + gl_line_2d(ll_round(box.mRight - r[2]), box.mBottom, ll_round(box.mLeft + r[3]), box.mBottom, ink.get()); + gl_line_2d(box.mLeft, ll_round(box.mBottom + r[3]), box.mLeft, ll_round(box.mTop - r[0]), ink.get()); + + LLUICtrl::draw(); +} diff --git a/indra/llui/alcornerfield.h b/indra/llui/alcornerfield.h new file mode 100644 index 00000000000..b96063992fc --- /dev/null +++ b/indra/llui/alcornerfield.h @@ -0,0 +1,72 @@ +/** + * @file alcornerfield.h + * @brief Four corner radii, drawn as the corners they round + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluictrl.h" + +#include + +class LLCheckBoxCtrl; +class LLSpinCtrl; + +// A rounded rectangle drawn with the radii it is given, and a box at each +// of its corners to change one. Linked, which is how they start when they +// agree, a change to any corner is a change to all four; unlinked, each is +// its own. The value is the four numbers clockwise from the top left with +// spaces between. +class ALCornerField : public LLUICtrl +{ +public: + AL_VIEW_TYPE(ALCornerField, LLUICtrl); + + static constexpr S32 HEIGHT = 74; + + struct Params : public LLInitParam::Block + { + Params(); + }; + + void setValue(const LLSD& value) override; + LLSD getValue() const override; + void setRange(F32 minimum, F32 maximum, F32 step, S32 decimals); + + void draw() override; + void reshape(S32 width, S32 height, bool called_from_parent = true) override; + +protected: + friend class LLUICtrlFactory; + ALCornerField(const Params& p); + +private: + void layout(); + void onSpin(size_t corner); + void showRadii(); + + std::array mSpin{}; + LLCheckBoxCtrl* mLink = nullptr; + // Top left, top right, bottom right, bottom left. + std::array mRadii{}; + LLRect mPicture; +}; diff --git a/indra/llui/aldockpanel.cpp b/indra/llui/aldockpanel.cpp index f14eb3386be..be78652bdf0 100644 --- a/indra/llui/aldockpanel.cpp +++ b/indra/llui/aldockpanel.cpp @@ -26,6 +26,7 @@ #include "aldockpanel.h" +#include "llfocusmgr.h" #include "lllayoutstack.h" #include "lluictrlfactory.h" @@ -139,7 +140,8 @@ void ALDockPanel::popOut() // band with the title and the window's own buttons. const S32 header = LLFloater::getDefaultParams().header_height; LLRect where = mFloatingRect; - if (where.isEmpty()) + const bool remembered = !where.isEmpty(); + if (!remembered) { where = calcScreenRect(); where.mTop += header; @@ -180,7 +182,25 @@ void ALDockPanel::popOut() } } + // The keyboard is not taken along, and the window it came from forgets + // it was there: a focused control moving to another window, with this + // one still remembering it as where its focus was, is how a keystroke + // ends up somewhere nobody is looking. + if (gFocusMgr.childHasKeyboardFocus(this)) + { + gFocusMgr.setKeyboardFocus(nullptr); + } + if (was_in) + { + gFocusMgr.clearLastFocusForGroup(was_in); + } + floater->openFloater(); + // Where it was remembered may be off a screen that has since gone. + if (remembered && gFloaterView && floater->getParent() == gFloaterView) + { + gFloaterView->adjustToFitScreen(floater, false); + } floater->setFocus(true); } diff --git a/indra/llui/alhistorylist.cpp b/indra/llui/alhistorylist.cpp index 3d5e06db6b3..c08e2e274df 100644 --- a/indra/llui/alhistorylist.cpp +++ b/indra/llui/alhistorylist.cpp @@ -185,6 +185,18 @@ void ALHistoryList::onRowChosen() // Choosing a step is asking for the document to be as it was just after that // step: the count in force is one more than its index. Choosing the one that // is already the present asks for nothing. +bool ALHistoryList::handleKeyHere(KEY key, MASK mask) +{ + // Return is the double click by keyboard. Reached after the list has + // declined it, which a scroll list does. + if (key == KEY_RETURN && mask == MASK_NONE && mList && !mSteps.empty()) + { + goToSelected(); + return true; + } + return LLPanel::handleKeyHere(key, mask); +} + void ALHistoryList::goToSelected() { const LLScrollListItem* item = mList->getFirstSelected(); diff --git a/indra/llui/alhistorylist.h b/indra/llui/alhistorylist.h index dfc10eaeece..d154b5b33f2 100644 --- a/indra/llui/alhistorylist.h +++ b/indra/llui/alhistorylist.h @@ -91,9 +91,12 @@ class ALHistoryList : public LLPanel } // The step now selected, asked for as the present: what a double click - // does, and what a caller with a key bound to it calls. + // does, what Return does while the list has the keyboard, and what a + // caller with another key bound to it calls. void goToSelected(); + bool handleKeyHere(KEY key, MASK mask) override; + // A row selected, by its index into the steps: for a caller that shows // the element a step was about while it is pointed at. typedef boost::signals2::signal chose_signal_t; diff --git a/indra/llui/alimagefield.cpp b/indra/llui/alimagefield.cpp new file mode 100644 index 00000000000..810ba94ed30 --- /dev/null +++ b/indra/llui/alimagefield.cpp @@ -0,0 +1,297 @@ +/** + * @file alimagefield.cpp + * @brief A field whose value is the name of a picture, shown as the picture + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alimagefield.h" + +#include "alpopover.h" +#include "alspecimenlist.h" +#include "alstringmatch.h" + +#include "llbutton.h" +#include "llfiltereditor.h" +#include "llfloater.h" +#include "lllineeditor.h" +#include "llrender2dutils.h" +#include "lltrans.h" +#include "llui.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +static LLDefaultChildRegistry::Register r("image_field"); + +namespace +{ + constexpr S32 ROW = 22; + constexpr S32 MIN_WIDTH = 360; + constexpr S32 MIN_HEIGHT = 300; + + // Where the popover opens, and where it opens next time. + S32 sWidth = 520; + S32 sHeight = 480; + + // The popover: a filter over a gallery of the pictures offered, each + // as itself under its name, and a way through to the tool that edits + // one. Choosing is choosing and not committing: the title says what + // would be written, and it is written when the popover goes. Escape + // abandons it. + class ALImagePopover final : public ALPopover + { + public: + AL_VIEW_TYPE(ALImagePopover, ALPopover); + + ALImagePopover(const LLFloater::Params& p, const std::string& value, std::vector choices, + ALImageField::edit_t edit, const std::string& edit_label) + : ALPopover(p), + mValue(value), + mChoices(std::move(choices)), + mEdit(std::move(edit)) + { + const S32 width = getRect().getWidth(); + const S32 height = getRect().getHeight() - getHeaderHeight(); + const bool editable = mEdit != nullptr; + const S32 button_width = editable ? 160 : 0; + + LLFilterEditor::Params fp; + fp.name = "filter"; + fp.rect = LLRect(4, height - 4, width - 4 - button_width - (editable ? 6 : 0), height - 4 - ROW); + fp.label = LLTrans::getString("ColorFieldFilter"); + fp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT; + mFilter = LLUICtrlFactory::create(fp); + mFilter->setCommitCallback([this](LLUICtrl* ctrl, const LLSD&) { filter(ctrl->getValue().asString()); }); + addChild(mFilter); + + if (editable) + { + LLButton::Params bp; + bp.name = "edit"; + bp.label = edit_label; + bp.rect = LLRect(width - 4 - button_width, height - 4, width - 4, height - 4 - ROW); + bp.follows.flags = FOLLOWS_RIGHT | FOLLOWS_TOP; + LLButton* edit_button = LLUICtrlFactory::create(bp); + edit_button->setCommitCallback([this](LLUICtrl*, const LLSD&) + { + // The name goes to the tool and the field alike: the + // tool opens on it, and the field keeps it. Copied + // out first, since settling is the end of this. + const ALImageField::edit_t edit = mEdit; + const std::string name = mValue; + settle(); + edit(name); + }); + addChild(edit_button); + } + + ALSpecimenList::Params gp(LLUICtrlFactory::getDefaultParams()); + gp.name = "images"; + gp.rect = LLRect(2, height - 8 - ROW, width - 2, 2); + gp.cell_width = 120; + gp.cell_height = 72; + gp.empty_headline = LLTrans::getString("ImageFieldNoMatch"); + gp.empty_sentence = LLTrans::getString("ImageFieldNoMatchHow"); + gp.follows.flags = FOLLOWS_ALL; + mGallery = LLUICtrlFactory::create(gp); + mGallery->onChose([this](const std::string& name) + { + mValue = name; + setTitle(mValue); + }); + addChild(mGallery); + filter(LLStringUtil::null); + setTitle(mValue); + } + + const std::string& value() const { return mValue; } + + void onClose(bool app_quitting) override + { + sWidth = getRect().getWidth(); + sHeight = getRect().getHeight(); + ALPopover::onClose(app_quitting); + } + + private: + // The pictures are given once and the list narrows them itself. + void filter(const std::string& text) + { + if (mGallery->count() == 0) + { + std::vector specimens; + for (const ALImageField::Choice& choice : mChoices) + { + ALSpecimenList::Specimen specimen; + specimen.group = choice.heading; + specimen.label = choice.name; + specimen.value = choice.name; + specimen.image = choice.name; + specimen.toolTip = choice.name; + specimens.push_back(std::move(specimen)); + } + mGallery->setSpecimens(std::move(specimens)); + } + mGallery->filter(text); + mGallery->setChosen(mValue); + } + + std::string mValue; + std::vector mChoices; + ALImageField::edit_t mEdit; + LLFilterEditor* mFilter = nullptr; + ALSpecimenList* mGallery = nullptr; + }; +} + +ALImageField::Params::Params() +: swatch_width("swatch_width", 22) +{ +} + +ALImageField::ALImageField(const Params& p) +: LLUICtrl(p), + mSwatchWidth(p.swatch_width) +{ + LLLineEditor::Params ep; + ep.name = "text"; + ep.rect = LLRect(mSwatchWidth + 3, getRect().getHeight(), getRect().getWidth(), 0); + ep.follows.flags = FOLLOWS_ALL; + ep.commit_on_focus_lost = true; + mEditor = LLUICtrlFactory::create(ep); + mEditor->setCommitCallback([this](LLUICtrl*, const LLSD&) { onTextCommit(); }); + addChild(mEditor); +} + +ALImageField::~ALImageField() +{ + closePopover(); +} + +void ALImageField::setValue(const LLSD& value) +{ + setName(value.asString()); + if (mEditor) + { + mEditor->setText(mName); + } +} + +LLSD ALImageField::getValue() const +{ + return mName; +} + +// The picture is looked up when the name changes, not on every frame it +// is drawn. +void ALImageField::setName(const std::string& name) +{ + mName = name; + mImage = name.empty() ? LLPointer() : LLUI::getUIImage(name); +} + +void ALImageField::setChoices(choices_t choices) +{ + mChoices = std::move(choices); +} + +void ALImageField::setEditor(edit_t editor, std::string label) +{ + mEdit = std::move(editor); + mEditLabel = std::move(label); +} + +// The picture fitted into the swatch at its own proportions over a +// checkerboard, so an edge that is transparent reads as one. +void ALImageField::draw() +{ + static const LLUIColor edge = LLUIColorTable::instance().getColor("DefaultShadowLight", LLColor4::black); + const LLRect swatch(0, getRect().getHeight() - 2, mSwatchWidth, 2); + + gl_rect_2d_checkerboard(swatch, 1.f); + if (mImage.notNull() && mImage->getWidth() > 0 && mImage->getHeight() > 0) + { + const F32 fit = llmin((F32)(swatch.getWidth() - 2) / (F32)mImage->getWidth(), + (F32)(swatch.getHeight() - 2) / (F32)mImage->getHeight()); + const S32 width = llmax(1, ll_round((F32)mImage->getWidth() * fit)); + const S32 height = llmax(1, ll_round((F32)mImage->getHeight() * fit)); + mImage->draw(swatch.mLeft + (swatch.getWidth() - width) / 2, swatch.mBottom + (swatch.getHeight() - height) / 2, + width, height); + } + gl_rect_2d(swatch, edge.get(), false); + LLUICtrl::draw(); +} + +bool ALImageField::handleMouseDown(S32 x, S32 y, MASK mask) +{ + if (x < mSwatchWidth) + { + openPopover(); + return true; + } + return LLUICtrl::handleMouseDown(x, y, mask); +} + +void ALImageField::onTextCommit() +{ + setName(mEditor->getText()); + onCommit(); +} + +// What the popover settled on, once it has gone. +void ALImageField::chose(const std::string& name) +{ + if (name != mName) + { + setValue(name); + onCommit(); + } +} + +void ALImageField::openPopover() +{ + closePopover(); + + LLFloater::Params p(ALPopover::paramsFor(sWidth, sHeight, mName, /*resizable=*/true)); + p.min_width = MIN_WIDTH; + p.min_height = MIN_HEIGHT; + ALImagePopover* popover = new ALImagePopover(p, mName, mChoices ? mChoices() : std::vector(), mEdit, mEditLabel); + mPopover = popover->getDerivedHandle(); + popover->onClosed([this, held = popover->getDerivedHandle()](bool escaped) + { + if (ALImagePopover* said = held.get(); said && !escaped) + { + chose(said->value()); + } + mPopover.markDead(); + }); + popover->openBeside(this); +} + +void ALImageField::closePopover() +{ + if (ALPopover* popover = mPopover.get()) + { + popover->escape(); + } + mPopover.markDead(); +} diff --git a/indra/llui/alimagefield.h b/indra/llui/alimagefield.h new file mode 100644 index 00000000000..0d91f88defa --- /dev/null +++ b/indra/llui/alimagefield.h @@ -0,0 +1,103 @@ +/** + * @file alimagefield.h + * @brief A field whose value is the name of a picture, shown as the picture + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluictrl.h" +#include "lluiimage.h" + +#include +#include +#include + +class ALPopover; +class LLLineEditor; + +// What a XUI file writes for a picture is the name of a UI image, a file +// among the skin's textures. So this +// holds the name, shows the picture it comes to beside it, and opens the +// pictures over whatever is under it -- each drawn as itself under its +// name, since a picture is known by sight before it is known by name -- +// with a way through to whatever tool edits it. +// +// The names offered and the tool are the caller's: this library draws a +// picture by name and knows nothing about where the names come from. +class ALImageField : public LLUICtrl +{ +public: + AL_VIEW_TYPE(ALImageField, LLUICtrl); + + struct Params : public LLInitParam::Block + { + Optional swatch_width; + Params(); + }; + + // The name as the file writes it. Committing gives back a name. + void setValue(const LLSD& value) override; + LLSD getValue() const override; + + // A name that is offered, and the heading it is offered under: the + // skin's shapes under one, its textures under another. + struct Choice + { + std::string name; + std::string heading; + }; + typedef std::function()> choices_t; + // Asked when the popover opens, since what a skin draws changes. + void setChoices(choices_t choices); + + // A tool that edits a picture by name, and what its button says. + typedef std::function edit_t; + void setEditor(edit_t editor, std::string label); + + // Whether the name comes to a picture at all. + bool resolved() const { return mImage.notNull(); } + + void draw() override; + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + + ~ALImageField() override; + +protected: + friend class LLUICtrlFactory; + ALImageField(const Params& p); + +private: + void openPopover(); + void closePopover(); + void chose(const std::string& name); + void onTextCommit(); + void setName(const std::string& name); + + std::string mName; + LLPointer mImage; + S32 mSwatchWidth; + LLLineEditor* mEditor = nullptr; + LLHandle mPopover; + choices_t mChoices; + edit_t mEdit; + std::string mEditLabel; +}; diff --git a/indra/llui/aloffsetpad.cpp b/indra/llui/aloffsetpad.cpp new file mode 100644 index 00000000000..b105bb63b1b --- /dev/null +++ b/indra/llui/aloffsetpad.cpp @@ -0,0 +1,220 @@ +/** + * @file aloffsetpad.cpp + * @brief An offset chosen by dragging a dot + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "aloffsetpad.h" + +#include "llfocusmgr.h" +#include "llrender.h" +#include "llrender2dutils.h" +#include "llspinctrl.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +#include +#include + +static LLDefaultChildRegistry::Register r("offset_pad"); + +namespace +{ + constexpr S32 BOX_WIDTH = 78; + constexpr S32 BOX_HEIGHT = 20; + constexpr S32 GAP = 6; +} + +ALOffsetPad::Params::Params() +{ +} + +ALOffsetPad::ALOffsetPad(const Params& p) : LLUICtrl(p) +{ + for (LLSpinCtrl** spin : { &mX, &mY }) + { + LLSpinCtrl::Params sp; + + sp.name = spin == &mX ? "x" : "y"; + sp.label = spin == &mX ? "X" : "Y"; + sp.label_width = 14; + sp.rect = LLRect(0, BOX_HEIGHT, BOX_WIDTH, 0); + sp.decimal_digits = 1; + sp.increment = 0.5f; + sp.min_value = -512.f; + sp.max_value = 512.f; + *spin = LLUICtrlFactory::create(sp); + (*spin)->setCommitCallback([this](LLUICtrl*, const LLSD&) + { + mOffset[0] = (F32)mX->getValue().asReal(); + mOffset[1] = (F32)mY->getValue().asReal(); + onCommit(); + }); + addChild(*spin); + } + + layout(); +} + +void ALOffsetPad::layout() +{ + const S32 height = getRect().getHeight(); + const S32 side = llmax(24, height - 4); + + mPad = LLRect(2, height - 2, 2 + side, height - 2 - side); + + const S32 left = mPad.mRight + GAP; + const S32 top = height - 2; + + mX->setShape(LLRect(left, top, left + BOX_WIDTH, top - BOX_HEIGHT)); + mY->setShape(LLRect(left, top - BOX_HEIGHT - GAP, left + BOX_WIDTH, top - 2 * BOX_HEIGHT - GAP)); +} + +void ALOffsetPad::reshape(S32 width, S32 height, bool called_from_parent) +{ + LLUICtrl::reshape(width, height, called_from_parent); + layout(); +} + +void ALOffsetPad::setRange(F32 reach, F32 step, S32 decimals) +{ + mReach = llmax(1.f, reach); + + for (LLSpinCtrl* spin : { mX, mY }) + { + spin->setMinValue(-mReach); + spin->setMaxValue(mReach); + spin->setIncrement(step); + spin->setPrecision(decimals); + } +} + +void ALOffsetPad::setValue(const LLSD& value) +{ + std::istringstream in(value.asString()); + F32 x = 0.f; + F32 y = 0.f; + + in >> x >> y; + mOffset[0] = x; + mOffset[1] = y; + showNumbers(); +} + +LLSD ALOffsetPad::getValue() const +{ + return fmt::format("{} {}", mOffset[0], mOffset[1]); +} + +void ALOffsetPad::showNumbers() +{ + mX->setValue(LLSD(mOffset[0])); + mY->setValue(LLSD(mOffset[1])); +} + +// Where the pointer is, as an offset: the pad's centre is none and its +// edge is the reach, in both directions. +void ALOffsetPad::place(S32 x, S32 y) +{ + const F32 half = llmax(1.f, F32(mPad.getWidth()) * 0.5f); + const F32 cx = F32(mPad.mLeft + mPad.mRight) * 0.5f; + const F32 cy = F32(mPad.mTop + mPad.mBottom) * 0.5f; + + mOffset[0] = llclamp((x - cx) / half * mReach, -mReach, mReach); + mOffset[1] = llclamp((y - cy) / half * mReach, -mReach, mReach); + // Half steps, which is what the boxes show. + mOffset[0] = ll_round(mOffset[0] * 2.f) * 0.5f; + mOffset[1] = ll_round(mOffset[1] * 2.f) * 0.5f; + showNumbers(); +} + +void ALOffsetPad::draw() +{ + static const LLUIColor well = LLUIColorTable::instance().getColor("DefaultShadowLight", LLColor4::black); + static const LLUIColor grid = LLUIColorTable::instance().getColor("LabelDisabledColor", LLColor4::grey); + static const LLUIColor dot = LLUIColorTable::instance().getColor("EmphasisColor", LLColor4::yellow); + + gl_rect_2d(mPad, well.get() % 0.6f, true); + gl_rect_2d(mPad, grid.get() % 0.6f, false); + + const S32 cx = (mPad.mLeft + mPad.mRight) / 2; + const S32 cy = (mPad.mTop + mPad.mBottom) / 2; + + gl_line_2d(mPad.mLeft, cy, mPad.mRight, cy, grid.get() % 0.4f); + gl_line_2d(cx, mPad.mBottom, cx, mPad.mTop, grid.get() % 0.4f); + + const F32 half = F32(mPad.getWidth()) * 0.5f; + const S32 x = cx + ll_round(mOffset[0] / mReach * half); + const S32 y = cy + ll_round(mOffset[1] / mReach * half); + + gl_line_2d(cx, cy, x, y, dot.get() % 0.6f); + gl_rect_2d(LLRect(x - 3, y + 3, x + 3, y - 3), dot.get(), true); + + LLUICtrl::draw(); +} + +bool ALOffsetPad::handleMouseDown(S32 x, S32 y, MASK mask) +{ + if (!mPad.pointInRect(x, y)) + { + return LLUICtrl::handleMouseDown(x, y, mask); + } + + mDragging = true; + gFocusMgr.setMouseCapture(this); + place(x, y); + + return true; +} + +bool ALOffsetPad::handleHover(S32 x, S32 y, MASK mask) +{ + if (mDragging) + { + place(x, y); + + return true; + } + + return LLUICtrl::handleHover(x, y, mask); +} + +void ALOffsetPad::onMouseCaptureLost() +{ + mDragging = false; + LLUICtrl::onMouseCaptureLost(); +} + +bool ALOffsetPad::handleMouseUp(S32 x, S32 y, MASK mask) +{ + if (!mDragging) + { + return LLUICtrl::handleMouseUp(x, y, mask); + } + + mDragging = false; + gFocusMgr.setMouseCapture(nullptr); + place(x, y); + onCommit(); + + return true; +} diff --git a/indra/llui/aloffsetpad.h b/indra/llui/aloffsetpad.h new file mode 100644 index 00000000000..e088228ab31 --- /dev/null +++ b/indra/llui/aloffsetpad.h @@ -0,0 +1,75 @@ +/** + * @file aloffsetpad.h + * @brief An offset chosen by dragging a dot + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluictrl.h" + +class LLSpinCtrl; + +// A square pad with a dot where the offset is, dragged to move it, and the +// two numbers beside it for anyone who knows the number they want. The +// value is x and y with a space between; a drag commits when it ends. +class ALOffsetPad : public LLUICtrl +{ +public: + AL_VIEW_TYPE(ALOffsetPad, LLUICtrl); + + static constexpr S32 HEIGHT = 70; + + struct Params : public LLInitParam::Block + { + Params(); + }; + + void setValue(const LLSD& value) override; + LLSD getValue() const override; + // How far the pad reaches from its centre, in the value's units. + void setRange(F32 reach, F32 step, S32 decimals); + + void draw() override; + void reshape(S32 width, S32 height, bool called_from_parent = true) override; + bool handleMouseDown(S32 x, S32 y, MASK mask) override; + bool handleMouseUp(S32 x, S32 y, MASK mask) override; + bool handleHover(S32 x, S32 y, MASK mask) override; + // A drag ends with the button, or with the mouse taken away: either + // way the pointer stops moving the dot. + void onMouseCaptureLost() override; + +protected: + friend class LLUICtrlFactory; + ALOffsetPad(const Params& p); + +private: + void layout(); + void place(S32 x, S32 y); + void showNumbers(); + + LLSpinCtrl* mX = nullptr; + LLSpinCtrl* mY = nullptr; + LLRect mPad; + F32 mOffset[2] = { 0.f, 0.f }; + F32 mReach = 32.f; + bool mDragging = false; +}; diff --git a/indra/llui/alpanefolds.cpp b/indra/llui/alpanefolds.cpp new file mode 100644 index 00000000000..a0d8aad7ef9 --- /dev/null +++ b/indra/llui/alpanefolds.cpp @@ -0,0 +1,286 @@ +/** + * @file alpanefolds.cpp + * @brief The regions of a window that fold away, and the buttons that fold them + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "alpanefolds.h" + +#include "aldockpanel.h" +#include "llbutton.h" +#include "llfloater.h" +#include "lllayoutstack.h" + +void ALPaneFolds::bind(LLView* window, std::vector panes) +{ + mPanes.clear(); + + for (Pane& pane : panes) + { + Bound bound; + + bound.mRegion = window->findChildView(pane.mPanel, true); + bound.mPanel = ALViewType::as(bound.mRegion); + bound.mButton = pane.mButton.empty() ? nullptr : window->findChild(pane.mButton, true); + // Everything the region holds, moved into a pane of its own that + // fills it: the tree gains a level and every name in it is where it + // was. + bound.mDock = bound.mRegion && !pane.mTitle.empty() ? ALDockPanel::wrap(bound.mRegion, pane.mTitle) : nullptr; + bound.mPane = std::move(pane); + + if (bound.mButton) + { + const std::string key = bound.mPane.mKey; + + bound.mButton->setCommitCallback([this, key](LLUICtrl*, const LLSD&) { toggle(key); }); + } + + mPanes.push_back(std::move(bound)); + } + + refreshButtons(); +} + +const ALPaneFolds::Bound* ALPaneFolds::find(std::string_view pane) const +{ + for (const Bound& bound : mPanes) + { + if (bound.mPane.mKey == pane || bound.mPane.mPanel == pane) + { + return &bound; + } + } + + return nullptr; +} + +ALPaneFolds::Bound* ALPaneFolds::find(std::string_view pane) +{ + return const_cast(std::as_const(*this).find(pane)); +} + +// A fold somebody asked for, which is what a window writes down; a fold +// read back from what was written is not. +void ALPaneFolds::toggle(std::string_view pane) +{ + setCollapsed(pane, !collapsed(pane)); + mChanged(); +} + +// A region the person is not using gives its room to the one that grows. +// A folded region keeps everything it holds: what goes is the room, so +// coming back costs nothing and loses no selection. +void ALPaneFolds::setCollapsed(std::string_view pane, bool collapsed) +{ + const Bound* bound = find(pane); + LLLayoutStack* stack = bound && bound->mPanel ? bound->mPanel->getParentAs() : nullptr; + + if (!stack) + { + return; + } + + stack->collapsePanel(bound->mPanel, collapsed); + refreshButtons(); +} + +bool ALPaneFolds::collapsed(std::string_view pane) const +{ + const Bound* bound = find(pane); + + return bound && bound->mPanel && bound->mPanel->isCollapsed(); +} + +S32 ALPaneFolds::dim(std::string_view pane) const +{ + const Bound* bound = find(pane); + + return bound && bound->mPanel ? bound->mPanel->getTargetDim() : 0; +} + +// Asked for the way a drag on the bar asks, so the stack takes it from +// its neighbours the way it would have then. +void ALPaneFolds::setDim(std::string_view pane, S32 dim) +{ + const Bound* bound = find(pane); + + if (bound && bound->mPanel && dim > 0) + { + bound->mPanel->setTargetDim(dim); + } +} + +std::vector ALPaneFolds::dims() const +{ + std::vector result; + + for (const Bound& bound : mPanes) + { + result.push_back(bound.mPanel ? bound.mPanel->getTargetDim() : 0); + } + + return result; +} + +bool ALPaneFolds::out(std::string_view pane) const +{ + const Bound* bound = find(pane); + + return bound && bound->mDock && bound->mDock->poppedOut(); +} + +void ALPaneFolds::toggleOut(std::string_view pane) +{ + const Bound* bound = find(pane); + + if (!bound || !bound->mDock) + { + return; + } + + if (bound->mDock->poppedOut()) + { + bound->mDock->dock(); + } + else + { + bound->mDock->popOut(); + } + + refreshButtons(); + mChanged(); +} + +LLFloater* ALPaneFolds::window(std::string_view pane) const +{ + const Bound* bound = find(pane); + + return bound && bound->mDock && bound->mDock->poppedOut() ? bound->mDock->getParentByType() : nullptr; +} + +void ALPaneFolds::dockAll() +{ + for (const Bound& bound : mPanes) + { + if (bound.mDock && bound.mDock->poppedOut()) + { + bound.mDock->dock(); + } + } +} + +// Pressed in while the region is showing, so a row of them reads as a +// list of what is on screen rather than a list of what is hidden. +void ALPaneFolds::refreshButtons() +{ + for (const Bound& bound : mPanes) + { + if (bound.mButton) + { + bound.mButton->setToggleState(!(bound.mPanel && bound.mPanel->isCollapsed())); + } + } +} + +// Folded, how wide, and whether it is in a window of its own and where +// that window is: a person who put the inspectors on the other monitor +// finds them there next time. +void ALPaneFolds::save(LLSD& state) const +{ + for (const Bound& bound : mPanes) + { + if (bound.mPanel) + { + state["fold_" + bound.mPane.mKey] = collapsed(bound.mPane.mKey); + state["dim_" + bound.mPane.mKey] = dim(bound.mPane.mKey); + } + + if (!bound.mDock) + { + continue; + } + + state["out_" + bound.mPane.mKey] = bound.mDock->poppedOut(); + + const LLRect r = bound.mDock->floatingRect(); + + if (!r.isEmpty()) + { + LLSD rect = LLSD::emptyArray(); + + rect.append(r.mLeft); + rect.append(r.mBottom); + rect.append(r.mRight); + rect.append(r.mTop); + state["rect_" + bound.mPane.mKey] = rect; + } + } +} + +// The sizes first, then the folds: a fold takes the size the region had +// and gives it back on unfolding, so a size set after a fold is lost. +void ALPaneFolds::load(const LLSD& state) +{ + for (const Bound& bound : mPanes) + { + const std::string key = "dim_" + bound.mPane.mKey; + + if (state.has(key)) + { + setDim(bound.mPane.mKey, state[key].asInteger()); + } + } + + for (const Bound& bound : mPanes) + { + const std::string key = "fold_" + bound.mPane.mKey; + + if (state.has(key)) + { + setCollapsed(bound.mPane.mKey, state[key].asBoolean()); + } + } + + for (const Bound& bound : mPanes) + { + const std::string rect = "rect_" + bound.mPane.mKey; + const std::string out = "out_" + bound.mPane.mKey; + + if (!bound.mDock) + { + continue; + } + + if (state.has(rect) && state[rect].size() == 4) + { + bound.mDock->setFloatingRect(LLRect(state[rect][0].asInteger(), state[rect][3].asInteger(), + state[rect][2].asInteger(), state[rect][1].asInteger())); + } + + if (state[out].asBoolean()) + { + bound.mDock->popOut(); + } + } + + refreshButtons(); +} diff --git a/indra/llui/alpanefolds.h b/indra/llui/alpanefolds.h new file mode 100644 index 00000000000..2349f2e41ce --- /dev/null +++ b/indra/llui/alpanefolds.h @@ -0,0 +1,125 @@ +/** + * @file alpanefolds.h + * @brief The regions of a window that fold away, and the buttons that fold them + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llsd.h" + +#include +#include +#include + +#include + +class ALDockPanel; +class LLButton; +class LLFloater; +class LLLayoutPanel; +class LLView; + +// The regions of a window that fold away and are dragged to a size, each +// with the button in the window's bar that folds it and the key its state +// is kept under, and that can be taken out into a window of their own. A +// window laid out as XUI Studio is has three of them and the same +// questions about each: fold it, is it folded, how wide is it, press the +// button in while it shows, take it out and put it back, write all that +// down and read it back. One answer to each, for every such window. +// +// A region taken out is moved in the tree, not copied, so every pointer +// into it keeps working; what stops working is every getChild that goes +// through it from the window, so a window with such regions holds +// pointers to what is in them. +// +// A region need not be a layout panel. Any view can be taken out and put +// back and have that written down -- a tab page, say -- and only a layout +// panel in a stack folds and has a size; asked to fold, another kind of +// region does nothing. +class ALPaneFolds +{ +public: + struct Pane + { + // What the state calls it: fold_, dim_, out_. + std::string mKey; + // The region, by name, and the toggle button that folds it, if any. + std::string mPanel; + std::string mButton; + // What the window is called while the region is out; empty for a + // region that stays put. + std::string mTitle; + }; + + // The window the panes live in, searched once; each button's press + // folds its region from then on, and each region with a title is put + // in a pane that can leave. + void bind(LLView* window, std::vector panes); + + // A region by its key or its panel's name. + void toggle(std::string_view pane); + void setCollapsed(std::string_view pane, bool collapsed); + bool collapsed(std::string_view pane) const; + // The size a drag on the bar gives it, and the same asked for. + S32 dim(std::string_view pane) const; + void setDim(std::string_view pane, S32 dim); + // Every region's size, in the order given, for a window that watches + // for a drag ending. + std::vector dims() const; + + // A region out in a window of its own, and back; and every one back, + // which a window does before it closes, since a pane left in a window + // it does not own goes down with it. + bool out(std::string_view pane) const; + void toggleOut(std::string_view pane); + void dockAll(); + // The window a region is out in, or null while it is home. + LLFloater* window(std::string_view pane) const; + + // The buttons pressed in while their regions show. + void refreshButtons(); + + // Into and out of a saved state. + void save(LLSD& state) const; + void load(const LLSD& state); + + // A region folded or unfolded, from a button or a call. + typedef boost::signals2::signal changed_signal_t; + boost::signals2::connection onChanged(const changed_signal_t::slot_type& cb) { return mChanged.connect(cb); } + +private: + struct Bound + { + Pane mPane; + // The region, and the same as a layout panel where it is one. + LLView* mRegion = nullptr; + LLLayoutPanel* mPanel = nullptr; + LLButton* mButton = nullptr; + ALDockPanel* mDock = nullptr; + }; + + const Bound* find(std::string_view pane) const; + Bound* find(std::string_view pane); + + std::vector mPanes; + changed_signal_t mChanged; +}; diff --git a/indra/llui/alpopover.cpp b/indra/llui/alpopover.cpp index 2b672dafdf4..8c19129c9d6 100644 --- a/indra/llui/alpopover.cpp +++ b/indra/llui/alpopover.cpp @@ -29,9 +29,28 @@ #include "llpanel.h" #include "lluictrlfactory.h" +#include + +namespace +{ +// The size each kind of popover was last left at, for the session. +boost::unordered_flat_map>& rememberedSizes() +{ + static boost::unordered_flat_map> sizes; + return sizes; +} +} // namespace + ALPopover::ALPopover(const LLFloater::Params& p) : LLFloater(LLSD(), p) { + // What paramsRemembered wrote into the name, which is the one place a + // params block has to carry it. + static const std::string prefix = "popover:"; + if (p.name().compare(0, prefix.size(), prefix) == 0) + { + mSizeKind = p.name().substr(prefix.size()); + } } // static @@ -67,6 +86,23 @@ LLFloater::Params ALPopover::paramsFor(S32 width, S32 height, const std::string& return p; } +// static +LLFloater::Params ALPopover::paramsRemembered(const std::string& kind, S32 width, S32 height, + const std::string& title, bool resizable) +{ + const auto found = rememberedSizes().find(kind); + if (found != rememberedSizes().end()) + { + width = found->second.first; + height = found->second.second; + } + LLFloater::Params p = paramsFor(width, height, title, resizable); + // Carried on the name, since a params block has nowhere else to say it; + // the constructor reads it back. + p.name = "popover:" + kind; + return p; +} + // Under the control, its left edge with the control's, and flipped above it // where under would put it off the bottom: a panel a person cannot see the // whole of is a panel that has not opened. A side off the screen is put @@ -76,6 +112,18 @@ void ALPopover::openBeside(const LLView* anchor) { if (anchor) { + // Where keys go: the window the anchor is in, or the anchor itself + // where it is one. + const LLFloater* home = ALViewType::as(anchor); + if (!home) + { + home = anchor->getParentByType(); + } + if (home && home != this) + { + mHome = home->getHandle(); + } + const LLRect screen = anchor->calcScreenRect(); LLRect where = getRect(); where.setLeftTopAndSize(screen.mLeft, screen.mBottom, where.getWidth(), where.getHeight()); @@ -114,6 +162,10 @@ void ALPopover::onClose(bool app_quitting) mSaidSo = true; mClosed(mEscaped); } + if (!mSizeKind.empty()) + { + rememberedSizes()[mSizeKind] = { getRect().getWidth(), getRect().getHeight() }; + } LLFloater::onClose(app_quitting); } @@ -137,5 +189,13 @@ bool ALPopover::handleKeyHere(KEY key, MASK mask) settle(); return true; } - return LLFloater::handleKeyHere(key, mask); + if (LLFloater::handleKeyHere(key, mask)) + { + return true; + } + if (LLFloater* home = mHome.get()) + { + return home->handleKeyHere(key, mask); + } + return false; } diff --git a/indra/llui/alpopover.h b/indra/llui/alpopover.h index ef6195cd34d..31ec065f2db 100644 --- a/indra/llui/alpopover.h +++ b/indra/llui/alpopover.h @@ -51,6 +51,13 @@ // header, or resizes -- derives from this, builds its own content, and // opens with openBeside(): the placing and the three ways out are the same // for every one of them. +// +// A popover is a window of its own, so a key pressed over it never climbs +// to the window it opened from, and a Control key would go to the viewer's +// menu bar instead. Keys the popover and its content do not take go home +// to the floater the anchor was in, the way a pane out in a window of its +// own sends them (ALPanelFloater), so a window's own shortcuts still answer +// while one of its popovers has the keyboard. class ALPopover : public LLFloater { public: @@ -72,6 +79,15 @@ class ALPopover : public LLFloater const std::string& title = LLStringUtil::null, bool resizable = false); + // The same, at the size the last popover of this kind was left at, or + // the size given where none has been: how much of a colour wheel a + // person wants to see is theirs to decide, and once decided it should + // not have to be decided again for the next one. A popover built from + // these remembers its size as it closes. + static LLFloater::Params paramsRemembered(const std::string& kind, S32 width, S32 height, + const std::string& title = LLStringUtil::null, + bool resizable = true); + // Opened beside the anchor: under it with their left edges together, // above it where under would run off the bottom, and shoved back on // screen where a side would run off, then shown and given the keyboard. @@ -94,16 +110,23 @@ class ALPopover : public LLFloater bool escaped() const { return mEscaped; } + // The floater the anchor was in, which unhandled keys go home to. + LLFloater* home() const { return mHome.get(); } + void onClose(bool app_quitting) override; void onFocusLost() override; bool handleKeyHere(KEY key, MASK mask) override; + bool hasAccelerators() const override { return !mHome.isDead(); } protected: friend class LLUICtrlFactory; ALPopover(const LLFloater::Params& p); private: - bool mEscaped = false; - bool mSaidSo = false; // the signal is sent once - closed_signal_t mClosed; + bool mEscaped = false; + bool mSaidSo = false; // the signal is sent once + closed_signal_t mClosed; + LLHandle mHome; + // What paramsRemembered was asked for, and so what the size is kept as. + std::string mSizeKind; }; diff --git a/indra/llui/alpropertygrid.cpp b/indra/llui/alpropertygrid.cpp index 6b8eb06c2de..3b0572cedc3 100644 --- a/indra/llui/alpropertygrid.cpp +++ b/indra/llui/alpropertygrid.cpp @@ -31,8 +31,11 @@ #include "llui.h" #include "llaccordionctrl.h" #include "llaccordionctrltab.h" +#include "alangledial.h" #include "alcolorfield.h" +#include "alcornerfield.h" #include "alflagsfield.h" +#include "aloffsetpad.h" #include "alemptystate.h" #include "alfollowscontrol.h" #include "alfontfield.h" @@ -40,10 +43,12 @@ #include "llcheckboxctrl.h" #include "llcombobox.h" #include "lllineeditor.h" +#include "llsliderctrl.h" #include "llspinctrl.h" #include "lltextbox.h" #include "lluicolortable.h" #include "lluictrlfactory.h" +#include "llfocusmgr.h" #include @@ -108,6 +113,28 @@ namespace return (F32)atof(text.c_str()); } + // How a number's box behaves: what the vocabulary said where it said + // anything, and the old rule of thumb where it did not. + S32 decimalsOf(const ALPropertyGrid::Field& field, bool whole) + { + return field.decimals >= 0 ? field.decimals : whole ? 0 : 3; + } + + F32 stepOf(const ALPropertyGrid::Field& field, bool whole) + { + return field.step > 0.f ? field.step : whole ? 1.f : 0.1f; + } + + F32 minimumOf(const ALPropertyGrid::Field& field) + { + return field.bounded ? field.minimum : field.kind == ALParamType::UNSIGNED ? 0.f : -100000.f; + } + + F32 maximumOf(const ALPropertyGrid::Field& field) + { + return field.bounded ? field.maximum : 100000.f; + } + // A value that is several numbers, as the numbers it is. A file writes // them with spaces between them and an LLSD one arrives with commas, so // both separate. @@ -149,6 +176,13 @@ namespace return type.find("LLFontGL") != std::string_view::npos; } + // A picture is a name out of the skin, and is known by sight: the + // type is the pointer the block holds. + bool isImageType(std::string_view type) + { + return type.find("LLUIImage") != std::string_view::npos; + } + // A field nothing writes is shown in the quieter ink, and so is one // that is written and does nothing: what is on the row is what the // widget is doing, not what the file says. @@ -325,17 +359,21 @@ void ALPropertyGrid::setGroups(std::vector groups) mRebuild.request(); } -void ALPropertyGrid::setFields(std::vector fields) +void ALPropertyGrid::order(std::vector& fields) const { - mFields = std::move(fields); // A heading the caller never named is the last one: a row has to be // somewhere, and a field filed past the end was not shown at all. const S32 last = (S32)mSections.size() - 1; - for (Field& field : mFields) + for (Field& field : fields) { field.group = last < 0 ? 0 : llclamp(field.group, 0, last); } - std::sort(mFields.begin(), mFields.end(), [](const Field& a, const Field& b) + if (mKeepsOrder) + { + std::stable_sort(fields.begin(), fields.end(), [](const Field& a, const Field& b) { return a.group < b.group; }); + return; + } + std::sort(fields.begin(), fields.end(), [](const Field& a, const Field& b) { // The headings first, in the order they were named; what the file // writes at the top of each, since that is what is being worked @@ -350,6 +388,132 @@ void ALPropertyGrid::setFields(std::vector fields) } return a.name < b.name; }); +} + +// Everything a row is built from, and nothing a row is refreshed with: the +// value, who wrote it, what the tip says and where a subject sits change +// under a kept row, and updateFields puts each of those right in place. +bool ALPropertyGrid::sameRow(const Field& a, const Field& b) +{ + return a.name == b.name && a.group == b.group && a.kind == b.kind && a.type == b.type && a.label == b.label + && a.values == b.values && a.valueLabels == b.valueLabels && a.components == b.components + && a.corners == b.corners && a.pad == b.pad && a.dial == b.dial && a.slider == b.slider + && a.bounded == b.bounded && a.minimum == b.minimum && a.maximum == b.maximum + && a.step == b.step && a.decimals == b.decimals + && a.edges == b.edges && a.flags == b.flags && a.allWord == b.allWord && a.noneWord == b.noneWord + && a.pairWith == b.pairWith && a.ignored == b.ignored && a.alsoWritten == b.alsoWritten; +} + +bool ALPropertyGrid::updateFields(std::vector fields) +{ + order(fields); + if (fields.size() != mFields.size()) + { + return false; + } + for (size_t i = 0; i < fields.size(); ++i) + { + if (!sameRow(fields[i], mFields[i])) + { + return false; + } + } + for (size_t i = 0; i < fields.size(); ++i) + { + Field& current = mFields[i]; + const Field& next = fields[i]; + const bool value_changed = current.value != next.value; + const bool authored_changed = current.authored != next.authored || current.source != next.source; + // The tip is made from these as well as from who wrote the value. + const bool said_changed = current.unknown != next.unknown || current.deprecated != next.deprecated + || current.instead != next.instead || current.description != next.description; + // Where the thing a follows row is about sits, which moves with + // every edit of its rect and is drawn rather than built. + const bool subject_changed = current.subject != next.subject || current.subjectParent != next.subjectParent; + current = std::move(fields[i]); + if (authored_changed || said_changed) + { + refreshAuthored(current); + } + if (value_changed || authored_changed) + { + refreshEditor(current); + } + if (subject_changed && !current.subjectParent.isEmpty()) + { + if (ALFollowsControl* follows = findChild(current.name, true)) + { + follows->setSubject(current.subject, current.subjectParent); + } + } + } + return true; +} + +void ALPropertyGrid::refreshEditor(const Field& field) +{ + LLUICtrl* editor = findChild(field.name, true); + // Being dragged or typed in: what it shows is what the hand is doing, + // and what the hand is doing is what the field is about to say. + if (editor && (gFocusMgr.childHasKeyboardFocus(editor) || gFocusMgr.childHasMouseCapture(editor) + || editor->hasFocus() || editor->hasMouseCapture())) + { + return; + } + if (!field.components.empty()) + { + const std::vector parts = numbersOf(field.value); + for (size_t i = 0; i < field.components.size(); ++i) + { + LLSpinCtrl* spin = findChild(field.name + "." + field.components[i], true); + // The same test as the whole editor's: one of the three being + // scrubbed keeps what the hand is doing. + if (spin && !gFocusMgr.childHasKeyboardFocus(spin) && !gFocusMgr.childHasMouseCapture(spin) + && !spin->hasFocus() && !spin->hasMouseCapture()) + { + spin->setValue(i < parts.size() ? numberOf(parts[i]) : 0.f); + spin->setUnset(!field.authored); + } + } + return; + } + if (!editor) + { + return; + } + if (LLSpinCtrl* spin = editor->as()) + { + spin->setValue(numberOf(field.value)); + spin->setUnset(!field.authored); + } + else if (LLSliderCtrl* slider = editor->as()) + { + slider->setValue(numberOf(field.value)); + } + else if (LLComboBox* combo = editor->as()) + { + combo->setValue(field.value); + combo->setUnset(!field.authored); + } + else if (LLCheckBoxCtrl* check = editor->as()) + { + check->setValue(field.value == "true" || field.value == "1"); + } + else if (LLLineEditor* line = editor->as()) + { + line->setText(field.authored ? field.value : LLStringUtil::null); + line->setLabel(field.authored ? LLStringUtil::null : field.value); + } + else + { + editor->setValue(field.value); + } +} + +void ALPropertyGrid::setFields(std::vector fields) +{ + mFields = std::move(fields); + order(mFields); // A section the file writes nothing in is every field the tag will take // and none that anyone chose, which is a page of grey to scroll past to @@ -362,7 +526,7 @@ void ALPropertyGrid::setFields(std::vector fields) // grid, and a grid of one section draws no heading, so there would be // nothing to unfold it with: a setting at its default showed a blank // strip and nothing else. Folding and headings are one decision. - const bool folds = mSections.size() > 1; + const bool folds = mFoldsUnwritten && mSections.size() > 1; for (size_t group = 0; group < mSections.size(); ++group) { const bool written = std::any_of(mFields.begin(), mFields.end(), @@ -395,6 +559,16 @@ void ALPropertyGrid::setAuthoredOnly(bool only) } } +void ALPropertyGrid::setFoldsUnwritten(bool folds) +{ + mFoldsUnwritten = folds; +} + +void ALPropertyGrid::setKeepsOrder(bool keeps) +{ + mKeepsOrder = keeps; +} + void ALPropertyGrid::setNested(bool nested) { if (mNested != nested) @@ -423,6 +597,31 @@ void ALPropertyGrid::setTips(Tips tips) mRebuild.request(); } +void ALPropertyGrid::setColorResolver(color_resolver_t resolver) +{ + mColorResolver = std::move(resolver); + mRebuild.request(); +} + +void ALPropertyGrid::setColorChoices(ALColorField::choices_t choices) +{ + mColorChoices = std::move(choices); + mRebuild.request(); +} + +void ALPropertyGrid::setImageChoices(ALImageField::choices_t choices) +{ + mImageChoices = std::move(choices); + mRebuild.request(); +} + +void ALPropertyGrid::setImageEditor(ALImageField::edit_t editor, std::string label) +{ + mImageEditor = std::move(editor); + mImageEditLabel = std::move(label); + mRebuild.request(); +} + void ALPropertyGrid::setEdgeTips(std::vector tips) { mEdgeTips = std::move(tips); @@ -460,7 +659,10 @@ bool ALPropertyGrid::shows(const Field& field) const { return false; } - return ALStringMatch::containsNoCase(field.name, mFilter); + // By the name a file writes or the words the row is called by: a + // person looking for the fill colour types either. + return ALStringMatch::containsNoCase(field.name, mFilter) + || (!field.label.empty() && ALStringMatch::containsNoCase(field.label, mFilter)); } // What the row is, then where what is in force came from, then -- where @@ -507,6 +709,18 @@ S32 ALPropertyGrid::rowHeight(const Field& field) const { return llmax(mRowHeight, PICTURE_HEIGHT); } + if (field.corners) + { + return llmax(mRowHeight, ALCornerField::HEIGHT); + } + if (field.pad) + { + return llmax(mRowHeight, ALOffsetPad::HEIGHT); + } + if (field.dial) + { + return llmax(mRowHeight, ALAngleDial::HEIGHT); + } // A row whose parts are captioned is a caption taller than one whose // value needs no saying which part it is -- and a colour is never // captioned, however its caller named its parts. @@ -693,10 +907,10 @@ void ALPropertyGrid::makeComponents(const Field& field, const LLRect& box, LLPan p.name = field.name + "." + field.components[i]; p.rect = LLRect(left, box.mTop - 1, left + each, box.mBottom + 1 + CAPTION_HEIGHT); p.label_width = 0; - p.decimal_digits = whole ? 0 : 3; - p.increment = whole ? 1.f : 0.1f; - p.min_value = field.kind == ALParamType::UNSIGNED ? 0.f : -100000.f; - p.max_value = 100000.f; + p.decimal_digits = decimalsOf(field, whole); + p.increment = stepOf(field, whole); + p.min_value = minimumOf(field); + p.max_value = maximumOf(field); p.initial_value = i < parts.size() ? numberOf(parts[i]) : 0.f; LLSpinCtrl* spin = LLUICtrlFactory::create(p); spin->setUnset(!field.authored); @@ -737,8 +951,38 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa { const std::string name = field.name; LLUICtrl* editor = nullptr; + const bool picture = field.corners || field.pad || field.dial; - if (field.kind == ALParamType::BOOLEAN) + if (field.corners) + { + ALCornerField::Params p; + p.name = name; + p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); + ALCornerField* corners = LLUICtrlFactory::create(p); + corners->setRange(minimumOf(field), maximumOf(field), stepOf(field, false), decimalsOf(field, false)); + corners->setValue(field.value); + editor = corners; + } + else if (field.pad) + { + ALOffsetPad::Params p; + p.name = name; + p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); + ALOffsetPad* pad = LLUICtrlFactory::create(p); + pad->setRange(field.bounded ? llmax(1.f, field.maximum) : 32.f, stepOf(field, false), decimalsOf(field, false)); + pad->setValue(field.value); + editor = pad; + } + else if (field.dial) + { + ALAngleDial::Params p; + p.name = name; + p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); + ALAngleDial* dial = LLUICtrlFactory::create(p); + dial->setValue(field.value); + editor = dial; + } + else if (field.kind == ALParamType::BOOLEAN) { LLCheckBoxCtrl::Params p; p.name = name; @@ -754,9 +998,36 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa p.name = name; p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); ALColorField* colour = LLUICtrlFactory::create(p); + if (mColorResolver) + { + colour->setResolver(mColorResolver); + } + if (mColorChoices) + { + colour->setChoices(mColorChoices); + } colour->setValue(field.value); editor = colour; } + else if (isImageType(field.type)) + { + // The other type known by sight: the picture beside its name, and + // the skin's pictures to choose from. + ALImageField::Params p; + p.name = name; + p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); + ALImageField* image = LLUICtrlFactory::create(p); + if (mImageChoices) + { + image->setChoices(mImageChoices); + } + if (mImageEditor) + { + image->setEditor(mImageEditor, mImageEditLabel); + } + image->setValue(field.value); + editor = image; + } else if (field.edges.size() == 4) { // Which edges of its parent a thing is tied to, drawn as what that @@ -820,9 +1091,19 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa { combo->add(field.value); } - for (const std::string& value : field.values) + // Shown by the label where there is one, chosen by the value + // either way: what commits is what a file writes. + const bool labelled = field.valueLabels.size() == field.values.size(); + for (size_t i = 0; i < field.values.size(); ++i) { - combo->add(value); + if (labelled) + { + combo->add(field.valueLabels[i], LLSD(field.values[i])); + } + else + { + combo->add(field.values[i]); + } } combo->setValue(field.value); // Nobody wrote this one: what is shown is what is in force, not a @@ -830,6 +1111,24 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa combo->setUnset(!field.authored); editor = combo; } + else if (field.slider && field.bounded && field.kind == ALParamType::REAL) + { + // A number chosen out of a range rather than typed: the slider is + // the range and the box beside it is the number. + LLSliderCtrl::Params p; + p.name = name; + p.rect = LLRect(box.mLeft, box.mTop - 1, box.mRight, box.mBottom + 1); + p.label_width = 0; + p.show_text = true; + p.can_edit_text = true; + p.text_width = 52; + p.decimal_digits = decimalsOf(field, false); + p.increment = stepOf(field, false); + p.min_value = field.minimum; + p.max_value = field.maximum; + p.initial_value = llclamp(numberOf(field.value), field.minimum, field.maximum); + editor = LLUICtrlFactory::create(p); + } else if (field.kind == ALParamType::INTEGER || field.kind == ALParamType::UNSIGNED || field.kind == ALParamType::REAL) { @@ -838,15 +1137,15 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa p.name = name; p.rect = LLRect(box.mLeft, box.mTop - 1, llmin(box.mRight, box.mLeft + NUMBER_WIDTH), box.mBottom + 1); p.label_width = 0; - p.decimal_digits = whole ? 0 : 3; + p.decimal_digits = decimalsOf(field, whole); // A whole number steps by one. The step is a tenth unless it is said // otherwise, and a field carrying no decimals rounds a tenth straight // back to the number it started at -- so every press of an arrow and // every notch of the wheel wrote back the number that was there, and // only the keyboard could change one. - p.increment = whole ? 1.f : 0.1f; - p.min_value = field.kind == ALParamType::UNSIGNED ? 0.f : -100000.f; - p.max_value = 100000.f; + p.increment = stepOf(field, whole); + p.min_value = minimumOf(field); + p.max_value = maximumOf(field); p.initial_value = numberOf(field.value); LLSpinCtrl* spin = LLUICtrlFactory::create(p); // Nobody wrote this one, so the number in force goes behind the box @@ -874,7 +1173,9 @@ LLUICtrl* ALPropertyGrid::makeEditor(const Field& field, const LLRect& box, LLPa // font, which answers by its parts above, since it is three fields and // a commit of the whole would write the name a second time. const bool whole = field.kind != ALParamType::REAL; - const ALParamType::EValue kind = field.kind; + // A picture answers with the numbers already spelled the way a file + // writes them. + const ALParamType::EValue kind = picture ? ALParamType::OTHER : field.kind; if (!editor->as()) { editor->setCommitCallback([this, name, kind, whole](LLUICtrl* ctrl, const LLSD&) @@ -1010,6 +1311,11 @@ void ALPropertyGrid::addRow(Rows* host, const Field& field, const Field* partner { makeEditor(field, LLRect(left, top, left + editor_width, 0), row); } + else if (field.corners || field.pad || field.dial) + { + // A picture of the value, as tall as the row was made for it. + makeEditor(field, LLRect(left, top, left + editor_width, bottom), row); + } else if (!field.components.empty() && !isColorType(field.type)) { // Several numbers, as the numbers they are. The row was made a @@ -1140,16 +1446,21 @@ void ALPropertyGrid::setAuthored(const std::string& name, bool authored, const s mRebuild.request(); return; } + refreshAuthored(*field); +} - // The row is this field's own, or the one it shares with the field it - // partners -- where the label is the other field's, the way back is - // both of theirs, and only the editor is this one's to tell. - const std::string tip = tipFor(*field); +// The row is this field's own, or the one it shares with the field it +// partners -- where the label is the other field's, the way back is +// both of theirs, and only the editor is this one's to tell. +void ALPropertyGrid::refreshAuthored(const Field& field) +{ + const std::string& name = field.name; + const std::string tip = tipFor(field); if (LLPanel* row = rowOf(name)) { if (LLTextBox* label = row->findChild(name + "_label")) { - label->setColor(inkFor(*field)); + label->setColor(inkFor(field)); label->setToolTip(tip); } for (LLView* child : *row->getChildList()) diff --git a/indra/llui/alpropertygrid.h b/indra/llui/alpropertygrid.h index 083e55eb59b..7067e224567 100644 --- a/indra/llui/alpropertygrid.h +++ b/indra/llui/alpropertygrid.h @@ -24,9 +24,14 @@ #pragma once +#include "alcolorfield.h" #include "aldeferredrebuild.h" +#include "alimagefield.h" #include "alparamtype.h" #include "llpanel.h" +#include "v4color.h" + +#include class ALEmptyState; class LLAccordionCtrl; @@ -91,6 +96,31 @@ class ALPropertyGrid : public LLPanel ALParamType::EValue kind = ALParamType::OTHER; // An enumeration's names. A field with any becomes a list. std::vector values; + // What the list shows for each of them, where a file's word is not + // the one a person would choose by. One per value, in order, or + // none and the values are shown as they are. + std::vector valueLabels; + // A number's range, where the vocabulary knows one: the box refuses + // what is outside it, and with `slider` the row is a slider over it + // with the number beside, which is how an opacity or a blur is + // chosen rather than typed. + bool bounded = false; + F32 minimum = 0.f; + F32 maximum = 0.f; + bool slider = false; + // How a number steps and how many decimals it shows. Unset, a whole + // number steps by one and shows none, and a real steps by a tenth + // and shows three. + F32 step = 0.f; + S32 decimals = -1; + // A value that is a picture as much as numbers, and gets the editor + // that draws it: four corner radii around a rounded rectangle, an + // offset as a dot on a pad, a direction as a handle on a dial. The + // value is still the numbers with spaces between, as a file writes + // them; the row is as tall as the picture. + bool corners = false; + bool pad = false; + bool dial = false; // The C++ type: it decides the colour editor, and it is the rest // of the row's tool tip. std::string type; @@ -163,6 +193,12 @@ class ALPropertyGrid : public LLPanel typedef boost::signals2::signal commit_signal_t; void setFields(std::vector fields); + // The same rows with new values: what each field says now replaces + // what it said, and every editor that is not being dragged or typed + // in shows it, but no row is rebuilt -- a slider mid-drag and a + // popover mid-pick keep the control they belong to. False when the + // rows would differ, and then setFields is the answer. + bool updateFields(std::vector fields); void clearFields(); const std::vector& fields() const { return mFields; } @@ -224,6 +260,20 @@ class ALPropertyGrid : public LLPanel // each field, because every such field means the same thing by them. void setEdgeTips(std::vector tips); + // Asked first what a colour's text comes to, on every colour row, for + // a caller whose files write colours colors.xml knows nothing about. + typedef std::function color_resolver_t; + void setColorResolver(color_resolver_t resolver); + // The caller's own colour names, offered first by every colour row's + // popover: a theme value, with the colour it comes to. + void setColorChoices(ALColorField::choices_t choices); + + // The pictures a picture row's popover offers, and the tool that edits + // one by name with what its button says. A grid given neither shows a + // picture row as its name and its picture, with no popover to open. + void setImageChoices(ALImageField::choices_t choices); + void setImageEditor(ALImageField::edit_t editor, std::string label); + boost::signals2::connection onFieldCommit(const commit_signal_t::slot_type& cb) { return mFieldCommit.connect(cb); @@ -265,6 +315,22 @@ class ALPropertyGrid : public LLPanel void setNested(bool nested); bool nested() const { return mNested; } + // A section nothing is written in arrives folded, so what is written + // is what is in front of you: right for a file of fields, most of + // them at their defaults. A grid whose every row is a choice somebody + // made -- a shape's layer, where nothing is a default -- turns this + // off, and every section arrives open. Set before the groups. + void setFoldsUnwritten(bool folds); + bool foldsUnwritten() const { return mFoldsUnwritten; } + + // Rows in the order they were given, within each section, instead of + // the written ones first and the rest by name: a vocabulary has an + // order of its own, and a row that jumps to the top of its section + // when it is first edited is a row that moves under the hand. Set + // before the fields. + void setKeepsOrder(bool keeps); + bool keepsOrder() const { return mKeepsOrder; } + // A row is laid out to the width it was given, so a change of width is // a re-layout of the rows and not a stretch of them. void reshape(S32 width, S32 height, bool called_from_parent = true) override; @@ -295,6 +361,14 @@ class ALPropertyGrid : public LLPanel }; void rebuild(); + // The fields in the order the rows take. + void order(std::vector& fields) const; + // Whether two fields would make the same row. + static bool sameRow(const Field& a, const Field& b); + // The row's ink and tips for what the field says about itself now. + void refreshAuthored(const Field& field); + // The editor showing what the field says now, unless it is being used. + void refreshEditor(const Field& field); void addRow(Rows* host, const Field& field, const Field* partner, bool shaded); // The way back, at the right end of the row; shown on the rows the // file writes. @@ -335,6 +409,11 @@ class ALPropertyGrid : public LLPanel std::vector mFields; std::vector mGroups; + color_resolver_t mColorResolver; + ALColorField::choices_t mColorChoices; + ALImageField::choices_t mImageChoices; + ALImageField::edit_t mImageEditor; + std::string mImageEditLabel; std::vector
mSections; LLAccordionCtrl* mAccordion = nullptr; ALEmptyState* mEmpty = nullptr; @@ -353,6 +432,8 @@ class ALPropertyGrid : public LLPanel S32 mRemoveWidth; bool mAuthoredOnly = false; bool mNested = false; + bool mFoldsUnwritten = true; + bool mKeepsOrder = false; // A rebuild deletes every row, so one asked for from inside a row's own // callback -- a caller answering a commit by filling the grid again, // which is what XUI Studio does after any edit it cannot apply in place diff --git a/indra/llui/alquickopen.cpp b/indra/llui/alquickopen.cpp index 0bd194c1501..7ee0995d289 100644 --- a/indra/llui/alquickopen.cpp +++ b/indra/llui/alquickopen.cpp @@ -48,6 +48,10 @@ namespace constexpr S32 SCORE_WORD_START = 2000; constexpr S32 SCORE_RUN = 1000; constexpr S32 SCORE_SCATTERED = 100; + // What the other words a candidate answers to score below what its + // label would for the same match: a tier. A label that answers is + // still what was typed, and the words are how else it is known. + constexpr S32 SCORE_WORDS_BELOW = 1000; char lower(char c) { @@ -175,10 +179,15 @@ S32 ALQuickOpen::score(std::string_view label, std::string_view query) std::vector ALQuickOpen::rank(const std::vector& candidates, std::string_view query) { + // By the label, or by the other words a tier down: a candidate + // answering both is scored by whichever answers better. std::vector > scored; for (size_t i = 0; i < candidates.size(); ++i) { - if (const S32 how = score(candidates[i].label, query); how > 0) + const S32 by_label = score(candidates[i].label, query); + const S32 by_words = score(candidates[i].also, query); + const S32 how = llmax(by_label, by_words > 0 ? llmax(1, by_words - SCORE_WORDS_BELOW) : 0); + if (how > 0) { scored.emplace_back(how, i); } diff --git a/indra/llui/alquickopen.h b/indra/llui/alquickopen.h index 825cf363183..99c6ab52286 100644 --- a/indra/llui/alquickopen.h +++ b/indra/llui/alquickopen.h @@ -65,6 +65,10 @@ class ALQuickOpen : public LLPanel // Said quietly beside the label: which folder, which file, which // skin. Not matched against, because a person types the name. std::string detail; + // Other words it answers to, scored a tier below what the label + // would score for the same match: what a thing is called in plain + // words, where the label is what a file calls it. + std::string also; std::string value; std::string icon; }; @@ -75,8 +79,9 @@ class ALQuickOpen : public LLPanel void setQuery(const std::string& query); const std::string& query() const { return mQuery; } - // The ranking, in order, best first. Public because it is the whole of - // this widget and the only part worth testing without a screen. + // The ranking, in order, best first: by the label, or by the other + // words a tier down. Public because it is the whole of this widget + // and the only part worth testing without a screen. static std::vector rank(const std::vector& candidates, std::string_view query); diff --git a/indra/llui/alsettingrow.cpp b/indra/llui/alsettingrow.cpp new file mode 100644 index 00000000000..2dc0a30face --- /dev/null +++ b/indra/llui/alsettingrow.cpp @@ -0,0 +1,272 @@ +/** + * @file alsettingrow.cpp + * @brief One setting on one line: its name, a slider, its value, and the way back to its default. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alsettingrow.h" + +#include "llbutton.h" +#include "llcontrol.h" +#include "llfontgl.h" +#include "llsdutil.h" +#include "llsliderctrl.h" +#include "lltextbox.h" +#include "lltrans.h" +#include "lluictrlfactory.h" + +#include + +#include + +static LLDefaultChildRegistry::Register r("setting_row"); + +namespace +{ +// How many digits the whole part of a number takes, at least one. +S32 wholeDigits(F32 value) +{ + const F32 magnitude = std::fabs(value); + return magnitude < 1.f ? 1 : (S32)std::log10(magnitude) + 1; +} + +// A value box wide enough for the widest number between min and max, at the +// decimals shown: the digits of the wider end, the point, a sign where the +// range goes below zero, and the slider's own padding. +S32 valueBoxWidth(const LLFontGL* font, F32 min_value, F32 max_value, S32 decimal_digits) +{ + S32 width = font->getWidth("0") * (llmax(wholeDigits(min_value), wholeDigits(max_value)) + decimal_digits); + if (decimal_digits > 0) + { + width += font->getWidth("."); + } + if (min_value < 0.f) + { + width += font->getWidth("-"); + } + return width + 8; +} +} // namespace + +ALSettingRow::Params::Params() +: label_width("label_width", 140), + min_value("min_val", 0.f), + max_value("max_val", 1.f), + increment("increment", 0.1f), + decimal_digits("decimal_digits", 3), + text_width("text_width"), + show_text("show_text", true), + can_edit_text("can_edit_text", true), + show_reset("show_reset", true), + mouse_down_callback("mouse_down_callback"), + mouse_up_callback("mouse_up_callback") +{ + changeDefault(follows.flags, FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT); + // The slider's value box and the reset button take the keyboard; the row + // around them is nowhere to stop. + changeDefault(tab_stop, false); +} + +ALSettingRow::ALSettingRow(const Params& p) +: LLUICtrl(p), + mLabel(p.label()), + mDecimalDigits(p.decimal_digits), + mShowReset(p.show_reset) +{ + const S32 width = getRect().getWidth(); + const S32 height = getRect().getHeight(); + + // From the skin's slider, so it has the fonts, colours and images every + // other slider has. It keeps its own label, which is what keeps the gap + // between label and track, and the label's grey when disabled, exactly + // as a slider has them. + LLSliderCtrl::Params sp(LLUICtrlFactory::getDefaultParams()); + sp.name = "setting_row_slider"; + // A pixel in at the top and bottom: the row is as tall as its reset + // button, and that is where a slider sat beside one. + sp.rect = LLRect(0, height - 1, width - RESET_SIZE - RESET_GAP, 1); + sp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT; + // Values, not params: a param copied from one block to another carries + // whether it was provided, and a row that left one out would hand the + // slider an unprovided param -- which the slider fills in its own way, + // measuring its label for a width instead of taking 140, and following + // the skin's template for whether its value can be typed. + sp.label = p.label(); + sp.label_width = p.label_width(); + sp.min_value = p.min_value(); + sp.max_value = p.max_value(); + sp.increment = p.increment(); + sp.decimal_digits = p.decimal_digits(); + sp.show_text = p.show_text(); + sp.can_edit_text = p.can_edit_text(); + if (p.text_width.isProvided()) + { + sp.text_width = p.text_width(); + } + else if (p.show_text && (p.max_value < 1.f || -p.min_value > p.max_value)) + { + // The slider sizes its value box from the maximum's logarithm, which + // has no room for the leading zero under 1, nothing at all at 0 or + // below, and nothing for a minimum wider than the maximum. Anywhere + // else it gets it right, and is left to. + sp.text_width = valueBoxWidth(sp.font(), p.min_value, p.max_value, p.decimal_digits); + } + sp.commit_callback.function([this](LLUICtrl*, const LLSD&) { onSliderCommit(); }); + // Handed on as params, and only when said: a callback a file names is + // resolved by the slider that fires it, against the registrar in scope + // while the row is being built -- which is this constructor. + if (p.mouse_down_callback.isProvided()) + { + sp.mouse_down_callback(p.mouse_down_callback); + } + if (p.mouse_up_callback.isProvided()) + { + sp.mouse_up_callback(p.mouse_up_callback); + } + mSlider = LLUICtrlFactory::create(sp); + addChild(mSlider); + + LLButton::Params bp(LLUICtrlFactory::getDefaultParams()); + bp.name = "setting_row_reset"; + bp.rect = LLRect(width - RESET_SIZE, height, width, height - RESET_SIZE); + bp.follows.flags = FOLLOWS_TOP | FOLLOWS_RIGHT; + bp.label = LLStringUtil::null; + bp.scale_image = true; + bp.visible = false; + bp.click_callback.function([this](LLUICtrl*, const LLSD&) { onResetClicked(); }); + mReset = LLUICtrlFactory::create(bp); + // By name after construction: the overlay param wants an image, and this + // resolves the skin's. + mReset->setImageOverlay("Refresh_Off", LLFontGL::HCENTER); + addChild(mReset); +} + +void ALSettingRow::setValue(const LLSD& value) +{ + // What the setting says, from wherever it was changed: the slider shows + // it without committing, and the reset button follows it. + mSlider->setValue((F32)value.asReal()); + refreshReset(); +} + +LLSD ALSettingRow::getValue() const +{ + return LLSD(mSlider->getValueF32()); +} + +void ALSettingRow::setEnabled(bool enabled) +{ + LLUICtrl::setEnabled(enabled); + mSlider->setEnabled(enabled); + mReset->setEnabled(enabled); +} + +void ALSettingRow::setFocus(bool focus) +{ + mSlider->setFocus(focus); +} + +LLTextBox* ALSettingRow::getLabelBox() const +{ + // The slider made it from the label the row was given. + return mLabel.empty() ? nullptr : mSlider->getLabelBox(); +} + +bool ALSettingRow::isModified() +{ + LLControlVariable* control = getControlVariable(); + if (!control || control->isDefault()) + { + return false; + } + const LLSD value = control->getValue(); + const LLSD fallback = control->getDefault(); + if ((value.isReal() || value.isInteger()) && (fallback.isReal() || fallback.isInteger())) + { + // As shown: a value that rounds to the default's digits is the + // default as far as anyone looking can tell. + const F64 scale = std::pow(10.0, (F64)llmax(mDecimalDigits, 0)); + return std::llround(value.asReal() * scale) != std::llround(fallback.asReal() * scale); + } + return !llsd_equals(value, fallback); +} + +std::string ALSettingRow::_getSearchText() const +{ + return mLabel + getToolTip(); +} + +void ALSettingRow::onSetHighlight() const +{ + // The slider lights its label, which is the row's name. + mSlider->setHighlighted(getHighlighted()); +} + +void ALSettingRow::onSliderCommit() +{ + // The row is what is bound, so the row writes the setting -- once -- and + // then says it changed, as any control does. + setControlValue(LLSD(mSlider->getValueF32())); + refreshReset(); + onCommit(); +} + +void ALSettingRow::onResetClicked() +{ + if (mResetHandler) + { + mResetHandler(this); + return; + } + // The setting's own reset, which fires the setting's signal: the slider + // and the button come back through setValue like any other change, and + // whatever listens to the setting hears it. + if (LLControlVariable* control = getControlVariable()) + { + control->resetToDefault(true); + } +} + +void ALSettingRow::refreshReset() +{ + const bool show = mShowReset && getControlVariable() && isModified(); + if (show == mResetShown) + { + return; + } + mResetShown = show; + + if (show) + { + LLStringUtil::format_map_t args; + args["[VALUE]"] = fmt::format("{:.{}f}", getControlVariable()->getDefault().asReal(), llmax(mDecimalDigits, 0)); + mReset->setToolTip(LLTrans::getString("SettingRowReset", args)); + } + else if (mReset->hasFocus()) + { + // A button that goes away with the keyboard in it would leave the + // keyboard nowhere; the slider beside it is where it was pointing. + mSlider->setFocus(true); + } + mReset->setVisible(show); +} diff --git a/indra/llui/alsettingrow.h b/indra/llui/alsettingrow.h new file mode 100644 index 00000000000..2e90fc5da10 --- /dev/null +++ b/indra/llui/alsettingrow.h @@ -0,0 +1,134 @@ +/** + * @file alsettingrow.h + * @brief One setting on one line: its name, a slider, its value, and the way back to its default. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "lluictrl.h" +#include "llsearchablecontrol.h" + +#include + +class LLButton; +class LLSliderCtrl; +class LLTextBox; + +// One setting on one line: a slider with its label and value, and a reset +// button that brings the setting back to its default. It is what a settings +// floater writes as a slider and a separate reset button that repeats the +// slider's setting name -- a pair that has to be laid out against each other +// by hand, kept in step by hand, and greyed together by hand. +// +// The row is the only thing bound: control_name binds the row, and the slider +// inside it is not bound to anything. So a walk looking for what a panel's +// controls are bound to finds one setting per row, the setting is written +// once per commit, and a change to the setting from anywhere reaches the +// slider through the row. +// +// The reset button shows only while the setting differs from its default as +// far as the row can show -- rounded to the decimals the value is shown to, so +// a drag that comes back to 0.7 and lands on 0.69999999 is at the default -- +// and its place is kept while it is hidden, so nothing moves when it appears. +// It resets the setting directly and does not fire the row's commit: what +// cares about the setting changing listens to the setting. +class ALSettingRow final : public LLUICtrl, public ll::ui::SearchableControl +{ +public: + AL_VIEW_TYPE(ALSettingRow, LLUICtrl); + + // The slider's own parameters, by the names a slider writes them, so a + // slider row becomes a setting row by changing its tag. + struct Params : public LLInitParam::Block + { + Optional label_width; + Optional min_value; + Optional max_value; + Optional increment; + Optional decimal_digits; + // Left out, the value box is as wide as the slider would make it -- + // except where the slider's own sizing would cut the number short, + // where the row works out a width that fits. + Optional text_width; + Optional show_text; + Optional can_edit_text; + Optional show_reset; + // The slider's press and release, by the names a slider writes them. A + // setting that is dear to apply can be told a drag has begun and ended + // and leave its work for the release; the commits in between still + // write the setting as any row's do. + Optional mouse_down_callback, + mouse_up_callback; + Params(); + }; + + void setValue(const LLSD& value) override; + LLSD getValue() const override; + void setEnabled(bool enabled) override; + // The keyboard goes to the slider: the row itself has nothing to type + // into and no key to answer. + void setFocus(bool focus) override; + + // What the row is called, and the text box that shows it: for a caller + // that names a setting after its row, or lights the name up. + const std::string& getLabel() const { return mLabel; } + LLTextBox* getLabelBox() const; + + // Whether the setting differs from its default as far as the row shows + // it. False for a row bound to nothing. + bool isModified(); + + // Hand the reset to the row's owner: when set, the reset button calls + // this instead of resetting the setting itself. For an owner that has + // more to do around a reset than the reset -- naming it as an undo step, + // say. Unset, the row resets the setting on its own. + typedef std::function reset_handler_t; + void setResetHandler(reset_handler_t handler) { mResetHandler = std::move(handler); } + + LLSliderCtrl* getSlider() const { return mSlider; } + LLButton* getResetButton() const { return mReset; } + + // The widths the row lays its parts out by: the reset button's square, + // and the gap between it and the slider's value box. + static constexpr S32 RESET_SIZE = 18; + static constexpr S32 RESET_GAP = 6; + +protected: + friend class LLUICtrlFactory; + ALSettingRow(const Params& p); + + std::string _getSearchText() const override; + void onSetHighlight() const override; + +private: + void onSliderCommit(); + void onResetClicked(); + void refreshReset(); + + LLSliderCtrl* mSlider = nullptr; + LLButton* mReset = nullptr; + reset_handler_t mResetHandler; + std::string mLabel; + S32 mDecimalDigits = 3; + bool mShowReset = true; + bool mResetShown = false; +}; diff --git a/indra/llui/alspecimenlist.cpp b/indra/llui/alspecimenlist.cpp index 82d6109f792..417f27cbc6d 100644 --- a/indra/llui/alspecimenlist.cpp +++ b/indra/llui/alspecimenlist.cpp @@ -1,6 +1,6 @@ /** * @file alspecimenlist.cpp - * @brief A list whose rows are the widgets themselves rather than words about them. + * @brief A list whose rows are the things themselves rather than words about them. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -30,8 +30,11 @@ #include "alstringmatch.h" #include "llfocusmgr.h" #include "llfontgl.h" +#include "lliconctrl.h" +#include "llrender2dutils.h" #include "llscrollcontainer.h" #include "lltextbox.h" +#include "lltooltip.h" #include "lluicolortable.h" #include "lluictrlfactory.h" #include "llui.h" @@ -46,6 +49,17 @@ namespace constexpr S32 HEADING_HEIGHT = 20; constexpr S32 INSET = 6; constexpr S32 GAP = 8; + // A cell's own margins, and the strip its name sits in. + constexpr S32 CELL_PAD = 4; + constexpr S32 CAPTION_HEIGHT = 14; + + // Whether a press has moved far enough to be a drag. + bool dragged(S32 x, S32 y, S32 from_x, S32 from_y) + { + const S32 dx = x - from_x; + const S32 dy = y - from_y; + return dx * dx + dy * dy > DRAG_N_DROP_DISTANCE_THRESHOLD * DRAG_N_DROP_DISTANCE_THRESHOLD; + } } // One row: a label, and the thing itself beside it. @@ -96,9 +110,7 @@ class ALSpecimenList::Row final : public LLPanel { return LLPanel::handleHover(x, y, mask); } - const S32 dx = x - mPressX; - const S32 dy = y - mPressY; - if (dx * dx + dy * dy > DRAG_N_DROP_DISTANCE_THRESHOLD * DRAG_N_DROP_DISTANCE_THRESHOLD) + if (dragged(x, y, mPressX, mPressY)) { gFocusMgr.setMouseCapture(nullptr); mList.startDrag(mValue); @@ -128,9 +140,104 @@ class ALSpecimenList::Row final : public LLPanel S32 mPressY = 0; }; +// The pane the cells are drawn on: it draws them and takes the clicks, and +// the list owns the entries and the layout. Cells are drawn rather than +// built, since there are hundreds and a picture needs no widget. +class ALSpecimenList::Tiles final : public LLPanel +{ +public: + AL_VIEW_TYPE(ALSpecimenList::Tiles, LLPanel); + + Tiles(const LLPanel::Params& p, ALSpecimenList& list) : LLPanel(p), mList(list) {} + + void draw() override + { + for (const Cell& cell : mList.mCells) + { + mList.drawCell(cell, cell.index >= 0 && cell.index == mHovered); + } + LLPanel::draw(); + } + + bool handleMouseDown(S32 x, S32 y, MASK mask) override + { + const S32 index = mList.cellAt(x, y); + if (index < 0) + { + return LLPanel::handleMouseDown(x, y, mask); + } + // The keyboard comes with the click, so the arrows work from here; + // the press is held, so one that goes on to move is a drag -- of + // what was pressed, which a control or shift click leaves short of + // being the chosen one. + mList.setFocus(true); + mList.cellPressed(index, mask); + mPressed = mList.mSpecimens[index].value; + mPressX = x; + mPressY = y; + gFocusMgr.setMouseCapture(this); + return true; + } + + bool handleMouseUp(S32 x, S32 y, MASK mask) override + { + if (hasMouseCapture()) + { + gFocusMgr.setMouseCapture(nullptr); + return true; + } + return LLPanel::handleMouseUp(x, y, mask); + } + + bool handleHover(S32 x, S32 y, MASK mask) override + { + if (hasMouseCapture()) + { + if (dragged(x, y, mPressX, mPressY)) + { + gFocusMgr.setMouseCapture(nullptr); + mList.startDrag(mPressed); + } + return true; + } + mHovered = mList.cellAt(x, y); + return LLPanel::handleHover(x, y, mask); + } + + void onMouseLeave(S32 x, S32 y, MASK mask) override + { + mHovered = -1; + LLPanel::onMouseLeave(x, y, mask); + } + + bool handleToolTip(S32 x, S32 y, MASK mask) override + { + const S32 index = mList.cellAt(x, y); + if (index < 0 || mList.mSpecimens[index].toolTip.empty()) + { + return LLPanel::handleToolTip(x, y, mask); + } + const auto cell = std::find_if(mList.mCells.begin(), mList.mCells.end(), + [&](const Cell& c) { return c.index == index; }); + LLRect sticky; + localRectToScreen(cell->rect, &sticky); + LLToolTipMgr::instance().show(LLToolTip::Params().message(mList.mSpecimens[index].toolTip).sticky_rect(sticky)); + return true; + } + +private: + ALSpecimenList& mList; + S32 mHovered = -1; + std::string mPressed; + S32 mPressX = 0; + S32 mPressY = 0; +}; + ALSpecimenList::Params::Params() : row_height("row_height", 30), label_width("label_width", 120), + cell_width("cell_width", 0), + cell_height("cell_height", 0), empty_headline("empty_headline"), empty_sentence("empty_sentence") { @@ -141,12 +248,15 @@ ALSpecimenList::ALSpecimenList(const Params& p) mEmptyHeadline(p.empty_headline), mEmptySentence(p.empty_sentence), mRowHeight(p.row_height), - mLabelWidth(p.label_width) + mLabelWidth(p.label_width), + mCellWidth(p.cell_width), + mCellHeight(p.cell_height) { LLScrollContainer::Params sp(LLUICtrlFactory::getDefaultParams()); sp.name = "scroller"; sp.rect = getLocalRect(); sp.follows.flags = FOLLOWS_ALL; + sp.reserve_scroll_corner = false; mScroller = LLUICtrlFactory::create(sp); addChild(mScroller); @@ -155,7 +265,11 @@ ALSpecimenList::ALSpecimenList(const Params& p) cp.rect = LLRect(0, getRect().getHeight(), getRect().getWidth(), 0); cp.background_visible = false; cp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT; - mContent = LLUICtrlFactory::create(cp); + mRowsPane = LLUICtrlFactory::create(cp); + cp.name = "tiles"; + mTiles = new Tiles(cp, *this); + mTiles->initFromParams(cp); + mContent = cells() ? static_cast(mTiles) : mRowsPane; mScroller->addChild(mContent); ALEmptyState::Params ep(LLUICtrlFactory::getDefaultParams()); @@ -169,23 +283,77 @@ ALSpecimenList::ALSpecimenList(const Params& p) mEmpty->setVisible(false); } -ALSpecimenList::~ALSpecimenList() = default; +ALSpecimenList::~ALSpecimenList() +{ + // The views are children of a row or the tiles and go with them; a + // view never placed is nobody's but this list's. The layout that is + // not showing is nobody's child either. + for (const Specimen& specimen : mSpecimens) + { + if (specimen.view && !specimen.view->getParent()) + { + delete specimen.view; + } + } + if (mRowsPane && !mRowsPane->getParent()) + { + delete mRowsPane; + } + if (mTiles && !mTiles->getParent()) + { + delete mTiles; + } +} + +void ALSpecimenList::setEmpty(const std::string& headline, const std::string& sentence) +{ + mEmptyHeadline = headline; + mEmptySentence = sentence; + mEmpty->say(headline, sentence); +} -void ALSpecimenList::setSpecimens(std::vector specimens) +// A view out of wherever it sits: a row is a home for a view, not its +// owner, and so are the tiles. +static void takeOut(LLView* view) +{ + if (view && view->getParent()) + { + view->getParent()->removeChild(view); + } +} + +// The rows and the headings taken down, and every view out. +void ALSpecimenList::unbuild() { + for (const Specimen& specimen : mSpecimens) + { + takeOut(specimen.view); + } for (Row* row : mRows) { - mContent->removeChild(row); + mRowsPane->removeChild(row); delete row; } mRows.clear(); for (LLTextBox* heading : mHeadings) { - mContent->removeChild(heading); + mRowsPane->removeChild(heading); delete heading; } mHeadings.clear(); + mRowsBuilt = false; + mCells.clear(); +} + +void ALSpecimenList::setSpecimens(std::vector specimens) +{ + unbuild(); + for (const Specimen& specimen : mSpecimens) + { + delete specimen.view; + } mGroups.clear(); + mImages.clear(); mSpecimens = std::move(specimens); // The headings, in the order they first appear: which things belong @@ -199,6 +367,83 @@ void ALSpecimenList::setSpecimens(std::vector specimens) mGroups.push_back(specimen.group); } } + for (const Specimen& specimen : mSpecimens) + { + mImages.push_back(specimen.image.empty() ? LLPointer() : LLUI::getUIImage(specimen.image)); + } + placeViews(); +} + +// The other layout: the views out of this one and into the other, and +// the container given the other to scroll. Both layouts stay built, so +// the switch costs the frame and nothing else. +void ALSpecimenList::setCellSize(S32 width, S32 height) +{ + const bool were_cells = cells(); + mCellWidth = llmax(0, width); + mCellHeight = llmax(0, height); + if (were_cells == cells()) + { + layout(); + return; + } + for (const Specimen& specimen : mSpecimens) + { + takeOut(specimen.view); + } + mScroller->removeChild(mContent); + mContent = cells() ? static_cast(mTiles) : mRowsPane; + mScroller->addChild(mContent); + placeViews(); +} + +// The views into whichever layout is showing: each into its cell, over +// the cell's own drawing, or into its row beside the label. +void ALSpecimenList::placeViews() +{ + if (cells()) + { + for (const Specimen& specimen : mSpecimens) + { + if (specimen.view) + { + specimen.view->setVisible(false); + mTiles->addChild(specimen.view); + } + } + layout(); + return; + } + + buildRows(); + for (size_t i = 0; i < mSpecimens.size() && i < mRows.size(); ++i) + { + LLView* view = mSpecimens[i].view; + if (!view) + { + continue; + } + // Beside the label, at its own height where that fits: a view + // that was in a cell a moment ago is placed afresh. + const S32 tall = llmin(mRowHeight - 8, view->getRect().getHeight()); + const S32 bottom = (mRowHeight - tall) / 2; + view->setShape(LLRect(INSET + mLabelWidth + 4, bottom + tall, getRect().getWidth() - INSET, bottom)); + view->setVisible(true); + mRows[i]->addChild(view); + } + layout(); +} + +// One row per entry with its label, and a heading per group, made once +// for the entries held and kept through a change of layout. +void ALSpecimenList::buildRows() +{ + if (mRowsBuilt) + { + return; + } + mRowsBuilt = true; + for (const std::string& group : mGroups) { LLTextBox::Params tp(LLUICtrlFactory::getDefaultParams()); @@ -207,7 +452,7 @@ void ALSpecimenList::setSpecimens(std::vector specimens) tp.rect = LLRect(0, HEADING_HEIGHT, getRect().getWidth(), 0); tp.font = LLFontGL::getFontSansSerifSmallBold(); LLTextBox* made = LLUICtrlFactory::create(tp); - mContent->addChild(made); + mRowsPane->addChild(made); mHeadings.push_back(made); } @@ -231,14 +476,28 @@ void ALSpecimenList::setSpecimens(std::vector specimens) lp.use_ellipses = true; row->addChild(LLUICtrlFactory::create(lp)); - if (specimen.view) + if (!specimen.view && !specimen.image.empty()) { - row->addChild(specimen.view); + // A picture is shown beside the label the way a view would be, + // fitted to the row: across the rest of it where it is chrome, + // else at the row's height and twice as wide. The row's own + // rather than the specimen's view, which is the caller's to + // give: a cell draws the picture itself, and one that has been + // a row draws it the same way as one that has not. + LLIconCtrl::Params ip; + ip.name = "picture"; + ip.image = LLUI::getUIImage(specimen.image); + const S32 tall = mRowHeight - 8; + const S32 left = INSET + mLabelWidth + 4; + const S32 right = specimen.stretch ? getRect().getWidth() - INSET : left + tall * 2; + ip.rect = LLRect(left, 4 + tall, right, 4); + ip.follows.flags = specimen.stretch ? (FOLLOWS_LEFT | FOLLOWS_TOP | FOLLOWS_RIGHT) + : (FOLLOWS_LEFT | FOLLOWS_TOP); + row->addChild(LLUICtrlFactory::create(ip)); } - mContent->addChild(row); + mRowsPane->addChild(row); mRows.push_back(row); } - layout(); } bool ALSpecimenList::passes(const Specimen& specimen) const @@ -254,13 +513,125 @@ void ALSpecimenList::filter(const std::string& text) } void ALSpecimenList::choose(const std::string& value) +{ + setChosen(value); + mChose(value); +} + +void ALSpecimenList::setChosen(const std::string& value) { mChosen = value; + mSelection = value.empty() ? std::vector() : std::vector{ value }; for (Row* row : mRows) { row->setMarked(row->value() == value); } - mChose(value); + showChosen(); + mSelectionChanged(); +} + +void ALSpecimenList::setSelection(std::vector values) +{ + mSelection = std::move(values); + if (!mChosen.empty() && std::find(mSelection.begin(), mSelection.end(), mChosen) == mSelection.end()) + { + mSelection.push_back(mChosen); + } + mSelectionChanged(); +} + +// A plain press chooses, and the selection is that one thing; control +// adds or takes the pressed one out, leaving what is chosen alone unless +// it was the pressed one; shift takes the run from the chosen one to the +// pressed one, in the order shown. +void ALSpecimenList::cellPressed(S32 index, MASK mask) +{ + const std::string& value = mSpecimens[index].value; + + if (mask & MASK_CONTROL) + { + const auto found = std::find(mSelection.begin(), mSelection.end(), value); + if (found != mSelection.end() && value != mChosen) + { + mSelection.erase(found); + } + else if (found == mSelection.end()) + { + mSelection.push_back(value); + if (mChosen.empty()) + { + mChosen = value; + mChose(value); + } + } + mSelectionChanged(); + return; + } + + if ((mask & MASK_SHIFT) && !mChosen.empty()) + { + const std::vector order = shownOrder(); + const auto from = std::find_if(order.begin(), order.end(), [&](S32 i) { return mSpecimens[i].value == mChosen; }); + const auto to = std::find(order.begin(), order.end(), index); + if (from != order.end() && to != order.end()) + { + mSelection.clear(); + for (auto it = llmin(from, to); it <= llmax(from, to); ++it) + { + mSelection.push_back(mSpecimens[*it].value); + } + mSelectionChanged(); + return; + } + } + + choose(value); +} + +// The chosen cell scrolled into view, in the pane's own coordinates, +// which is what the container scrolls in. +void ALSpecimenList::showChosen() +{ + const S32 cell = chosenCell(); + if (cell >= 0 && mScroller) + { + mScroller->scrollToShowRect(mCells[cell].rect); + } +} + +// The entries under no heading first, then each heading and what is under +// it, leaving out what the filter has narrowed away. +std::vector ALSpecimenList::shownOrder() const +{ + std::vector order; + const auto sweep = [&](const std::string& group) + { + for (size_t i = 0; i < mSpecimens.size(); ++i) + { + if (mSpecimens[i].group == group && passes(mSpecimens[i])) + { + order.push_back((S32)i); + } + } + }; + sweep(std::string()); + for (const std::string& group : mGroups) + { + sweep(group); + } + return order; +} + +void ALSpecimenList::layout() +{ + if (cells()) + { + layoutCells(); + } + else + { + layoutRows(); + } } // Down the list: rows under no heading first, then each heading and what is @@ -268,9 +639,9 @@ void ALSpecimenList::choose(const std::string& value) // what it held, and a row the filter has narrowed away is hidden rather than // destroyed -- building a widget is the expensive part, and a filter is typed // a letter at a time. -void ALSpecimenList::layout() +void ALSpecimenList::layoutRows() { - if (!mContent || !mScroller) + if (!mRowsPane || !mScroller || mContent != mRowsPane) { return; } @@ -324,8 +695,8 @@ void ALSpecimenList::layout() total += one.height; } const S32 tall = llmax(total, mScroller->getContentWindowRect().getHeight()); - mContent->reshape(width, tall); - mContent->setOrigin(0, 0); + mRowsPane->reshape(width, tall); + mRowsPane->setOrigin(0, 0); S32 top = tall; for (const Placed& one : order) @@ -339,6 +710,283 @@ void ALSpecimenList::layout() mScroller->setVisible(mShown > 0); } +// Rows of cells from the top, a heading row wherever the heading changes, +// and the pane made as tall as the rows come to. +void ALSpecimenList::layoutCells() +{ + if (!mTiles || !mScroller || mContent != mTiles) + { + return; + } + const S32 width = llmax(1, mScroller->getContentWindowRect().getWidth()); + const S32 columns = llmax(1, width / mCellWidth); + const std::vector order = shownOrder(); + + mCells.clear(); + mShown = order.size(); + + S32 y = 0; + S32 column = 0; + std::string heading; + bool first = true; + + for (const S32 index : order) + { + if (first || mSpecimens[index].group != heading) + { + heading = mSpecimens[index].group; + first = false; + if (column != 0) + { + y += mCellHeight; + column = 0; + } + if (!heading.empty()) + { + Cell cell; + cell.rect = LLRect(0, -y, width, -(y + HEADING_HEIGHT)); + cell.heading = heading; + mCells.push_back(cell); + y += HEADING_HEIGHT; + } + } + + Cell cell; + const S32 left = column * mCellWidth; + cell.rect = LLRect(left, -y, left + mCellWidth, -(y + mCellHeight)); + cell.index = index; + mCells.push_back(cell); + + if (++column == columns) + { + column = 0; + y += mCellHeight; + } + } + + if (column != 0) + { + y += mCellHeight; + } + + const S32 height = llmax(y, mScroller->getContentWindowRect().getHeight()); + + // Cells were laid out from the top down as negative offsets; the pane + // is as tall as they come to, with its top at its own height. + for (Cell& cell : mCells) + { + cell.rect.translate(0, height); + } + + mTiles->reshape(width, height); + mTiles->setOrigin(0, mScroller->getContentWindowRect().getHeight() - height); + + // A built view goes in its cell, at its own height where that fits, + // over the strip its name sits in; one the filter narrowed away is + // hidden. + for (const Specimen& specimen : mSpecimens) + { + if (specimen.view) + { + specimen.view->setVisible(false); + } + } + for (const Cell& cell : mCells) + { + LLView* view = cell.index >= 0 ? mSpecimens[cell.index].view : nullptr; + if (!view) + { + continue; + } + const LLRect area(cell.rect.mLeft + 2 + CELL_PAD, cell.rect.mTop - 2 - CELL_PAD, + cell.rect.mRight - 2 - CELL_PAD, cell.rect.mBottom + 2 + CAPTION_HEIGHT + CELL_PAD); + const S32 tall = llmin(area.getHeight(), view->getRect().getHeight()); + const S32 bottom = area.mBottom + (area.getHeight() - tall) / 2; + view->setShape(LLRect(area.mLeft, bottom + tall, area.mRight, bottom)); + view->setVisible(true); + } + + mEmpty->setVisible(mShown == 0); + mScroller->setVisible(mShown > 0); +} + +S32 ALSpecimenList::cellAt(S32 x, S32 y) const +{ + for (const Cell& cell : mCells) + { + if (cell.index >= 0 && cell.rect.pointInRect(x, y)) + { + return cell.index; + } + } + return -1; +} + +S32 ALSpecimenList::chosenCell() const +{ + for (size_t i = 0; i < mCells.size(); ++i) + { + if (mCells[i].index >= 0 && mSpecimens[mCells[i].index].value == mChosen) + { + return (S32)i; + } + } + return -1; +} + +void ALSpecimenList::drawCell(const Cell& cell, bool hovered) const +{ + static const LLUIColor heading_color = LLUIColorTable::instance().getColor("LabelTextColor", LLColor4::white); + static const LLUIColor caption_color = LLUIColorTable::instance().getColor("LabelDisabledColor", LLColor4::grey); + static const LLUIColor selected_color = LLUIColorTable::instance().getColor("EmphasisColor", LLColor4::yellow); + static const LLUIColor hover_color = LLUIColorTable::instance().getColor("DkGray", LLColor4::grey); + static const LLUIColor mark_color = LLUIColorTable::instance().getColor("EmphasisColor", LLColor4::yellow); + static const LLUIColor broken_color = LLUIColorTable::instance().getColor("LtOrange", LLColor4::red); + + const LLFontGL* small = LLFontGL::getFontSansSerifSmall(); + + if (cell.index < 0) + { + small->renderUTF8(cell.heading, 0, F32(CELL_PAD), F32(cell.rect.mBottom) + (HEADING_HEIGHT - small->getLineHeight()) / 2.f, + heading_color.get(), LLFontGL::LEFT, LLFontGL::BOTTOM, LLFontGL::BOLD, LLFontGL::NO_SHADOW); + gl_line_2d(CELL_PAD, cell.rect.mBottom + 1, cell.rect.mRight - CELL_PAD, cell.rect.mBottom + 1, heading_color.get() % 0.3f); + return; + } + + const Specimen& specimen = mSpecimens[cell.index]; + const bool chosen = specimen.value == mChosen; + const bool selected = !chosen && std::find(mSelection.begin(), mSelection.end(), specimen.value) != mSelection.end(); + const LLRect box(cell.rect.mLeft + 2, cell.rect.mTop - 2, cell.rect.mRight - 2, cell.rect.mBottom + 2); + + if (chosen || selected || hovered) + { + gl_rect_2d(box, chosen ? selected_color.get() % 0.35f : selected ? selected_color.get() % 0.18f : hover_color.get(), true); + } + + // The picture, over the strip the name sits in: stretched across the + // cell the way chrome is drawn, or fitted the way an icon is. + const LLRect area(box.mLeft + CELL_PAD, box.mTop - CELL_PAD, box.mRight - CELL_PAD, box.mBottom + CAPTION_HEIGHT + CELL_PAD); + const LLPointer& image = mImages[cell.index]; + + // A built view in the cell draws itself; a picture is drawn here. + if (!specimen.view && image.notNull() && area.getWidth() > 0 && area.getHeight() > 0) + { + const S32 natural_width = llmax(1, image->getWidth()); + const S32 natural_height = llmax(1, image->getHeight()); + S32 width; + S32 height; + + if (specimen.stretch) + { + width = area.getWidth(); + height = llmin(area.getHeight(), natural_height * 2); + } + else + { + const F32 scale = llmin(1.f, llmin(F32(area.getWidth()) / natural_width, F32(area.getHeight()) / natural_height)); + width = llmax(1, ll_round(natural_width * scale)); + height = llmax(1, ll_round(natural_height * scale)); + } + + const S32 left = area.mLeft + (area.getWidth() - width) / 2; + const S32 bottom = area.mBottom + (area.getHeight() - height) / 2; + + gl_rect_2d_checkerboard(LLRect(left, bottom + height, left + width, bottom), 0.6f); + image->draw(left, bottom, width, height); + } + + if (specimen.marked) + { + gl_rect_2d(LLRect(box.mRight - 8, box.mTop - 3, box.mRight - 3, box.mTop - 8), mark_color.get(), true); + } + + small->renderUTF8(specimen.label, 0, F32(box.mLeft + CELL_PAD), F32(box.mBottom + 2), + specimen.broken ? broken_color.get() : caption_color.get(), + LLFontGL::LEFT, LLFontGL::BOTTOM, LLFontGL::NORMAL, LLFontGL::NO_SHADOW, S32_MAX, + box.getWidth() - 2 * CELL_PAD, nullptr, true); +} + +// Left and right are the next cell either way, headings skipped; up and +// down are the cell in the row above or below nearest across, which is +// the one under or over this one when the rows are full. +bool ALSpecimenList::handleKeyHere(KEY key, MASK mask) +{ + if (cells() && key == 'A' && mask == MASK_CONTROL) + { + std::vector values; + for (const S32 index : shownOrder()) + { + values.push_back(mSpecimens[index].value); + } + if (!values.empty()) + { + if (std::find(values.begin(), values.end(), mChosen) == values.end()) + { + choose(values.front()); + } + setSelection(std::move(values)); + } + return true; + } + if (!cells() || mask != MASK_NONE || mCells.empty() + || (key != KEY_LEFT && key != KEY_RIGHT && key != KEY_UP && key != KEY_DOWN)) + { + return LLPanel::handleKeyHere(key, mask); + } + + const S32 from = chosenCell(); + S32 to = -1; + + if (from < 0) + { + for (size_t i = 0; i < mCells.size() && to < 0; ++i) + { + to = mCells[i].index >= 0 ? (S32)i : -1; + } + } + else if (key == KEY_LEFT || key == KEY_RIGHT) + { + const S32 step = key == KEY_LEFT ? -1 : 1; + for (S32 i = from + step; i >= 0 && i < (S32)mCells.size(); i += step) + { + if (mCells[i].index >= 0) + { + to = i; + break; + } + } + } + else + { + const LLRect& here = mCells[from].rect; + S32 nearest = S32_MAX; + for (size_t i = 0; i < mCells.size(); ++i) + { + const LLRect& there = mCells[i].rect; + const bool that_way = key == KEY_UP ? there.mBottom >= here.mTop : there.mTop <= here.mBottom; + if (mCells[i].index < 0 || !that_way) + { + continue; + } + // The nearest row first, and within it the nearest across. + const S32 rows = std::abs(there.getCenterY() - here.getCenterY()); + const S32 across = std::abs(there.getCenterX() - here.getCenterX()); + const S32 distance = rows * 4096 + across; + if (distance < nearest) + { + nearest = distance; + to = (S32)i; + } + } + } + + if (to >= 0) + { + choose(mSpecimens[mCells[to].index].value); + } + return true; +} + void ALSpecimenList::reshape(S32 width, S32 height, bool called_from_parent) { LLPanel::reshape(width, height, called_from_parent); diff --git a/indra/llui/alspecimenlist.h b/indra/llui/alspecimenlist.h index ea9cb5787f5..e3ed759faa7 100644 --- a/indra/llui/alspecimenlist.h +++ b/indra/llui/alspecimenlist.h @@ -1,6 +1,6 @@ /** * @file alspecimenlist.h - * @brief A list whose rows are the widgets themselves rather than words about them. + * @brief A list whose rows are the things themselves rather than words about them. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -25,6 +25,7 @@ #pragma once #include "llpanel.h" +#include "lluiimage.h" #include #include @@ -36,22 +37,29 @@ class ALEmptyState; class LLScrollContainer; class LLTextBox; -// A list of things, where each row carries the thing rather than its name. +// A list of things, where each entry carries the thing rather than its +// name. // // Where a catalogue of widgets would ship a thumbnail, we can build the // widget: it is in this process, it draws itself, and it is a truer picture // of what a file will get than any screenshot of an older version of it. The // same applies to anything a viewer can make one of -- a colour, a font, a -// notification, an outfit. +// notification, an outfit -- and to a picture the skin draws, which is +// known by sight before it is known by name. // -// A specimen is a picture of a widget rather than a widget: a click on a row -// chooses the row, and never reaches what is in it. A button in a list of -// buttons is not there to be pressed. +// A specimen is a picture of a widget rather than a widget: a click on an +// entry chooses the entry, and never reaches what is in it. A button in a +// list of buttons is not there to be pressed. // -// Rows sit under headings, in the order the headings first appear, because -// which things belong together is a fact about them and not about their -// names. A filter narrows to the rows that carry the words, and a heading -// left with nothing under it goes with them. +// Two layouts, chosen by the list and changed at will: rows, one under +// the other, each with a label beside the thing; or cells, across and +// down, each a thing with its name under it. A thing is a built view or +// a picture by name; a picture is drawn rather than built, since there +// may be hundreds. Entries sit under headings, in the order the headings +// first appear, because which things belong together is a fact about +// them and not about their names. A filter narrows to the entries that +// carry the words, and a heading left with nothing under it goes with +// them. class ALSpecimenList : public LLPanel { public: @@ -61,6 +69,9 @@ class ALSpecimenList : public LLPanel { Optional row_height; Optional label_width; + // Given, the list is cells of this size rather than rows. + Optional cell_width; + Optional cell_height; Optional empty_headline; Optional empty_sentence; Params(); @@ -68,8 +79,8 @@ class ALSpecimenList : public LLPanel struct Specimen { - // The heading this row sits under. Rows with none sit at the top, - // under no heading at all. + // The heading this entry sits under. Entries with none sit at the + // top, under no heading at all. std::string group; std::string label; // What the caller calls it, answered back when it is chosen. @@ -79,19 +90,41 @@ class ALSpecimenList : public LLPanel // knows how. Taken, and deleted with the list. Null leaves the row // its label, which is what a thing that would not build looks like. LLView* view = nullptr; + // Or the picture, by the name the skin knows it by: drawn in a + // cell, and shown beside the label in a row. + std::string image; + // Stretched across the cell, as chrome is drawn, rather than fitted + // at its own proportions, as an icon is. + bool stretch = false; + // Carries the person's changes; drawn with a mark. + bool marked = false; + // Does not resolve; its name drawn as a warning. + bool broken = false; }; // Takes them, views and all. void setSpecimens(std::vector specimens); size_t count() const { return mSpecimens.size(); } - // How many rows the filter leaves. + // How many entries the filter leaves. size_t shown() const { return mShown; } + // Cells of this size, or rows for none; the entries stay, views and + // all, and move to the other layout, which is kept built: switching + // back and forth costs a frame and no rebuilding. + void setCellSize(S32 width, S32 height); + bool cells() const { return mCellWidth > 0 && mCellHeight > 0; } void filter(const std::string& text); const std::string& filterText() const { return mFilter; } + // What the list says while it holds nothing the filter lets through. + void setEmpty(const std::string& headline, const std::string& sentence); + const std::string& chosen() const { return mChosen; } + // Chosen, and said; or only marked as such and brought into view, + // for a caller that chose it somewhere else. Either way it is the + // whole of the selection then. void choose(const std::string& value); + void setChosen(const std::string& value); typedef boost::signals2::signal chose_signal_t; boost::signals2::connection onChose(const chose_signal_t::slot_type& cb) @@ -99,16 +132,34 @@ class ALSpecimenList : public LLPanel return mChose.connect(cb); } - // A row carried off the list. The list holds nothing a drop could + // In cells, control and a click adds an entry to the selection or + // takes it out again, and shift and a click takes everything between + // the chosen entry and the clicked one: the chosen entry is what the + // list is about, and the selection is what an operation over several + // is about. The chosen entry is always among the selected. + const std::vector& selection() const { return mSelection; } + void setSelection(std::vector values); + typedef boost::signals2::signal selection_signal_t; + boost::signals2::connection onSelectionChanged(const selection_signal_t::slot_type& cb) + { + return mSelectionChanged.connect(cb); + } + // A cell pressed with the modifiers held, which the tiles pass on. + void cellPressed(S32 index, MASK mask); + + // An entry carried off the list. The list holds nothing a drop could // use and starts no drag of its own: it says which specimen was // picked up, once the press has moved far enough to be a drag, and // the caller with the drag tool starts the one. A list given no - // starter has rows that are chosen and never carried. + // starter has entries that are chosen and never carried. typedef std::function drag_fn_t; void setDragStarter(drag_fn_t fn) { mDragStart = std::move(fn); } bool startDrag(const std::string& value) { return mDragStart && mDragStart(value); } void reshape(S32 width, S32 height, bool called_from_parent = true) override; + // In cells, the arrows move the choice a cell across or a row up or + // down. + bool handleKeyHere(KEY key, MASK mask) override; ~ALSpecimenList() override; protected: @@ -117,9 +168,36 @@ class ALSpecimenList : public LLPanel private: class Row; + class Tiles; + + // A cell, or a heading across the cells. + struct Cell + { + LLRect rect; + // Which specimen, or none for a heading. + S32 index = -1; + std::string heading; + }; + // The rows made for the entries held, once; the tiles need none. + void buildRows(); + // Every row and heading taken down, and every view out of wherever it + // sat; the views are the caller's until they are placed again. + void unbuild(); + // The views into the rows or the tiles, whichever is showing. + void placeViews(); void layout(); + void layoutRows(); + void layoutCells(); bool passes(const Specimen& specimen) const; + // What is shown, in the order it is shown: the entries under no + // heading, then each heading's. + std::vector shownOrder() const; + S32 cellAt(S32 x, S32 y) const; + // The cell of the chosen entry, or -1. + S32 chosenCell() const; + void drawCell(const Cell& cell, bool hovered) const; + void showChosen(); std::vector mSpecimens; // One per specimen, in the same order; a row for a filtered-out specimen @@ -128,16 +206,27 @@ class ALSpecimenList : public LLPanel std::vector mRows; std::vector mHeadings; // one per group, in first order std::vector mGroups; + // The pictures, one per specimen, and the cells they are drawn in. + std::vector> mImages; + std::vector mCells; LLScrollContainer* mScroller = nullptr; + // The two layouts, both kept; one is what the container scrolls. + LLPanel* mRowsPane = nullptr; + Tiles* mTiles = nullptr; LLPanel* mContent = nullptr; + bool mRowsBuilt = false; ALEmptyState* mEmpty = nullptr; std::string mFilter; std::string mChosen; + std::vector mSelection; std::string mEmptyHeadline; std::string mEmptySentence; S32 mRowHeight; S32 mLabelWidth; + S32 mCellWidth; + S32 mCellHeight; size_t mShown = 0; chose_signal_t mChose; + selection_signal_t mSelectionChanged; drag_fn_t mDragStart; }; diff --git a/indra/llui/alstudiofloater.cpp b/indra/llui/alstudiofloater.cpp new file mode 100644 index 00000000000..a3aca93abe8 --- /dev/null +++ b/indra/llui/alstudiofloater.cpp @@ -0,0 +1,277 @@ +/** + * @file alstudiofloater.cpp + * @brief A window laid out as a studio is, and what every such window keeps + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alstudiofloater.h" + +#include "alpopover.h" +#include "llcontrol.h" +#include "lleditmenuhandler.h" +#include "llfocusmgr.h" +#include "llmenugl.h" +#include "lltextbox.h" +#include "llui.h" +#include "lluicolortable.h" +#include "lluictrlfactory.h" + +ALStudioFloater::ALStudioFloater(const LLSD& key, std::string state_setting) +: LLFloater(key), + mStateSetting(std::move(state_setting)) +{ +} + +void ALStudioFloater::setMenuBar(LLMenuBarGL* menu_bar) +{ + mMenuBar = menu_bar; + mUndoItem = menu_bar ? menu_bar->findChild("undo", true) : nullptr; + mRedoItem = menu_bar ? menu_bar->findChild("redo", true) : nullptr; +} + +bool ALStudioFloater::handleMenuAccelerator(KEY key, MASK mask) +{ + return mMenuBar && mMenuBar->handleAcceleratorKey(key, mask); +} + +bool ALStudioFloater::handleUndoKeys(KEY key, MASK mask) +{ + const bool undo_key = (key == 'Z' && mask == MASK_CONTROL); + const bool redo_key = (key == 'Y' && mask == MASK_CONTROL) || (key == 'Z' && mask == (MASK_CONTROL | MASK_SHIFT)); + if (!undo_key && !redo_key) + { + return false; + } + + LLEditMenuHandler* text = LLEditMenuHandler::gEditMenuHandler; + LLView* text_view = text ? text->asView() : nullptr; + if (text_view && text_view->hasAncestor(this)) + { + if (undo_key && text->canUndo()) + { + text->undo(); + return true; + } + if (redo_key && text->canRedo()) + { + text->redo(); + return true; + } + } + + if (undo_key) + { + undo(); + } + else + { + redo(); + } + return true; +} + +void ALStudioFloater::sayUndoRedo(const std::string& undo_what, const std::string& redo_what) +{ + if (mUndoItem) + { + mUndoItem->setLabel(undo_what.empty() ? getString("MenuUndo") + : getString("MenuUndoWhat", LLStringUtil::format_map_t{ { "[WHAT]", undo_what } })); + } + if (mRedoItem) + { + mRedoItem->setLabel(redo_what.empty() ? getString("MenuRedo") + : getString("MenuRedoWhat", LLStringUtil::format_map_t{ { "[WHAT]", redo_what } })); + } +} + +void ALStudioFloater::showHistory(ALHistoryList* list, std::vector steps, size_t in_force) +{ + const std::string undo_what = in_force > 0 && in_force <= steps.size() ? steps[in_force - 1].what : std::string(); + const std::string redo_what = in_force < steps.size() ? steps[in_force].what : std::string(); + sayUndoRedo(undo_what, redo_what); + if (list) + { + list->setSteps(std::move(steps), in_force); + } +} + +void ALStudioFloater::quickOpen(std::vector candidates, const std::string& placeholder, + const std::string& title, std::function chose, + LLView* anchor, S32 width) +{ + // Asked for again while it is up -- the same key pressed twice -- it is + // what was typed that is wanted back, not a fresh field. + if (LLView* up = mQuickPopover.get()) + { + if (ALQuickOpen* quick = up->findChild("quick_open")) + { + quick->setCandidates(std::move(candidates)); + quick->takeFocus(); + return; + } + if (ALPopover* popover = ALViewType::as(up)) + { + popover->escape(); + } + else + { + up->die(); + } + } + mQuickPopover.markDead(); + + constexpr S32 WIDTH = 460; + constexpr S32 HEIGHT = 300; + + ALQuickOpen::Params qp(LLUICtrlFactory::getDefaultParams()); + qp.name = "quick_open"; + qp.rect = LLRect(0, HEIGHT, width > 0 ? width : WIDTH, 0); + qp.placeholder = placeholder; + ALQuickOpen* quick = LLUICtrlFactory::create(qp); + quick->setCandidates(std::move(candidates)); + + // The popover takes the content, and takes it even when it cannot show. + ALPopover* popover = ALPopover::show(anchor ? anchor : this, quick, title); + if (!popover) + { + return; + } + mQuickPopover = popover->getHandle(); + LLHandle held = popover->getDerivedHandle(); + quick->onChose([held, chose = std::move(chose)](const std::string& value) + { + // Settled first, so the keyboard comes back to the window before + // what was chosen is acted on -- a choice that puts the keyboard + // somewhere needs it back to give. + if (ALPopover* up = held.get()) + { + up->settle(); + } + chose(value); + }); + popover->onClosed([this](bool) { mQuickPopover.markDead(); }); + quick->takeFocus(); +} + +void ALStudioFloater::setStatus(const std::string& text, bool failure) +{ + static const LLUIColor normal = LLUIColorTable::instance().getColor("TextFgColor", LLColor4::white); + static const LLUIColor alarm = LLUIColorTable::instance().getColor("LtOrange", LLColor4::yellow); + + if (mStatus) + { + mStatus->setColor(failure ? alarm : normal); + mStatus->setText(text); + } +} + +// Before anything else: a pane left in a window this one does not own +// goes down with it. +void ALStudioFloater::onClose(bool app_quitting) +{ + saveState(); + mFolds.dockAll(); + LLFloater::onClose(app_quitting); +} + +void ALStudioFloater::draw() +{ + rememberShape(); + LLFloater::draw(); +} + +// Told to the per-account control the floater reads its rect from, so +// that the floater's own bookkeeping -- where it sits relative to the +// screen, what it writes back -- runs as it would have. The relative +// position the account kept is let go of, since it would otherwise +// outrank the rect it was derived from. +bool ALStudioFloater::applyRectControl() +{ + if (!mRestoredRect.isEmpty() && !mRectControl.empty()) + { + LLControlGroup* group = getControlGroup(); + group->setRect(mRectControl, mRestoredRect); + for (const std::string& name : { mPosXControl, mPosYControl }) + { + if (LLControlVariable* control = group->getControl(name)) + { + control->resetToDefault(); + } + } + } + return LLFloater::applyRectControl(); +} + +// Not in the middle of a drag: the shape worth keeping is the one it ends +// at. And not while minimized, when the rect is a title bar. +void ALStudioFloater::rememberShape() +{ + if (gFocusMgr.getMouseCapture() || isMinimized()) + { + return; + } + if (getRect() != mShapeRect || mFolds.dims() != mShapeDims) + { + saveState(); + } +} + +void ALStudioFloater::saveState() +{ + LLSD state; + writeState(state); + // Which regions are folded, how big each is, and which are out in + // windows of their own and where; a folded region remembers the size + // it unfolds to. + mFolds.save(state); + // And the window itself, left, bottom, right, top. + const LLRect r = getRect(); + state["rect"] = LLSD::emptyArray(); + state["rect"].append(r.mLeft); + state["rect"].append(r.mBottom); + state["rect"].append(r.mRight); + state["rect"].append(r.mTop); + mShapeRect = r; + mShapeDims = mFolds.dims(); + if (LLControlGroup* settings = LLUI::getInstance()->getSettingGroup("config")) + { + settings->setLLSD(mStateSetting, state); + } +} + +void ALStudioFloater::loadState() +{ + LLControlGroup* settings = LLUI::getInstance()->getSettingGroup("config"); + const LLSD state = settings ? settings->getLLSD(mStateSetting) : LLSD(); + if (!state.isMap()) + { + return; + } + readState(state); + mFolds.load(state); + if (state.has("rect") && state["rect"].size() == 4) + { + mRestoredRect = LLRect(state["rect"][0].asInteger(), state["rect"][3].asInteger(), + state["rect"][2].asInteger(), state["rect"][1].asInteger()); + } +} diff --git a/indra/llui/alstudiofloater.h b/indra/llui/alstudiofloater.h new file mode 100644 index 00000000000..f1de074d884 --- /dev/null +++ b/indra/llui/alstudiofloater.h @@ -0,0 +1,144 @@ +/** + * @file alstudiofloater.h + * @brief A window laid out as a studio is, and what every such window keeps + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "alhistorylist.h" +#include "alpanefolds.h" +#include "alquickopen.h" +#include "llfloater.h" + +#include +#include +#include + +class LLMenuBarGL; +class LLMenuItemGL; +class LLTextBox; + +// A window laid out as a studio is: a menu bar of its own, regions that +// fold away and come out into windows of their own, a status line, and a +// state that remembers all of that and where the window was. XUI Studio +// is one. +// +// The state is kept in the viewer's own settings rather than the +// account's, since a studio is opened from the login screen as often as +// from the world, and there is no account yet to have remembered +// anything -- which is why the rect is in it too, and why the floater's +// own rect bookkeeping is told what the state says. +// +// The menu bar's shortcuts are this window's and not the viewer's: they +// answer while it has the keyboard and are silent everywhere else. +// Without saying so, the viewer's menu answers first and quietly keeps +// every key it also binds -- Control+0 is Zoom In out there, and the pane +// it folds here is never reached. +class ALStudioFloater : public LLFloater +{ +public: + AL_VIEW_TYPE(ALStudioFloater, LLFloater); + + bool hasAccelerators() const override { return true; } + void onClose(bool app_quitting) override; + void draw() override; + bool applyRectControl() override; + + // The status line, in the plain ink or the alarm's. + virtual void setStatus(const std::string& text, bool failure = false); + + // Undo and redo, which every studio has: a step back and a step + // forward, however the studio keeps them. False where there was + // nothing to do. + virtual bool undo() { return false; } + virtual bool redo() { return false; } + +protected: + // The setting the state is kept under, which a subclass declares. + ALStudioFloater(const LLSD& key, std::string state_setting); + + // What the window is built of, told once it is built. The menu bar's + // undo and redo items are found then, by those names. + void setMenuBar(LLMenuBarGL* menu_bar); + void setStatusLine(LLTextBox* status) { mStatus = status; } + LLMenuBarGL* menuBar() const { return mMenuBar; } + + // The menu bar's own shortcut for a key, taken: what a subclass asks + // first in handleKeyHere, before its own keys. + bool handleMenuAccelerator(KEY key, MASK mask); + // Control and Z, Y, and shift and Z, which the menu bar may not bind: + // what a subclass asks next. A text control in this window with an + // edit history of its own keeps them for it -- with hasAccelerators + // they arrive before the Edit menu's own undo, which is what it would + // have got from them -- and only a key it declines reaches undo(). + bool handleUndoKeys(KEY key, MASK mask); + + // "Undo" on its own is a promise about nothing in particular. The + // two menu items say the next step back and the next step forward, + // in the words the history list uses for the same steps, so the menu + // and the list agree about what is about to happen; empty says the + // plain word. The words are the floater's MenuUndo, MenuUndoWhat, + // MenuRedo and MenuRedoWhat. + void sayUndoRedo(const std::string& undo_what, const std::string& redo_what); + // The history list fed, and the menu items said from the steps + // either side of the present. + void showHistory(ALHistoryList* list, std::vector steps, size_t in_force); + + // Open quickly: the candidates against a few letters, over the + // window, gone as soon as one is chosen or the person looks away. + // Under `anchor` where one is given, else over the window; as wide as + // `width` where one is given. Asked again while it is up, it keeps + // what was typed and takes the keyboard back. + void quickOpen(std::vector candidates, const std::string& placeholder, + const std::string& title, std::function chose, + LLView* anchor = nullptr, S32 width = 0); + + // The regions that fold, which the subclass binds. + ALPaneFolds mFolds; + + // The state as a whole: the regions and the rect, then whatever the + // subclass writes and reads. Saved on close and whenever the window + // is dragged to another shape; a subclass saves on its own changes. + void saveState(); + void loadState(); + virtual void writeState(LLSD& state) const {} + virtual void readState(const LLSD& state) {} + +private: + // The shape a drag leaves behind -- the window's rect, the size of + // each region -- noticed once the drag is over, rather than written + // on every pixel of it. + void rememberShape(); + + std::string mStateSetting; + LLMenuBarGL* mMenuBar = nullptr; + LLMenuItemGL* mUndoItem = nullptr; + LLMenuItemGL* mRedoItem = nullptr; + LLTextBox* mStatus = nullptr; + // What the state said the window's rect was, applied when it opens; + // and what was last written, so a frame can tell whether anything + // moved. + LLRect mRestoredRect; + LLRect mShapeRect; + std::vector mShapeDims; + LLHandle mQuickPopover; +}; diff --git a/indra/llui/alundostack.h b/indra/llui/alundostack.h new file mode 100644 index 00000000000..a990ca07c76 --- /dev/null +++ b/indra/llui/alundostack.h @@ -0,0 +1,149 @@ +/** + * @file alundostack.h + * @brief The steps back and forward from where things are, whatever a step is + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "stdtypes.h" + +#include +#include +#include +#include + +// The steps back from where things are, and the steps forward again once +// some have been taken, whatever a step is: a document as it was, what +// one change did, a colour. What a step is and how one is taken are the +// caller's; this keeps them in order, joins a run of changes to one thing +// within a moment of each other into one step -- a slider dragged is one +// step, not one per pixel -- names each step by the first thing said +// after it, and forgets the oldest past a depth. +// +// A step has a `mLabel` the stack writes; everything else in it is the +// caller's. +template +class ALUndoStack +{ +public: + explicit ALUndoStack(size_t depth = 100) : mDepth(depth) {} + + // A change made: the way back from it, noted. A change to the same + // thing -- the same key, within the window of the last -- joins the + // step before rather than adding one, through `join(last, step)`, + // which folds what the new step covers that the old did not. The next + // label given names the step, either way. + template + void note(Step step, std::string_view key, F64 now, F64 window, Join&& join) + { + const bool same_run = !key.empty() && key == mLastKey && now - mLastTime < window && !mUndo.empty(); + if (same_run) + { + join(mUndo.back(), std::move(step)); + } + else + { + mUndo.push_back(std::move(step)); + } + mRedo.clear(); + mLabelPending = true; + mLastKey = std::string(key); + mLastTime = now; + if (mUndo.size() > mDepth) + { + mUndo.erase(mUndo.begin()); + } + } + + // What the last change was called, said once: the first label after a + // change names it, and later ones are about something else. + void label(std::string_view text) + { + if (mLabelPending && !mUndo.empty()) + { + mUndo.back().mLabel = std::string(text); + } + mLabelPending = false; + } + // A frame later is something else. + void closeLabel() { mLabelPending = false; } + + bool canUndo() const { return !mUndo.empty(); } + bool canRedo() const { return !mRedo.empty(); } + + // The step back, taken off the stack for the caller to take; what it + // took becomes the step forward once the caller says so. + std::optional takeUndo() + { + if (mUndo.empty()) + { + return std::nullopt; + } + mLastKey.clear(); + closeLabel(); + Step step = std::move(mUndo.back()); + mUndo.pop_back(); + return step; + } + std::optional takeRedo() + { + if (mRedo.empty()) + { + return std::nullopt; + } + mLastKey.clear(); + closeLabel(); + Step step = std::move(mRedo.back()); + mRedo.pop_back(); + return step; + } + // A step put on either stack: the way forward from an undo, the way + // back from a redo, or one that could not be taken put back. + void pushUndo(Step step) { mUndo.push_back(std::move(step)); } + void pushRedo(Step step) { mRedo.push_back(std::move(step)); } + + // Everything done, oldest first, and how many are in force: the steps + // back, then the steps forward in the order they would be taken. + const std::vector& undone() const { return mUndo; } + const std::vector& redone() const { return mRedo; } + size_t inForce() const { return mUndo.size(); } + // The label of the next step back and of the next step forward, or + // nothing. + std::string undoLabel() const { return mUndo.empty() ? std::string() : mUndo.back().mLabel; } + std::string redoLabel() const { return mRedo.empty() ? std::string() : mRedo.back().mLabel; } + + void clear() + { + mUndo.clear(); + mRedo.clear(); + mLabelPending = false; + mLastKey.clear(); + } + +private: + std::vector mUndo; + std::vector mRedo; + size_t mDepth; + bool mLabelPending = false; + std::string mLastKey; + F64 mLastTime = 0.0; +}; diff --git a/indra/llui/alxuidocuments.cpp b/indra/llui/alxuidocuments.cpp index 6e3c62a7825..e65159af48b 100644 --- a/indra/llui/alxuidocuments.cpp +++ b/indra/llui/alxuidocuments.cpp @@ -26,6 +26,8 @@ #include "alxuidocuments.h" +#include "llframetimer.h" + #include ALXUIDocuments::ALXUIDocuments() = default; @@ -113,10 +115,9 @@ bool ALXUIDocuments::close(std::string_view path) } return false; }; - if (names(mDone) || names(mUndone)) + if (names(mActions.undone()) || names(mActions.redone())) { - mDone.clear(); - mUndone.clear(); + mActions.clear(); } if (was_active) { @@ -138,8 +139,7 @@ void ALXUIDocuments::closeAll() mOpen.clear(); mPaths.clear(); mSeen.clear(); - mDone.clear(); - mUndone.clear(); + mActions.clear(); mActivePath.clear(); mActivePtr = nullptr; } @@ -222,39 +222,63 @@ void ALXUIDocuments::settle() // Everything taken since this last looked is one thing, because looking // is what says a thing has finished: a caller settles after each edit, // and an Action is a caller saying not to settle in the middle of one. - mDone.push_back(std::move(taken)); - mUndone.clear(); + // A one-step edit to a field joins a run of edits to the same field, + // within the window: the steps add up, and what the run did is what + // its last step did. + taken.sameField = taken.steps.size() == 1 && taken.steps.front().second == 1 && taken.what.oneField; + const std::string key = mCoalesceWindow > 0.0 && taken.sameField ? runKey(taken) : std::string(); + mActions.note(std::move(taken), key, LLFrameTimer::getTotalSeconds(), mCoalesceWindow, + [](Taken& last, Taken&& next) + { + last.steps.front().second += next.steps.front().second; + last.what = std::move(next.what); + }); +} + +// static +std::string ALXUIDocuments::runKey(const Taken& taken) +{ + std::string key = taken.steps.front().first; + for (const std::string& step : taken.what.path) + { + key += '\n'; + key += step; + } + key += '\n'; + key += taken.what.field; + return key; } bool ALXUIDocuments::undo() { settle(); - if (mDone.empty()) + std::optional taken = mActions.takeUndo(); + if (!taken) { return false; } - const Taken taken = mDone.back(); - mDone.pop_back(); mLastChange = ALXUIEdit::Change(); mLastPath.clear(); mLastDocument.clear(); // Backwards, since the last step taken is the first put back. - for (auto it = taken.steps.rbegin(); it != taken.steps.rend(); ++it) + for (auto it = taken->steps.rbegin(); it != taken->steps.rend(); ++it) { ALXUIEdit* held = find(it->first); for (S32 i = 0; held && i < it->second; ++i) { held->undo(); } - if (held && taken.steps.size() == 1 && it->second == 1) + // A run of one field is that field: what its first step put back + // is where the whole run leaves the element. + if (held && taken->steps.size() == 1 && (it->second == 1 || taken->sameField)) { mLastChange = held->lastChange(); mLastPath = held->lastPath(); mLastDocument = it->first; } } - mUndone.push_back(taken); + mActions.pushRedo(std::move(*taken)); remember(); return true; } @@ -262,44 +286,46 @@ bool ALXUIDocuments::undo() bool ALXUIDocuments::redo() { settle(); - if (mUndone.empty()) + std::optional taken = mActions.takeRedo(); + if (!taken) { return false; } - const Taken taken = mUndone.back(); - mUndone.pop_back(); mLastChange = ALXUIEdit::Change(); mLastPath.clear(); mLastDocument.clear(); - for (const auto& [path, count] : taken.steps) + for (const auto& [path, count] : taken->steps) { ALXUIEdit* held = find(path); for (S32 i = 0; held && i < count; ++i) { held->redo(); } - if (held && taken.steps.size() == 1 && count == 1) + if (held && taken->steps.size() == 1 && (count == 1 || taken->sameField)) { mLastChange = held->lastChange(); mLastPath = held->lastPath(); mLastDocument = path; } } - mDone.push_back(taken); + mActions.pushUndo(std::move(*taken)); remember(); return true; } std::vector ALXUIDocuments::history() const { + const std::vector& done = mActions.undone(); + const std::vector& undone = mActions.redone(); std::vector all; - all.reserve(mDone.size() + mUndone.size()); + all.reserve(done.size() + undone.size()); const auto describe = [&all](const Taken& taken) { Entry entry; entry.change = taken.what; + entry.sameField = taken.sameField; entry.documents = (S32)taken.steps.size(); for (const auto& [path, count] : taken.steps) { @@ -312,13 +338,13 @@ std::vector ALXUIDocuments::history() const all.push_back(std::move(entry)); }; - for (const Taken& taken : mDone) + for (const Taken& taken : done) { describe(taken); } // The most recently put back is the one nearest the present, so it comes // first of those: read forwards, the list is one timeline. - for (auto it = mUndone.rbegin(); it != mUndone.rend(); ++it) + for (auto it = undone.rbegin(); it != undone.rend(); ++it) { describe(*it); } @@ -354,8 +380,7 @@ S32 ALXUIDocuments::rereadClean() } if (had_history) { - mDone.clear(); - mUndone.clear(); + mActions.clear(); } remember(); return read; diff --git a/indra/llui/alxuidocuments.h b/indra/llui/alxuidocuments.h index 871f2f97008..42145676b15 100644 --- a/indra/llui/alxuidocuments.h +++ b/indra/llui/alxuidocuments.h @@ -24,6 +24,7 @@ #pragma once +#include "alundostack.h" #include "alxuiedit.h" #include "llstl.h" @@ -118,11 +119,19 @@ class ALXUIDocuments bool mOpen = true; }; - bool canUndo() const { return !mDone.empty(); } - bool canRedo() const { return !mUndone.empty(); } + bool canUndo() const { return mActions.canUndo(); } + bool canRedo() const { return mActions.canRedo(); } bool undo(); bool redo(); + // A run of edits to one thing within this many seconds of each other + // is one action: a number stepped with an arrow, an element nudged + // along, are put back as one rather than a press at a time. Only an + // action of one step that wrote one field of one element joins a run; + // an Action over several is always its own. Zero, which is how this + // starts, keeps every settled edit an action of its own. + void setCoalesceWindow(F64 seconds) { mCoalesceWindow = seconds; } + // One action, as something listing them reads it. struct Entry { @@ -133,6 +142,9 @@ class ALXUIDocuments std::string document; S32 steps = 0; S32 documents = 0; + // Every step wrote the one field the change names: one step, or a + // run of them joined. What was done is that field, however many. + bool sameField = false; }; // Everything done, oldest first, and then everything put back -- one @@ -140,11 +152,12 @@ class ALXUIDocuments // back does not make it never have happened, and a stack a person can // see is the whole reason to keep them in one place. std::vector history() const; - size_t inForce() const { return mDone.size(); } + size_t inForce() const { return mActions.inForce(); } // What the last undo or redo put back, where it was one step of one - // document. An action over more than that says only that it was more - // than that, since there is no one field for a caller to write. + // document or a run of steps to one field of it. An action over more + // than that says only that it was more than that, since there is no + // one field for a caller to write. const ALXUIEdit::Change& lastChange() const { return mLastChange; } const ALXUIEdit::path_t& lastPath() const { return mLastPath; } const std::string& lastDocument() const { return mLastDocument; } @@ -174,7 +187,7 @@ class ALXUIDocuments private: // One thing done: which documents it took steps in, and how many in - // each, in the order they were taken. + // each, in the order they were taken. A step of the undo stack. struct Taken { std::vector > steps; @@ -182,14 +195,20 @@ class ALXUIDocuments // listing actions has to say what each one was, and the steps // themselves are in the documents rather than here. ALXUIEdit::Change what; + // One step that wrote one field, or a run of them joined into one. + bool sameField = false; + std::string mLabel; }; + // What a one-field action is a run of, for the stack to join a run by. + static std::string runKey(const Taken& taken); + // How many steps each document had taken when this last looked. void remember(); boost::unordered_flat_map > mSeen; - std::vector mDone; - std::vector mUndone; + ALUndoStack mActions; + F64 mCoalesceWindow = 0.0; S32 mOpenActions = 0; ALXUIEdit::Change mLastChange; ALXUIEdit::path_t mLastPath; diff --git a/indra/llui/alxuischema.cpp b/indra/llui/alxuischema.cpp index 0b90dd54d39..7814a823d1d 100644 --- a/indra/llui/alxuischema.cpp +++ b/indra/llui/alxuischema.cpp @@ -71,13 +71,10 @@ namespace return std::nullopt; } - // typeid spells a type the way a compiler does. This takes off the parts - // that are the spelling rather than the type, and leaves the rest alone: - // where it is a mangled name there is nothing to take off, and a mangled - // name still says more than "value" does. + // Decode compiler type names before removing any remaining MSVC qualifiers. std::string readableType(const char* name) { - std::string out(name ? name : ""); + std::string out = name ? LLError::Log::demangle(name) : ""; for (const char* prefix : { "class ", "struct ", "enum " }) { if (out.compare(0, std::strlen(prefix), prefix) == 0) diff --git a/indra/llui/llnotifications.cpp b/indra/llui/llnotifications.cpp index 95ed64c2f23..61317106840 100644 --- a/indra/llui/llnotifications.cpp +++ b/indra/llui/llnotifications.cpp @@ -1309,9 +1309,11 @@ bool LLNotifications::uniqueFilter(LLNotificationPtr pNotif) return true; } - // checks against existing unique notifications - for (LLNotificationMap::iterator existing_it = mUniqueNotifications.find(pNotif->getName()); - existing_it != mUniqueNotifications.end(); + // checks against existing unique notifications. A name can hold several, + // and multimap::find promises only some entry with the key, so the walk + // is over the whole run under it. + for (auto [existing_it, end] = mUniqueNotifications.equal_range(pNotif->getName()); + existing_it != end; ++existing_it) { LLNotificationPtr existing_notification = existing_it->second; @@ -1372,8 +1374,8 @@ bool LLNotifications::failedUniquenessTest(const LLSD& payload) // Update the existing unique notification with the data from this particular instance... // This guarantees that duplicate notifications will be collapsed to the one // most recently triggered - for (LLNotificationMap::iterator existing_it = mUniqueNotifications.find(pNotif->getName()); - existing_it != mUniqueNotifications.end(); + for (auto [existing_it, end] = mUniqueNotifications.equal_range(pNotif->getName()); + existing_it != end; ++existing_it) { LLNotificationPtr existing_notification = existing_it->second; @@ -1392,8 +1394,8 @@ bool LLNotifications::failedUniquenessTest(const LLSD& payload) // Add to the existing unique notification with the data from this particular instance... // This guarantees that duplicate notifications will be collapsed to the one // most recently triggered - for (LLNotificationMap::iterator existing_it = mUniqueNotifications.find(pNotif->getName()); - existing_it != mUniqueNotifications.end(); + for (auto [existing_it, end] = mUniqueNotifications.equal_range(pNotif->getName()); + existing_it != end; ++existing_it) { LLNotificationPtr existing_notification = existing_it->second; diff --git a/indra/llui/llscrollcontainer.cpp b/indra/llui/llscrollcontainer.cpp index 05c2a9bdb23..69edd3b63b3 100644 --- a/indra/llui/llscrollcontainer.cpp +++ b/indra/llui/llscrollcontainer.cpp @@ -592,6 +592,15 @@ bool LLScrollContainer::addChild(LLView* view, S32 tab_group) return ret_val; } +void LLScrollContainer::removeChild(LLView* view) +{ + if (view == mScrolledView) + { + mScrolledView = nullptr; + } + LLView::removeChild(view); +} + void LLScrollContainer::updateScroll() { // Reached several times over in one layout: from this container's reshape, diff --git a/indra/llui/llscrollcontainer.h b/indra/llui/llscrollcontainer.h index 767f2d23256..6ecd5b3df87 100644 --- a/indra/llui/llscrollcontainer.h +++ b/indra/llui/llscrollcontainer.h @@ -110,6 +110,9 @@ class LLScrollContainer : public LLUICtrl // is not always what there was to scroll then. void setDocPosVertical(S32 pos) { mScrollbar[VERTICAL]->setDocPos(pos); } S32 getDocPosHorizontal() const { return mScrollbar[HORIZONTAL]->getDocPos(); } + void setDocPosHorizontal(S32 pos) { mScrollbar[HORIZONTAL]->setDocPos(pos); } + // What is scrolled, for a caller working out where a point of it is. + LLView* getScrolledView() const { return mScrolledView; } S32 getBorderWidth() const; // LLView functionality @@ -126,6 +129,9 @@ class LLScrollContainer : public LLUICtrl virtual void draw() override; virtual bool addChild(LLView* view, S32 tab_group = 0) override; + // The scrolled view taken out is no longer what is scrolled: the next + // one added is, which is how a container shows another view in turn. + void removeChild(LLView* view) override; bool canAutoScroll(S32 x, S32 y); bool autoScroll(S32 x, S32 y); diff --git a/indra/llui/llsliderctrl.h b/indra/llui/llsliderctrl.h index 4db963ae820..7f48e7ddd1e 100644 --- a/indra/llui/llsliderctrl.h +++ b/indra/llui/llsliderctrl.h @@ -109,6 +109,8 @@ class LLSliderCtrl final: public LLF32UICtrl, public ll::ui::SearchableControl F32 getMaxValue() const override { return mSlider->getMaxValue(); } void setLabel(const LLStringExplicit& label) { if (mLabelBox) mLabelBox->setText(label); } + // The text box the label is shown in, or null for a slider built without one. + LLTextBox* getLabelBox() const { return mLabelBox; } void setLabelColor(const LLUIColor& c) { mTextEnabledColor = c; } void setDisabledLabelColor(const LLUIColor& c) { mTextDisabledColor = c; } diff --git a/indra/llui/lluicolortable.cpp b/indra/llui/lluicolortable.cpp index 0fa1d13019a..94522090e0f 100644 --- a/indra/llui/lluicolortable.cpp +++ b/indra/llui/lluicolortable.cpp @@ -34,23 +34,17 @@ void LLUIColorTable::clear() { clearTable(mLoadedColors); - clearTable(mUserSetColors); + mUserSetColors.clear(); + clearTable(mColors); mLoadedSheet.clear(); mUserSheet.clear(); + ++mGeneration; } LLUIColor LLUIColorTable::getColor(std::string_view name, const LLColor4& default_color) const { - string_color_map_t::const_iterator iter = mUserSetColors.find(name); - - if(iter != mUserSetColors.end()) - { - return LLUIColor(&iter->second); - } - - iter = mLoadedColors.find(name); - - if(iter != mLoadedColors.end()) + const auto iter = mColors.find(name); + if (iter != mColors.end() && colorExists(name)) { return LLUIColor(&iter->second); } @@ -61,30 +55,11 @@ LLUIColor LLUIColorTable::getColor(std::string_view name, const LLColor4& defaul // update user color, loaded colors are parsed on initialization void LLUIColorTable::setColor(std::string_view name, const LLColor4& color) { - auto it = mUserSetColors.find(name); - if(it != mUserSetColors.end()) - { - it->second = color; - } - else - { - string_color_map_t::iterator base_iter = mLoadedColors.find(name); - if (base_iter != mLoadedColors.end()) - { - // The node itself moves to the user table, so every handle - // taken on the loaded colour now reads the user's; a fresh node - // keeps the loaded value for isDefault and resetToDefault. - LLColor4 original_color = base_iter->second.get(); - auto color_handle = mLoadedColors.extract(base_iter); - auto new_color_pair = mUserSetColors.insert(std::move(color_handle)); - new_color_pair.position->second = color; - mLoadedColors.emplace(name, LLUIColor(original_color)); - } - else - { - mUserSetColors.insert(it, std::make_pair(name, color)); - } - } + setColor(name, color, mUserSetColors); + // Every colour declared by reference to this one follows it. + refreshColors(); + + ++mGeneration; } bool LLUIColorTable::isDefault(std::string_view name) const @@ -108,17 +83,16 @@ bool LLUIColorTable::isDefault(std::string_view name) const void LLUIColorTable::resetToDefault(std::string_view name) { - string_color_map_t::iterator iter = mUserSetColors.find(name); - - if (iter != mUserSetColors.end()) + const auto iter = mUserSetColors.find(name); + if (iter == mUserSetColors.end()) { - auto default_iter = mLoadedColors.find(name); - - if (default_iter != mLoadedColors.end()) - { - iter->second = default_iter->second.get(); - } + return; } + mUserSetColors.erase(iter); + // A user-only colour's handles keep reading magenta, and the colours + // declared by reference to it go back with it. + refreshColors(); + ++mGeneration; } bool LLUIColorTable::loadFromSettings() @@ -183,6 +157,8 @@ bool LLUIColorTable::load(std::span skin_documents, const LLXM } } + refreshColors(); + ++mGeneration; return result; } @@ -246,6 +222,52 @@ void LLUIColorTable::saveUserSettings(const bool scrub /* = false */) const } } +void LLUIColorTable::refreshColors() +{ + clearTable(mColors); + for (const auto& [name, color] : mLoadedColors) + { + setColor(name, inForce(name), mColors); + } + for (const auto& [name, color] : mUserSetColors) + { + setColor(name, color.get(), mColors); + } +} + +LLColor4 LLUIColorTable::inForce(std::string_view name) const +{ + // The sheet has already turned cycles and dangling references into the + // declarations they shadowed, so the chain ends; the bound is a guard. + std::string_view current = name; + + for (S32 depth = 0; depth < 64; ++depth) + { + if (const auto user = mUserSetColors.find(current); user != mUserSetColors.end()) + { + return user->second.get(); + } + + const ALColorSheet::Declaration* declared = mLoadedSheet.declarationOf(current); + + if (!declared || declared->kind != ALColorSheet::Declaration::Kind::Reference) + { + break; + } + + current = declared->reference; + } + + if (const auto loaded = mLoadedColors.find(current); loaded != mLoadedColors.end()) + { + return loaded->second.get(); + } + + const auto loaded = mLoadedColors.find(name); + + return loaded == mLoadedColors.end() ? LLColor4::magenta : loaded->second.get(); +} + bool LLUIColorTable::colorExists(std::string_view color_name) const { return ((mLoadedColors.find(color_name) != mLoadedColors.end()) @@ -273,7 +295,7 @@ void LLUIColorTable::setColor(std::string_view name, const LLColor4& color, stri } else { - table.insert(it, string_color_map_t::value_type(name, color)); + table.emplace(name, color); } } diff --git a/indra/llui/lluicolortable.h b/indra/llui/lluicolortable.h index 6845fff56aa..d1faaac513e 100644 --- a/indra/llui/lluicolortable.h +++ b/indra/llui/lluicolortable.h @@ -44,11 +44,14 @@ class LLUIColorTable : public LLSingleton LLSINGLETON_EMPTY_CTOR(LLUIColorTable); LOG_CLASS(LLUIColorTable); - // Node based on purpose: getColor() hands out pointers into these, so an - // entry, once made, keeps its address and is never erased. + // Node based so effective colours keep the addresses held by widgets. typedef boost::unordered_map> string_color_map_t; public: + // Counts up on every change, for whoever shows a colour by name and + // would rather compare a number each frame than hold a connection. + U32 generation() const { return mGeneration; } + // A parsed colors.xml and the name diagnostics know it by. typedef std::pair document_t; @@ -95,8 +98,17 @@ class LLUIColorTable : public LLSingleton private: void install(const ALColorSheet& sheet, string_color_map_t& table, bool drop_missing); void clearTable(string_color_map_t& table); + void refreshColors(); + // The colour a loaded name has with the user's colours in force: the + // user's own, or what the skins declare, where a colour the skins + // declare as a reference takes what its target has now. An accent the + // user edits reaches every colour the skins defined by it. + LLColor4 inForce(std::string_view name) const; void setColor(std::string_view name, const LLColor4& color, string_color_map_t& table); + U32 mGeneration = 0; + // Only this map supplies live handles. Authored overrides can be erased. + string_color_map_t mColors; string_color_map_t mLoadedColors; string_color_map_t mUserSetColors; ALColorSheet mLoadedSheet; diff --git a/indra/llui/tests/alangledial_test.cpp b/indra/llui/tests/alangledial_test.cpp new file mode 100644 index 00000000000..a6b049446a3 --- /dev/null +++ b/indra/llui/tests/alangledial_test.cpp @@ -0,0 +1,109 @@ +/** + * @file alangledial_test.cpp + * @brief ALAngleDial: a direction turned by dragging, and a drag that ends when the mouse is taken away + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alangledial.h" + +#include "../llfocusmgr.h" +#include "../lluictrlfactory.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +#include + +// llui reaches the viewer for this one, and linking any of the library pulls +// the object that calls it. Nothing under test goes near it. +class LLAvatarName; +const std::string gAngleDialTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gAngleDialTestAnonName; +} + +namespace tut +{ + struct alangledial_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + // 70 tall: the dial is the 66-pixel square at the left, its centre + // at (35, 35). + static ALAngleDial* dial() + { + ALAngleDial::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "dial"; + p.rect = LLRect(0, 70, 200, 0); + return LLUICtrlFactory::create(p); + } + }; + + typedef test_group alangledial_test; + typedef alangledial_test::object alangledial_object; + tut::alangledial_test alangledial_testgroup("alangledial"); + + // A press turns the dial to the pointer, a drag keeps turning it, and + // the release commits once. + template<> template<> + void alangledial_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALAngleDial* d = dial(); + S32 commits = 0; + d->setCommitCallback([&commits](LLUICtrl*, const LLSD&) { ++commits; }); + + d->handleMouseDown(60, 35, MASK_NONE); + ensure_equals("pressed to the right, it points right", d->getValue().asString(), std::string("1 0")); + ensure("and holds the mouse", d->hasMouseCapture()); + d->handleHover(35, 60, MASK_NONE); + ensure("dragged up, it points up", d->getValue().asString().ends_with(" 1")); + ensure_equals("nothing committed yet", commits, 0); + d->handleMouseUp(35, 60, MASK_NONE); + ensure_equals("the release commits once", commits, 1); + ensure("and lets the mouse go", !d->hasMouseCapture()); + d->die(); + } + + // The mouse taken away mid-drag ends the drag: the pointer passing over + // the dial afterwards, no button held, turns nothing. + template<> template<> + void alangledial_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALAngleDial* d = dial(); + d->handleMouseDown(60, 35, MASK_NONE); + gFocusMgr.setMouseCapture(nullptr); + d->handleHover(35, 60, MASK_NONE); + ensure_equals("a hover after the loss turns nothing", d->getValue().asString(), std::string("1 0")); + d->die(); + } +} diff --git a/indra/llui/tests/alcanvasview_test.cpp b/indra/llui/tests/alcanvasview_test.cpp index c0ab2e8b934..82a5fbc42fe 100644 --- a/indra/llui/tests/alcanvasview_test.cpp +++ b/indra/llui/tests/alcanvasview_test.cpp @@ -839,4 +839,236 @@ namespace tut box->setFocus(false); canvas->die(); } + + // The wheel climbs a ladder that doubles and halves rather than a fixed + // amount, and stops at the ends of the zoom's range. + template<> template<> + void alcanvasview_object::test<23>() + { + ALCanvasView* canvas = surface(); + + ensure_equals("life size to start", canvas->zoom(), 1.f); + const F32 up[] = { 1.5f, 2.f, 3.f, 4.f, 6.f, 8.f }; + for (const F32 step : up) + { + canvas->setZoom(canvas->steppedZoom(1)); + ensure_equals("up a rung", canvas->zoom(), step); + } + ensure_equals("and no further", canvas->steppedZoom(1), ALCanvasView::MAX_ZOOM); + + canvas->setZoom(1.f); + const F32 down[] = { 0.75f, 0.5f, 0.25f }; + for (const F32 step : down) + { + canvas->setZoom(canvas->steppedZoom(-1)); + ensure_equals("down a rung", canvas->zoom(), step); + } + ensure_equals("to the floor", canvas->steppedZoom(-1), ALCanvasView::MIN_ZOOM); + + // A zoom between rungs goes to the next rung, not past it. + canvas->setZoom(2.4f); + ensure_equals("between rungs, up", canvas->steppedZoom(1), 3.f); + ensure_equals("between rungs, down", canvas->steppedZoom(-1), 2.f); + canvas->setZoom(100.f); + ensure_equals("asked for too much, it is the ceiling", canvas->zoom(), ALCanvasView::MAX_ZOOM); + canvas->die(); + } + + // The zoom that fits is the largest rung at which what is on the + // surface still fits the room the surface is shown in. + template<> template<> + void alcanvasview_object::test<24>() + { + LLPanel::Params rp(LLUICtrlFactory::getDefaultParams()); + rp.name = "room"; + rp.rect = LLRect(0, ROOM_H, ROOM_W, 0); + LLPanel* room = LLUICtrlFactory::create(rp); + + ALCanvasView* canvas = surface(); + room->addChild(canvas); + place(canvas, 200, 100); + + // 208 by 108 with its margin: three times over fits 700 by 400, + // four times over does not. + ensure_equals("three times over", canvas->fittingZoom(), 3.f); + place(canvas, 1000, 100); + ensure_equals("too wide even at life size, so half", canvas->fittingZoom(), 0.5f); + // A surface is never narrower than 120 of its own units, so a small + // thing fits at four times over and not the eight its own size + // would allow. + place(canvas, 20, 20); + ensure_equals("a small thing stops where the surface's floor does", canvas->fittingZoom(), 4.f); + room->die(); + } + + // A surface that is the one thing in its container fills the container, + // so what is on it sits in the middle of the room until it outgrows it. + template<> template<> + void alcanvasview_object::test<25>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLScrollContainer::Params cp(LLUICtrlFactory::getDefaultParams()); + cp.name = "area"; + cp.rect = LLRect(0, ROOM_H, ROOM_W, 0); + LLScrollContainer* area = LLUICtrlFactory::create(cp); + + ALCanvasView* canvas = surface(); + area->addChild(canvas); + canvas->fitContent(200, 100); + canvas->refresh(); + + const LLRect room = area->getContentWindowRect(); + ensure_equals("as wide as the room", canvas->getRect().getWidth(), room.getWidth()); + ensure_equals("and as tall", canvas->getRect().getHeight(), room.getHeight()); + + canvas->setZoom(4.f); + canvas->refresh(); + ensure("zoomed past the room, the surface is bigger than it " + size(canvas), + canvas->getRect().getWidth() > room.getWidth()); + area->die(); + } + + // A surface told to fill a room never draws past it, whatever the zoom + // makes of the division: a pixel over brings out a scrollbar, which + // shrinks the room, which rounds the other way, and the canvas shakes. + template<> template<> + void alcanvasview_object::test<27>() + { + ALCanvasView* canvas = surface(); + canvas->fitContent(120, 40); + // Zooms at which what is on it still fits the room: past that a + // scrollbar is right. + for (const F32 zoom : { 4.f, 3.f, 1.5f, 5.f }) + { + canvas->setZoom(zoom); + for (S32 room = 640; room < 652; ++room) + { + canvas->setLeastSurface(room, room - 96); + ensure("no wider than the room at " + std::to_string(zoom) + " with " + std::to_string(room), + canvas->getRect().getWidth() <= room); + ensure("nor taller", canvas->getRect().getHeight() <= room - 96); + ensure("and not far short of it either", canvas->getRect().getWidth() > room - zoom - 1); + } + } + canvas->die(); + } + + // What the canvas says about itself: empty until something is on it, + // and the zoom as a percentage once there is. + template<> template<> + void alcanvasview_object::test<26>() + { + ALCanvasView* canvas = surface(); + + ensure("nothing on it yet", canvas->empty()); + ensure_equals("life size reads as a hundred", canvas->legend(), std::string("100%")); + place(canvas, 200, 100); + ensure("something on it now", !canvas->empty()); + canvas->setZoom(2.f); + ensure_equals("twice life size", canvas->legend(), std::string("200%")); + canvas->die(); + + ensure("a backdrop by name", ALCanvasView::backdropNamed("checker") == ALCanvasView::Backdrop::Checker); + ensure_equals("and back", std::string(ALCanvasView::backdropName(ALCanvasView::Backdrop::Light)), std::string("light")); + ensure("an unknown name is none", ALCanvasView::backdropNamed("velvet") == ALCanvasView::Backdrop::None); + } + + // Zooming about a point keeps that point of the surface where it was in + // the window: the container is scrolled by however much the zoom moved + // it. Where the pointer is in the window, read the way the canvas reads + // it. + template<> template<> + void alcanvasview_object::test<28>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLScrollContainer::Params cp(LLUICtrlFactory::getDefaultParams()); + cp.name = "area"; + cp.rect = LLRect(0, ROOM_H, ROOM_W, 0); + LLScrollContainer* area = LLUICtrlFactory::create(cp); + + ALCanvasView* canvas = surface(); + area->addChild(canvas); + canvas->fitContent(1000, 800); + canvas->refresh(); + area->getContentWindowRect(); + + // A point of the surface, and where it is in the window before. + const auto inWindow = [&](S32 x, S32 y, S32& wx, S32& wy) + { + canvas->localPointToOtherView(x, y, &wx, &wy, area); + const LLRect window = area->getContentWindowRect(); + wx -= window.mLeft; + wy -= window.mBottom; + }; + S32 before_x = 0; + S32 before_y = 0; + inWindow(300, 500, before_x, before_y); + + canvas->zoomAbout(2.f, 300, 500); + ensure_equals("zoomed", canvas->zoom(), 2.f); + ensure_equals("scrolled across to keep the point", area->getDocPosHorizontal(), 300); + + // The same point of the surface is drawn at twice the distance from + // the corner, and the container has been scrolled so that it is + // still where it was in the window. + S32 after_x = 0; + S32 after_y = 0; + inWindow(600, 1000, after_x, after_y); + ensure_equals("same place across", after_x, before_x); + ensure_equals("same place down", after_y, before_y); + area->die(); + } + + // A pan is the container scrolled by how far the pointer went, and no + // further than there is to scroll. + template<> template<> + void alcanvasview_object::test<29>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLScrollContainer::Params cp(LLUICtrlFactory::getDefaultParams()); + cp.name = "area"; + cp.rect = LLRect(0, ROOM_H, ROOM_W, 0); + LLScrollContainer* area = LLUICtrlFactory::create(cp); + + ALCanvasView* canvas = surface(); + area->addChild(canvas); + canvas->fitContent(1000, 800); + canvas->refresh(); + area->getContentWindowRect(); + + ensure("not panning yet", !canvas->panning()); + canvas->beginPan(100, 100); + ensure("panning", canvas->panning()); + // Dragged left and down: what is seen moves that way, so the + // container scrolls right and up. + canvas->panTo(50, 80); + ensure_equals("scrolled right by the drag", area->getDocPosHorizontal(), 50); + ensure_equals("and not above the top", area->getDocPosVertical(), 0); + canvas->panTo(150, 130); + ensure_equals("not past the left edge", area->getDocPosHorizontal(), 0); + ensure_equals("scrolled down by the drag", area->getDocPosVertical(), 30); + canvas->endPan(); + ensure("let go", !canvas->panning()); + + canvas->panBy(-20, 10); + ensure_equals("panned by amount across", area->getDocPosHorizontal(), 20); + ensure_equals("and down", area->getDocPosVertical(), 40); + + ensure("a plain surface does not drag by the left button", !canvas->dragPans()); + canvas->setDragPans(true); + ensure("until it says so", canvas->panGesture()); + area->die(); + } } diff --git a/indra/llui/tests/alcolorfield_test.cpp b/indra/llui/tests/alcolorfield_test.cpp index 37975b073b6..5db0c0e803f 100644 --- a/indra/llui/tests/alcolorfield_test.cpp +++ b/indra/llui/tests/alcolorfield_test.cpp @@ -267,4 +267,69 @@ namespace tut picker->color() == LLUIColorTable::instance().getColor(chosen).get()); field->die(); } + + // The popover asks the field's resolver what a text comes to, the way + // the field does, so a caller's own $name opens the picker on its + // colour; and the caller's names are the first swatches offered. + template<> template<> + void alcolorfield_object::test<6>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALColorField* field = make(); + field->setResolver([](const std::string& text, LLColor4& color) + { + if (text == "$Accent") + { + color = LLColor4(0.f, 0.5f, 1.f, 1.f); + return true; + } + return false; + }); + field->setChoices([]() + { + return std::vector{ { "$Accent", LLColor4(0.f, 0.5f, 1.f, 1.f) } }; + }); + field->setValue("$Accent"); + LLColor4 shown; + ensure("the field resolves its own name", field->resolved(shown)); + field->handleMouseDown(4, 10, MASK_NONE); + + ALColorPicker* picker = gFloaterView->findChild("picker", true); + ensure("the popover has its picker", picker != nullptr); + ensure("which starts from what the resolver says", picker->color() == LLColor4(0.f, 0.5f, 1.f, 1.f)); + + LLView* grid = swatchesUnder(gFloaterView); + ensure("and the grid", grid != nullptr); + // The first swatch is the caller's own name. + grid->handleMouseDown(5 + 14, grid->getRect().getHeight() - 5 - 14, MASK_NONE); + LLFloater* popover = grid->getParentByType(); + ensure_equals("the caller's name comes first", popover->getTitle(), std::string("$Accent")); + field->die(); + } + + // A name means what the table says now: the swatch follows a colour + // changed elsewhere without the text having changed. + template<> template<> + void alcolorfield_object::test<7>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + LLUIColorTable& table = LLUIColorTable::instance(); + table.setColor("FieldTestColor", LLColor4::red); + ALColorField* field = make(); + field->setValue("FieldTestColor"); + LLColor4 shown; + ensure("resolved to the table's colour", field->resolved(shown) && shown == LLColor4::red); + + const U32 before = table.generation(); + table.setColor("FieldTestColor", LLColor4::green); + ensure("the table counted the change", table.generation() != before); + ensure("and the field followed it", field->resolved(shown) && shown == LLColor4::green); + field->die(); + } } diff --git a/indra/llui/tests/alcolortablepanel_test.cpp b/indra/llui/tests/alcolortablepanel_test.cpp new file mode 100644 index 00000000000..52162e390b3 --- /dev/null +++ b/indra/llui/tests/alcolortablepanel_test.cpp @@ -0,0 +1,166 @@ +/** + * @file alcolortablepanel_test.cpp + * @brief The colour table as rows: whose each is, and the way back + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alcolortablepanel.h" + +#include "../alpropertygrid.h" +#include "llcallbacklist.h" +#include "../llfloater.h" +#include "../lluicolortable.h" +#include "../lluictrlfactory.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +class LLAvatarName; +const std::string gColorTablePanelTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gColorTablePanelTestAnonName; +} + +namespace tut +{ + struct alcolortablepanel_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + // The rows are the source tree's colors.xml, as the viewer would + // have it. + alcolortablepanel_data() + { + static bool loaded = false; + if (ui.ok() && !loaded) + { + loaded = LLUIColorTable::instance().loadFromSettings(); + } + } + + static ALColorTablePanel* make() + { + ALColorTablePanel::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "colours"; + p.rect = LLRect(0, 600, 460, 0); + ALColorTablePanel* panel = LLUICtrlFactory::create(p); + gFloaterView->addChild(panel); + return panel; + } + + static const ALPropertyGrid::Field* rowNamed(const ALColorTablePanel* panel, const std::string& name) + { + const ALPropertyGrid* grid = panel->getChild("color_grid"); + for (const ALPropertyGrid::Field& field : grid->fields()) + { + if (field.name == name) + { + return &field; + } + } + return nullptr; + } + }; + + typedef test_group alcolortablepanel_group; + typedef alcolortablepanel_group::object alcolortablepanel_object; + alcolortablepanel_group alcolortablepanel_instance("alcolortablepanel"); + + // One row per name the table has, each saying which file declared it + // and, for a name that refers to another, which. + template<> template<> + void alcolortablepanel_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + const LLUIColorTable& table = LLUIColorTable::instance(); + ALColorTablePanel* panel = make(); + const ALPropertyGrid* grid = panel->getChild("color_grid"); + ensure("a row per loaded colour, at least", grid->fields().size() >= table.getLoadedColors().size()); + + const ALPropertyGrid::Field* white = rowNamed(panel, "White"); + ensure("the table's White has a row", white != nullptr); + ensure_equals("with the colour as the picker writes it", white->value, ALColorTablePanel::valueText(LLColor4::white)); + ensure("declared somewhere", !white->source.empty()); + ensure("and nobody's until changed", !white->authored); + ensure("a colour row", white->type == "LLColor4"); + panel->die(); + } + + // A colour changed is the person's: its row says so, sits under the + // first heading, and says what the skin had; the way back is the + // skin's colour again, and both are said. + template<> template<> + void alcolortablepanel_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + LLUIColorTable& table = LLUIColorTable::instance(); + ALColorTablePanel* panel = make(); + std::vector said; + panel->onChanged([&](const std::string& name) { said.push_back(name); }); + + ensure("the skin declares White", table.getLoadedSheet().find("White") != nullptr); + const LLColor4 picked(0.1f, 0.2f, 0.3f, 1.f); + + // Committed the way a row commits: the editor on the row. + gIdleCallbacks.callFunctions(); + LLUICtrl* editor = panel->findChild("White", true); + ensure("the row has its editor", editor != nullptr); + editor->setValue(ALColorTablePanel::valueText(picked)); + editor->onCommit(); + ensure("the table has the colour", table.getColor("White").get() == picked); + ensure_equals("and the change was said", said.size(), size_t(1)); + ensure_equals("by name", said.front(), std::string("White")); + + panel->refresh(); + const ALPropertyGrid::Field* white = rowNamed(panel, "White"); + ensure("the changed row is still there", white != nullptr); + ensure("changed rows are the person's", white->authored); + ensure_equals("and sit under the first heading", white->group, 0); + // The words are the viewer's strings, which these tests do not + // load; that the row says something about the skin's is enough. + ensure("saying what the skin had", !white->description.empty()); + + table.resetToDefault("White"); + panel->refresh(); + white = rowNamed(panel, "White"); + ensure("the row survives the way back", white != nullptr); + ensure("put back, it is the skin's again", !white->authored); + ensure("under its file", white->group > 0); + panel->die(); + } + + // What a value is written as, and read back from. + template<> template<> + void alcolortablepanel_object::test<3>() + { + ensure_equals("four numbers with commas", ALColorTablePanel::valueText(LLColor4(1.f, 0.5f, 0.f, 1.f)), std::string("1, 0.5, 0, 1")); + } +} diff --git a/indra/llui/tests/alimagefield_test.cpp b/indra/llui/tests/alimagefield_test.cpp new file mode 100644 index 00000000000..bcb23b13b76 --- /dev/null +++ b/indra/llui/tests/alimagefield_test.cpp @@ -0,0 +1,151 @@ +/** + * @file alimagefield_test.cpp + * @brief A picture by name, and the popover that offers the skin's + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alimagefield.h" + +#include "../alpopover.h" +#include "../alspecimenlist.h" +#include "../llfloater.h" +#include "../lllineeditor.h" +#include "../lluictrlfactory.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +class LLAvatarName; +const std::string gImageFieldTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gImageFieldTestAnonName; +} + +namespace tut +{ + struct alimagefield_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + static ALImageField* make() + { + ALImageField::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "picture"; + p.rect = LLRect(100, 322, 300, 300); + ALImageField* field = LLUICtrlFactory::create(p); + gFloaterView->addChild(field); + return field; + } + + static std::vector choices() + { + return { { "PushButton_Off", "Shapes" }, { "PushButton_Over", "Shapes" }, { "Icon_Close", "Textures" } }; + } + }; + + typedef test_group alimagefield_group; + typedef alimagefield_group::object alimagefield_object; + alimagefield_group alimagefield_instance("alimagefield"); + + // The value is the name, whichever way it arrives, and typing one + // commits it. + template<> template<> + void alimagefield_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALImageField* field = make(); + field->setValue("PushButton_Off"); + ensure_equals("holds the name", field->getValue().asString(), std::string("PushButton_Off")); + + S32 commits = 0; + field->setCommitCallback([&](LLUICtrl*, const LLSD&) { ++commits; }); + LLLineEditor* text = field->getChild("text"); + text->setText(std::string("Icon_Close")); + text->onCommit(); + ensure_equals("typing commits", commits, 1); + ensure_equals("and the name follows", field->getValue().asString(), std::string("Icon_Close")); + field->die(); + } + + // The swatch opens the popover over the caller's pictures, under their + // headings; choosing one is what the popover would write, and closing + // it writes it. + template<> template<> + void alimagefield_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALImageField* field = make(); + field->setChoices(&alimagefield_data::choices); + field->setValue("PushButton_Off"); + + S32 commits = 0; + field->setCommitCallback([&](LLUICtrl*, const LLSD&) { ++commits; }); + field->handleMouseDown(4, 10, MASK_NONE); + + ALSpecimenList* gallery = gFloaterView->findChild("images", true); + ensure("the popover has the gallery", gallery != nullptr); + ensure("as cells", gallery->cells()); + ensure_equals("with every picture offered", gallery->count(), size_t(3)); + ensure_equals("open on the field's name", gallery->chosen(), std::string("PushButton_Off")); + ALPopover* popover = gallery->getParentByType(); + ensure("in a popover", popover != nullptr); + + // Nothing chosen yet: closing keeps what the field had and says + // nothing. + popover->settle(); + ensure_equals("no change, no commit", commits, 0); + ensure_equals("same name", field->getValue().asString(), std::string("PushButton_Off")); + field->die(); + } + + // The tool that edits a picture is a button in the popover, given the + // name that was chosen. + template<> template<> + void alimagefield_object::test<3>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALImageField* field = make(); + field->setChoices(&alimagefield_data::choices); + std::string edited; + field->setEditor([&](const std::string& name) { edited = name; }, "Edit"); + field->setValue("Icon_Close"); + field->handleMouseDown(4, 10, MASK_NONE); + + LLUICtrl* edit = gFloaterView->findChild("edit", true); + ensure("the popover offers the tool", edit != nullptr); + edit->onCommit(); + ensure_equals("which is given the name", edited, std::string("Icon_Close")); + field->die(); + } +} diff --git a/indra/llui/tests/aloffsetpad_test.cpp b/indra/llui/tests/aloffsetpad_test.cpp new file mode 100644 index 00000000000..352185c90cb --- /dev/null +++ b/indra/llui/tests/aloffsetpad_test.cpp @@ -0,0 +1,111 @@ +/** + * @file aloffsetpad_test.cpp + * @brief ALOffsetPad: an offset moved by dragging its dot, and a drag that ends when the mouse is taken away + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../aloffsetpad.h" + +#include "../llfocusmgr.h" +#include "../lluictrlfactory.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +#include + +// llui reaches the viewer for this one, and linking any of the library pulls +// the object that calls it. Nothing under test goes near it. +class LLAvatarName; +const std::string gOffsetPadTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gOffsetPadTestAnonName; +} + +namespace tut +{ + struct aloffsetpad_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + // 70 tall: the pad is the 66-pixel square at the left, its centre + // at (35, 35), reaching 32 either way. + static ALOffsetPad* pad() + { + ALOffsetPad::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "pad"; + p.rect = LLRect(0, 70, 200, 0); + ALOffsetPad* made = LLUICtrlFactory::create(p); + made->setRange(32.f, 1.f, 0); + return made; + } + }; + + typedef test_group aloffsetpad_test; + typedef aloffsetpad_test::object aloffsetpad_object; + tut::aloffsetpad_test aloffsetpad_testgroup("aloffsetpad"); + + // A press puts the dot under the pointer, a drag moves it, and the + // release commits once. + template<> template<> + void aloffsetpad_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALOffsetPad* p = pad(); + S32 commits = 0; + p->setCommitCallback([&commits](LLUICtrl*, const LLSD&) { ++commits; }); + + p->handleMouseDown(35, 35, MASK_NONE); + ensure_equals("pressed at the centre, no offset", p->getValue().asString(), std::string("0 0")); + ensure("and holds the mouse", p->hasMouseCapture()); + p->handleHover(35 + 33, 35, MASK_NONE); + ensure_equals("dragged to the right edge, the whole reach", p->getValue().asString(), std::string("32 0")); + ensure_equals("nothing committed yet", commits, 0); + p->handleMouseUp(35 + 33, 35, MASK_NONE); + ensure_equals("the release commits once", commits, 1); + ensure("and lets the mouse go", !p->hasMouseCapture()); + p->die(); + } + + // The mouse taken away mid-drag ends the drag: the pointer passing over + // the pad afterwards, no button held, moves nothing. + template<> template<> + void aloffsetpad_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALOffsetPad* p = pad(); + p->handleMouseDown(35, 35, MASK_NONE); + gFocusMgr.setMouseCapture(nullptr); + p->handleHover(35 + 33, 35, MASK_NONE); + ensure_equals("a hover after the loss moves nothing", p->getValue().asString(), std::string("0 0")); + p->die(); + } +} diff --git a/indra/llui/tests/alpanefolds_test.cpp b/indra/llui/tests/alpanefolds_test.cpp new file mode 100644 index 00000000000..984b787a62e --- /dev/null +++ b/indra/llui/tests/alpanefolds_test.cpp @@ -0,0 +1,228 @@ +/** + * @file alpanefolds_test.cpp + * @brief ALPaneFolds: regions of a window that fold, and that come out into windows of their own + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alpanefolds.h" + +#include "../aldockpanel.h" +#include "../llbutton.h" +#include "../lllayoutstack.h" +#include "../llpanel.h" +#include "../lluictrlfactory.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +#include + +// llui reaches the viewer for this one, and linking any of the library pulls +// the object that calls it. Nothing under test goes near it. +class LLAvatarName; +const std::string gPaneFoldsTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gPaneFoldsTestAnonName; +} + +namespace tut +{ + struct alpanefolds_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + // A window with a stack of two layout panels, and beside it a plain + // panel that is in no stack: the two kinds of region. + struct Window + { + LLFloater* floater = nullptr; + LLLayoutPanel* side = nullptr; + LLButton* fold = nullptr; + LLPanel* page = nullptr; + LLButton* in_page = nullptr; + }; + + static Window build() + { + Window w; + LLFloater::Params fp(LLFloater::getDefaultParams()); + fp.name = "tool"; + fp.rect = LLRect(0, 400, 600, 0); + fp.save_rect = false; + fp.save_visibility = false; + w.floater = new LLFloater(LLSD(), fp); + + LLLayoutStack::Params sp(LLUICtrlFactory::getDefaultParams()); + sp.name = "stack"; + sp.rect = LLRect(0, 300, 600, 0); + sp.orientation = "horizontal"; + sp.animate = false; + LLLayoutStack* stack = LLUICtrlFactory::create(sp); + w.floater->addChild(stack); + + LLLayoutPanel::Params pp(LLUICtrlFactory::getDefaultParams()); + pp.name = "side"; + pp.rect = LLRect(0, 300, 200, 0); + pp.auto_resize = false; + pp.min_dim = 40; + w.side = LLUICtrlFactory::create(pp); + stack->addChild(w.side); + + LLLayoutPanel::Params mp(LLUICtrlFactory::getDefaultParams()); + mp.name = "middle"; + mp.rect = LLRect(0, 300, 400, 0); + mp.auto_resize = true; + stack->addChild(LLUICtrlFactory::create(mp)); + stack->updateLayout(); + + LLButton::Params bp(LLUICtrlFactory::getDefaultParams()); + bp.name = "fold_side"; + bp.rect = LLRect(0, 320, 40, 300); + w.fold = LLUICtrlFactory::create(bp); + w.floater->addChild(w.fold); + + LLPanel::Params gp(LLUICtrlFactory::getDefaultParams()); + gp.name = "page"; + gp.rect = LLRect(0, 400, 600, 320); + w.page = LLUICtrlFactory::create(gp); + w.floater->addChild(w.page); + + LLButton::Params ip(LLUICtrlFactory::getDefaultParams()); + ip.name = "in_the_page"; + ip.rect = LLRect(0, 20, 80, 0); + w.in_page = LLUICtrlFactory::create(ip); + w.page->addChild(w.in_page); + + return w; + } + + static std::vector panes() + { + return { { "side", "side", "fold_side", "Side" }, + { "page", "page", "", "Page" } }; + } + }; + + typedef test_group alpanefolds_test; + typedef alpanefolds_test::object alpanefolds_object; + tut::alpanefolds_test alpanefolds_testgroup("alpanefolds"); + + // A layout panel folds and has a size; a plain panel does neither, and + // asking does nothing rather than something wrong. + template<> template<> + void alpanefolds_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + Window w = build(); + ALPaneFolds folds; + folds.bind(w.floater, panes()); + + ensure("the side is open", !folds.collapsed("side")); + folds.toggle("side"); + ensure("and folds", folds.collapsed("side")); + ensure("and its button says so", !w.fold->getToggleState()); + ensure_equals("it has a size", folds.dim("side"), 200); + + ensure("the page is never folded", !folds.collapsed("page")); + folds.toggle("page"); + ensure("nor can it be", !folds.collapsed("page")); + ensure_equals("and has no size", folds.dim("page"), 0); + w.floater->die(); + } + + // Either kind comes out into a window of its own and goes back, with + // what it holds still where it was. + template<> template<> + void alpanefolds_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + Window w = build(); + ALPaneFolds folds; + folds.bind(w.floater, panes()); + + ensure("home to start", !folds.out("page") && folds.window("page") == nullptr); + folds.toggleOut("page"); + ensure("out", folds.out("page")); + LLFloater* window = folds.window("page"); + ensure("in a window of its own", window != nullptr && window != w.floater); + ensure("with what it held", w.in_page->getParentByType() == window); + ensure("and the page itself still on the window", w.page->getParent() == w.floater); + + folds.toggleOut("side"); + ensure("the layout panel comes out too", folds.out("side")); + + folds.dockAll(); + ensure("and both go home", !folds.out("page") && !folds.out("side")); + ensure("the button back under its page", w.in_page->getParentByType() == w.floater); + w.floater->die(); + } + + // Written down: a fold and a size for the layout panel only, and for + // both whether it is out and where its window was. Read back, a region + // that was out comes out again where it was. + template<> template<> + void alpanefolds_object::test<3>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + LLSD state; + { + Window w = build(); + ALPaneFolds folds; + folds.bind(w.floater, panes()); + folds.toggleOut("page"); + LLFloater* window = folds.window("page"); + window->setRect(LLRect(700, 500, 1000, 300)); + folds.save(state); + ensure("the side's fold is written", state.has("fold_side")); + ensure("and its size", state.has("dim_side")); + ensure("the page has no fold to write", !state.has("fold_page")); + ensure("nor a size", !state.has("dim_page")); + ensure("but it is written as out", state["out_page"].asBoolean()); + ensure("with its window's rect", state.has("rect_page")); + w.floater->die(); + } + { + Window w = build(); + ALPaneFolds folds; + folds.bind(w.floater, panes()); + folds.load(state); + ensure("read back, the page is out again", folds.out("page")); + LLFloater* window = folds.window("page"); + ensure("in a window", window != nullptr); + ensure_equals("as wide as it was", window->getRect().getWidth(), 300); + ensure("and the side is home", !folds.out("side")); + w.floater->die(); + } + } +} diff --git a/indra/llui/tests/alpopover_test.cpp b/indra/llui/tests/alpopover_test.cpp index 1fd5558ee0c..2ca5722689d 100644 --- a/indra/llui/tests/alpopover_test.cpp +++ b/indra/llui/tests/alpopover_test.cpp @@ -33,6 +33,8 @@ #include "../test/lltut.h" +#include + class LLAvatarName; const std::string gPopoverTestAnonName("Anon"); const std::string& rlvGetAnonym(const LLAvatarName& av_name) @@ -203,4 +205,92 @@ namespace tut popover->closeFloater(); over->die(); } + + // A popover is a window of its own, so a key it does not take goes home + // to the window its anchor is in, and it asks before the viewer's menu + // does -- otherwise a window's own shortcuts stop working the moment one + // of its popovers has the keyboard. + template<> template<> + void alpopover_object::test<6>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + struct Home : public LLFloater + { + Home(const LLFloater::Params& p) : LLFloater(LLSD(), p) {} + bool handleKeyHere(KEY key, MASK mask) override + { + heard.push_back(key); + return key == 'F' && mask == MASK_CONTROL; + } + std::vector heard; + }; + LLFloater::Params fp(LLFloater::getDefaultParams()); + fp.name = "home"; + fp.rect = LLRect(100, 500, 500, 100); + fp.save_rect = false; + fp.save_visibility = false; + Home* home = new Home(fp); + LLPanel* over = anchor(); + home->addChild(over); + + ALPopover* popover = ALPopover::show(over, content()); + ensure_equals("it knows where home is", popover->home(), home); + ensure("and asks before the viewer's own menu does", popover->hasAccelerators()); + ensure("a key home takes is taken", popover->handleKeyHere('F', MASK_CONTROL)); + ensure_equals("and home heard it", home->heard.size(), 1u); + ensure("a key home declines is declined", !popover->handleKeyHere('Q', MASK_CONTROL)); + ensure_equals("having been asked", home->heard.size(), 2u); + ensure("escape is still the popover's own", popover->handleKeyHere(KEY_ESCAPE, MASK_NONE)); + ensure_equals("and never reaches home", home->heard.size(), 2u); + + // Hung off nothing in a window, it has no home and no claim on the + // menu's keys. + LLPanel* loose = anchor(); + ALPopover* alone = ALPopover::show(loose, content()); + ensure("no home", alone->home() == nullptr); + ensure("no claim", !alone->hasAccelerators()); + alone->closeFloater(); + loose->die(); + home->die(); + } + + // A popover built to remember its size opens at the size the last of + // its kind was left at, and one of another kind does not. + template<> template<> + void alpopover_object::test<7>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + // As a popover with more to it than a panel is made: derived, and + // opened with openBeside. + struct Remembering : public ALPopover + { + Remembering(const LLFloater::Params& p) : ALPopover(p) {} + }; + LLPanel* over = anchor(); + ALPopover* first = new Remembering(ALPopover::paramsRemembered("popover_test_kind", 200, 120)); + first->openBeside(over); + ensure_equals("opens at the size given", first->getRect().getWidth(), 200); + first->reshape(260, 150); + first->closeFloater(); + + ALPopover* second = new Remembering(ALPopover::paramsRemembered("popover_test_kind", 200, 120)); + second->openBeside(over); + ensure_equals("the next of its kind opens as wide as the last was left", second->getRect().getWidth(), 260); + ensure_equals("and as tall", second->getRect().getHeight(), 150); + second->closeFloater(); + + ALPopover* other = new Remembering(ALPopover::paramsRemembered("popover_test_other", 200, 120)); + other->openBeside(over); + ensure_equals("another kind is not told", other->getRect().getWidth(), 200); + other->closeFloater(); + over->die(); + } } diff --git a/indra/llui/tests/alpropertygrid_test.cpp b/indra/llui/tests/alpropertygrid_test.cpp index 8cb548714b8..886a19b8661 100644 --- a/indra/llui/tests/alpropertygrid_test.cpp +++ b/indra/llui/tests/alpropertygrid_test.cpp @@ -35,6 +35,7 @@ #include "../llaccordionctrltab.h" #include "../llspinctrl.h" #include "../lltextbox.h" +#include "../llfocusmgr.h" #include "../lluictrlfactory.h" #include "llcallbacklist.h" @@ -1033,4 +1034,113 @@ namespace tut ensure_equals("with what was committed", now->getText(), std::string("After")); grid->die(); } + + // The same rows with new values are taken without a rebuild: the + // editors are the same objects afterwards, saying the new values. + // Different rows are refused, and then the grid is filled again. + template<> template<> + void alpropertygrid_object::test<23>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALPropertyGrid* grid = build(); + grid->setGroups({ "identity" }); + std::vector fields; + fields.push_back(field("label", 0)); + fields.back().value = "Before"; + fields.push_back(field("width", 0)); + fields.back().kind = ALParamType::INTEGER; + fields.back().value = "10"; + grid->setFields(fields); + gIdleCallbacks.callFunctions(); + + LLLineEditor* label = grid->getChild("label", true); + LLSpinCtrl* width = grid->getChild("width", true); + + fields[0].value = "After"; + fields[1].value = "20"; + fields[1].authored = false; + ensure("the same rows are taken", grid->updateFields(fields)); + ensure("the editors are the ones that were there", grid->findChild("label", true) == label + && grid->findChild("width", true) == width); + ensure_equals("saying the new text", label->getText(), std::string("After")); + ensure_equals("and the new number", width->getValue().asInteger(), 20); + ensure_equals("and the grid knows it", grid->fields()[1].value, std::string("20")); + + fields.push_back(field("height", 0)); + ensure("a row more is refused", !grid->updateFields(fields)); + fields.pop_back(); + fields[1].kind = ALParamType::REAL; + ensure("a row of another kind is refused", !grid->updateFields(fields)); + grid->die(); + } + + // Rows keep the order they were given when asked to, however they are + // written: a vocabulary has an order of its own. + template<> template<> + void alpropertygrid_object::test<24>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALPropertyGrid* grid = build(); + grid->setKeepsOrder(true); + grid->setGroups({ "identity" }); + std::vector fields; + fields.push_back(field("radius", 0)); + fields.back().authored = false; + fields.push_back(field("corners", 0)); + fields.back().authored = false; + fields.push_back(field("border", 0)); + grid->setFields(fields); + ensure_equals("first as given", grid->fields()[0].name, std::string("radius")); + ensure_equals("second as given", grid->fields()[1].name, std::string("corners")); + ensure_equals("the written one stays where it was", grid->fields()[2].name, std::string("border")); + grid->die(); + } + + // A kept row is put right in every way it can change under an update: + // what its tip says follows the field, a part being scrubbed keeps the + // number under the hand, and a field that would build a different + // control -- another step -- is refused rather than kept stale. + template<> template<> + void alpropertygrid_object::test<25>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALPropertyGrid* grid = build(); + grid->setGroups({ "identity" }); + std::vector fields; + fields.push_back(field("rect", 0)); + fields.back().kind = ALParamType::INTEGER; + fields.back().components = { "L", "T", "R", "B" }; + fields.back().value = "10 20 30 40"; + grid->setFields(fields); + gIdleCallbacks.callFunctions(); + + LLPanel* row = grid->getChild("identity_rows", true)->getChild("rect_row", true); + LLTextBox* label = row->getChild("rect_label", true); + LLSpinCtrl* top = row->getChild("rect.T", true); + + fields[0].description = "Where it sits."; + ensure("a new description keeps the row", grid->updateFields(fields)); + ensure("and the tip says it", label->getToolTip().find("Where it sits.") != std::string::npos); + + // Scrubbing T: the update lands in the other three and not in T. + gFocusMgr.setMouseCapture(top); + fields[0].value = "11 21 31 41"; + ensure("a new value keeps the row", grid->updateFields(fields)); + ensure_equals("the part under the hand keeps its number", top->getValue().asInteger(), 20); + ensure_equals("and the others take theirs", row->getChild("rect.L", true)->getValue().asInteger(), 11); + gFocusMgr.setMouseCapture(nullptr); + + fields[0].step = 5.f; + ensure("another step is another control, so the rows differ", !grid->updateFields(fields)); + grid->die(); + } } diff --git a/indra/llui/tests/alquickopen_test.cpp b/indra/llui/tests/alquickopen_test.cpp index f60cd0c85e6..b5056a0c34e 100644 --- a/indra/llui/tests/alquickopen_test.cpp +++ b/indra/llui/tests/alquickopen_test.cpp @@ -206,4 +206,42 @@ namespace tut gFocusMgr.setKeyboardFocus(nullptr); quick->die(); } + + // The other words a candidate answers to: typing them finds it, below + // anything whose label answers. + template<> template<> + void alquickopen_object::test<7>() + { + std::vector shapes; + for (const auto& [name, words] : { std::pair{ "PushButton_Off", "Push button buttons" }, + { "Rounded_Square", "Rounded square basic" }, + { "Circle", "round disc" } }) + { + ALQuickOpen::Candidate one; + one.label = name; + one.also = words; + one.value = name; + shapes.push_back(one); + } + + std::vector order = ALQuickOpen::rank(shapes, "basic"); + ensure_equals("the words alone find it", order.size(), size_t(1)); + ensure_equals("the square", shapes[order.front()].label, std::string("Rounded_Square")); + + // The same match on a label outranks it on the words; a better + // match on the words outranks a poor one on a label. + order = ALQuickOpen::rank(shapes, "round"); + ensure_equals("both answer", order.size(), size_t(2)); + ensure_equals("the label first", shapes[order.front()].label, std::string("Rounded_Square")); + ensure_equals("the words after", shapes[order.back()].label, std::string("Circle")); + + ALQuickOpen::Candidate scattered; + scattered.label = "pxuxsxh"; + scattered.value = "scattered"; + shapes.push_back(scattered); + order = ALQuickOpen::rank(shapes, "push"); + ensure_equals("both answer", order.size(), size_t(2)); + ensure_equals("the words' whole word before the label's scattered letters", + shapes[order.front()].label, std::string("PushButton_Off")); + } } diff --git a/indra/llui/tests/alsettingrow_test.cpp b/indra/llui/tests/alsettingrow_test.cpp new file mode 100644 index 00000000000..2d15c3eb330 --- /dev/null +++ b/indra/llui/tests/alsettingrow_test.cpp @@ -0,0 +1,566 @@ +/** + * @file alsettingrow_test.cpp + * @brief One setting on one line, bound once, with its way back to the default. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alsettingrow.h" + +#include "../llbutton.h" +#include "../llfloater.h" +#include "../lllineeditor.h" +#include "../llslider.h" +#include "../llsliderctrl.h" +#include "../lltextbox.h" +#include "../llui.h" +#include "../lluictrlfactory.h" +#include "../llxuiparser.h" +#include "llcontrol.h" +#include "llxmlnode.h" + +#include "alheadlessui_fixture.h" + +#include "../test/lltut.h" + +#include +#include + +// llui reaches the viewer for this one, and linking any of the library pulls +// the object that calls it. Nothing under test goes near it. +class LLAvatarName; +const std::string gSettingRowTestAnonName("Anon"); +const std::string& rlvGetAnonym(const LLAvatarName& av_name) +{ + return gSettingRowTestAnonName; +} + +namespace tut +{ + struct alsettingrow_data + { + ll_test::HeadlessUI& ui = ll_test::HeadlessUI::get(); + + static LLControlGroup& config() + { + return *LLUI::getInstance()->getSettingGroup("config"); + } + + // One setting per test, put back to its default, so no test sees what + // another left behind. + static LLControlVariable* setting(const std::string& name, F32 value) + { + LLControlVariable* control = config().getControl(name).get(); + if (!control) + { + control = config().declareF32(name, value, std::string("A setting row test value")); + } + control->resetToDefault(false); + return control; + } + + static ALSettingRow::Params params(const std::string& control_name, F32 min_value = 0.f, + F32 max_value = 1.f, S32 decimal_digits = 2) + { + ALSettingRow::Params p(LLUICtrlFactory::getDefaultParams()); + p.name = "row"; + p.rect = LLRect(0, 18, 360, 0); + p.label = "Strength"; + p.min_value = min_value; + p.max_value = max_value; + p.increment = 0.01f; + p.decimal_digits = decimal_digits; + if (!control_name.empty()) + { + p.control_name = control_name; + } + return p; + } + + static ALSettingRow* row(const std::string& control_name, F32 min_value = 0.f, + F32 max_value = 1.f, S32 decimal_digits = 2) + { + ALSettingRow::Params p = params(control_name, min_value, max_value, decimal_digits); + return LLUICtrlFactory::create(p); + } + + // Every view under @a root that is bound to a setting. + static S32 boundUnder(LLView* root) + { + S32 bound = 0; + for (LLView* child : *root->getChildList()) + { + LLUICtrl* ctrl = ALViewType::as(child); + if (ctrl && ctrl->getControlVariable()) + { + ++bound; + } + bound += boundUnder(child); + } + return bound; + } + + // A slider's value box. + static LLLineEditor* valueBoxOf(LLSliderCtrl* slider) + { + for (LLView* child : *slider->getChildList()) + { + if (LLLineEditor* editor = ALViewType::as(child)) + { + return editor; + } + } + return nullptr; + } + + static bool closeTo(F64 a, F64 b) + { + return std::fabs(a - b) < 1e-5; + } + }; + + typedef test_group alsettingrow_test; + typedef alsettingrow_test::object alsettingrow_object; + tut::alsettingrow_test alsettingrow_testgroup("alsettingrow"); + + // A slider row becomes a setting row by changing its tag, so every one of + // the slider's attributes has to be read by the name a slider writes. + template<> template<> + void alsettingrow_object::test<1>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + const std::string source = + "\n"; + LLXMLNodePtr node; + ensure("parses", LLXMLNode::parseBuffer(source.data(), source.size(), node)); + + ALSettingRow::Params p; + LLXUIParser parser; + parser.readXUI(node, p, "setting_row_test.xml"); + ensure_equals("label", p.label(), std::string("Knee")); + ensure_equals("label_width", p.label_width(), 120); + ensure("min_val", closeTo(p.min_value(), -1.0)); + ensure("max_val", closeTo(p.max_value(), 2.0)); + ensure("increment", closeTo(p.increment(), 0.05)); + ensure_equals("decimal_digits", p.decimal_digits(), 3); + ensure("text_width said", p.text_width.isProvided()); + ensure_equals("text_width", p.text_width(), 56); + ensure("can_edit_text", !p.can_edit_text()); + ensure("show_reset", !p.show_reset()); + ensure_equals("control_name", p.control_name(), std::string("RenderBloomKnee")); + + // And what a file leaves out is what every Lightbox row writes today. + ALSettingRow::Params defaults; + ensure_equals("label_width defaults to the rows' 140", defaults.label_width(), 140); + ensure("can_edit_text defaults on", defaults.can_edit_text()); + ensure("show_reset defaults on", defaults.show_reset()); + ensure("text_width is not said unless said", !defaults.text_width.isProvided()); + } + + // Bound both ways, and a commit is one write: the row writes the setting, + // the setting's signal comes back to the row, and nothing writes it again. + template<> template<> + void alsettingrow_object::test<2>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLControlVariable* control = setting("SettingRowTestBinding", 0.5f); + ALSettingRow* r = row("SettingRowTestBinding"); + ensure("the row reads the setting", closeTo(r->getValue().asReal(), 0.5)); + + control->set(LLSD(0.75)); + ensure("and follows it", closeTo(r->getValue().asReal(), 0.75)); + ensure("the slider shows it", closeTo(r->getSlider()->getValueF32(), 0.75)); + + S32 writes = 0; + boost::signals2::scoped_connection watching = control->getSignal()->connect( + [&writes](LLControlVariable*, const LLSD&, const LLSD&) { ++writes; }); + S32 commits = 0; + r->setCommitCallback([&commits](LLUICtrl*, const LLSD&) { ++commits; }); + + r->getSlider()->setValue(0.25f); + r->getSlider()->onCommit(); + ensure("the slider's value reaches the setting", closeTo(control->getValue().asReal(), 0.25)); + ensure_equals("written once", writes, 1); + ensure_equals("and the row says so once", commits, 1); + r->die(); + } + + // The row is the only thing bound, so a walk for what a panel is bound to + // finds one setting per row and not one per part of it. + template<> template<> + void alsettingrow_object::test<3>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + setting("SettingRowTestBinding", 0.5f); + ALSettingRow* r = row("SettingRowTestBinding"); + ensure("the row is bound", r->getControlVariable() != nullptr); + ensure_equals("and nothing in it is", boundUnder(r), 0); + r->die(); + } + + // The reset button is there while there is something to reset, pressing it + // puts the default back and goes, and the row does not say it committed: + // what cares about the setting heard the setting change. + template<> template<> + void alsettingrow_object::test<4>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLControlVariable* control = setting("SettingRowTestReset", 0.5f); + ALSettingRow* r = row("SettingRowTestReset"); + ensure("hidden at the default", !r->getResetButton()->getVisible()); + + control->set(LLSD(0.8)); + ensure("shown once changed", r->getResetButton()->getVisible()); + ensure("and the row says so", r->isModified()); + + S32 commits = 0; + r->setCommitCallback([&commits](LLUICtrl*, const LLSD&) { ++commits; }); + r->getResetButton()->onCommit(); + ensure("the default is back", closeTo(control->getValue().asReal(), 0.5)); + ensure("the slider shows it", closeTo(r->getSlider()->getValueF32(), 0.5)); + ensure("the button has gone", !r->getResetButton()->getVisible()); + ensure_equals("and the row did not commit", commits, 0); + r->die(); + } + + // As shown, not as stored: a value that rounds to the default at the + // row's decimals is the default as far as anyone looking can tell, so a + // drag that came back to it does not leave a reset button lit. + template<> template<> + void alsettingrow_object::test<5>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLControlVariable* control = setting("SettingRowTestRounding", 0.7f); + ALSettingRow* r = row("SettingRowTestRounding", 0.f, 1.f, 2); + + control->set(LLSD(0.6999999)); + ensure("not the default to the letter", !control->isDefault()); + ensure("but not modified as shown", !r->isModified()); + ensure("so no button", !r->getResetButton()->getVisible()); + + control->set(LLSD(0.69)); + ensure("a change that shows, shows it", r->getResetButton()->getVisible()); + r->die(); + } + + // The button's place is kept whether it shows or not, at any width: the + // slider ends where it always ends, so nothing moves when the button + // appears, and a row and the rows above it line up. + template<> template<> + void alsettingrow_object::test<6>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + ALSettingRow* r = row(std::string()); + const S32 width = r->getRect().getWidth(); + const S32 slot = ALSettingRow::RESET_SIZE + ALSettingRow::RESET_GAP; + ensure_equals("the button at the right edge", r->getResetButton()->getRect().mRight, width); + ensure_equals("the slider ends before its slot", r->getSlider()->getRect().mRight, width - slot); + + r->reshape(width + 50, r->getRect().getHeight()); + ensure_equals("still at the right edge, wider", r->getResetButton()->getRect().mRight, width + 50); + ensure_equals("and the slider still before it", r->getSlider()->getRect().mRight, width + 50 - slot); + r->die(); + } + + // Greyed as one thing: the slider, its label and value, and the button, + // whether by hand or by the control the row's enabled_control names. + template<> template<> + void alsettingrow_object::test<7>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + setting("SettingRowTestBinding", 0.5f); + ALSettingRow* r = row("SettingRowTestBinding"); + r->setEnabled(false); + ensure("the slider greys", !r->getSlider()->getEnabled()); + ensure("and the button", !r->getResetButton()->getEnabled()); + r->setEnabled(true); + ensure("and both come back", r->getSlider()->getEnabled() && r->getResetButton()->getEnabled()); + r->die(); + + LLControlVariable* gate = config().getControl("SettingRowTestGate").get(); + if (!gate) + { + gate = config().declareBOOL("SettingRowTestGate", true, std::string("A setting row test gate")); + } + gate->set(LLSD(true)); + ALSettingRow::Params p = params("SettingRowTestBinding"); + p.enabled_controls.enabled = "SettingRowTestGate"; + ALSettingRow* gated = LLUICtrlFactory::create(p); + ensure("open while the gate is", gated->getSlider()->getEnabled()); + gate->set(LLSD(false)); + ensure("greyed when it closes", !gated->getSlider()->getEnabled() && !gated->getResetButton()->getEnabled()); + gate->set(LLSD(true)); + gated->die(); + } + + // Bound to nothing it is still a slider: it takes and gives a value and + // says when it changed -- which is all the vector rows need of a control -- + // and it never offers a reset it has nothing to reset with. + template<> template<> + void alsettingrow_object::test<8>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + ALSettingRow* r = row(std::string()); + r->setValue(LLSD(0.4)); + ensure("takes a value", closeTo(r->getValue().asReal(), 0.4)); + + S32 commits = 0; + r->setCommitCallback([&commits](LLUICtrl*, const LLSD&) { ++commits; }); + r->getSlider()->setValue(0.6f); + r->getSlider()->onCommit(); + ensure_equals("says when it changed", commits, 1); + ensure("and gives the new value", closeTo(r->getValue().asReal(), 0.6)); + ensure("never a reset", !r->getResetButton()->getVisible()); + ensure("nor a claim to be modified", !r->isModified()); + r->die(); + } + + // The value box is wide enough for every number the range holds. A + // slider sizes it from the maximum's logarithm, which under 1 has no room + // for the leading zero; the row sizes it itself there, and only there. + template<> template<> + void alsettingrow_object::test<9>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + const auto slider_like = [](F32 max_value) + { + LLSliderCtrl::Params sp(LLUICtrlFactory::getDefaultParams()); + sp.name = "plain"; + sp.rect = LLRect(0, 16, 336, 0); + sp.label = "Strength"; + sp.label_width = 140; + sp.min_value = 0.f; + sp.max_value = max_value; + sp.increment = 0.01f; + sp.decimal_digits = 3; + sp.can_edit_text = true; + return LLUICtrlFactory::create(sp); + }; + + ALSettingRow* small = row(std::string(), 0.f, 0.05f, 3); + LLSliderCtrl* plain_small = slider_like(0.05f); + LLLineEditor* row_box = valueBoxOf(small->getSlider()); + LLLineEditor* plain_box = valueBoxOf(plain_small); + ensure("both have a value box", row_box && plain_box); + ensure("under 1, the row's is wider than a slider's " + std::to_string(row_box->getRect().getWidth()) + + " > " + std::to_string(plain_box->getRect().getWidth()), + row_box->getRect().getWidth() > plain_box->getRect().getWidth()); + + ALSettingRow* large = row(std::string(), 0.f, 10.f, 3); + LLSliderCtrl* plain_large = slider_like(10.f); + ensure_equals("over 1, exactly a slider's", + valueBoxOf(large->getSlider())->getRect().getWidth(), + valueBoxOf(plain_large)->getRect().getWidth()); + + small->die(); + plain_small->die(); + large->die(); + plain_large->die(); + } + + // Found by its name and lit up by it, the way a slider is. + template<> template<> + void alsettingrow_object::test<10>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + ALSettingRow* r = row(std::string()); + ensure("searched for by its label", r->getSearchText().find("Strength") != std::string::npos); + ensure_equals("which it can say", r->getLabel(), std::string("Strength")); + LLTextBox* box = r->getLabelBox(); + ensure("and point at", box != nullptr); + ensure_equals("the text box that shows it", box->getText(), std::string("Strength")); + // The column every row's label takes when the file does not say: a + // default the slider did not get would have it measure the word. + ensure_equals("as wide as the rows' label column", box->getRect().getWidth(), 140); + + r->setHighlighted(true); + ensure("lit, the slider's label is lit", r->getSlider()->getHighlighted()); + r->setHighlighted(false); + ensure("and out again", !r->getSlider()->getHighlighted()); + r->die(); + } + + // A reset button that goes away with the keyboard in it would leave the + // keyboard nowhere. + template<> template<> + void alsettingrow_object::test<11>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLControlVariable* control = setting("SettingRowTestFocus", 0.5f); + ALSettingRow* r = row("SettingRowTestFocus"); + gFloaterView->addChild(r); + control->set(LLSD(0.9)); + r->getResetButton()->setFocus(true); + ensure("the button has the keyboard", r->getResetButton()->hasFocus()); + + control->resetToDefault(true); + ensure("gone", !r->getResetButton()->getVisible()); + ensure("and the keyboard with it", !r->getResetButton()->hasFocus()); + r->die(); + } + + // An owner can take the reset: the button asks it instead of resetting, + // once, with the row, and the row does nothing itself -- the owner that + // took it is the one that resets. + template<> template<> + void alsettingrow_object::test<12>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLControlVariable* control = setting("SettingRowTestHandler", 0.5f); + ALSettingRow* r = row("SettingRowTestHandler"); + control->set(LLSD(0.9)); + + S32 asked = 0; + ALSettingRow* asked_by = nullptr; + r->setResetHandler([&asked, &asked_by](ALSettingRow* from) + { + ++asked; + asked_by = from; + }); + r->getResetButton()->onCommit(); + ensure_equals("the owner is asked once", asked, 1); + ensure("with the row", asked_by == r); + ensure("and the row did not reset it itself", closeTo(control->getValue().asReal(), 0.9)); + + // The owner resetting is a reset like any other. + r->setResetHandler([](ALSettingRow* from) { from->getControlVariable()->resetToDefault(true); }); + r->getResetButton()->onCommit(); + ensure("reset by the owner", closeTo(control->getValue().asReal(), 0.5)); + ensure("and the button gone", !r->getResetButton()->getVisible()); + r->die(); + } + + // A row says when a drag begins and ends, the way a slider does and by the + // names a slider writes: a setting that is dear to apply waits for the + // release. The callbacks are named in the file, so they are resolved + // against the registrar in scope while the row is built. + template<> template<> + void alsettingrow_object::test<13>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + setting("SettingRowTestHeld", 0.5f); + S32 pressed = 0; + S32 released = 0; + LLUICtrl::CommitCallbackRegistry::ScopedRegistrar registrar; + registrar.add("SettingRowTest.Down", [&pressed](LLUICtrl*, const LLSD&) { ++pressed; }); + registrar.add("SettingRowTest.Up", [&released](LLUICtrl*, const LLSD&) { ++released; }); + + const std::string source = + "\n" + " \n" + " \n" + "\n"; + LLXMLNodePtr node; + ensure("parses", LLXMLNode::parseBuffer(source.data(), source.size(), node)); + ALSettingRow::Params p(LLUICtrlFactory::getDefaultParams()); + LLXUIParser parser; + parser.readXUI(node, p, "setting_row_test.xml"); + ensure("press said", p.mouse_down_callback.isProvided()); + ensure("release said", p.mouse_up_callback.isProvided()); + p.rect = LLRect(0, 18, 360, 0); + + ALSettingRow* r = LLUICtrlFactory::create(p); + gFloaterView->addChild(r); + LLSlider* bar = nullptr; + for (LLView* child : *r->getSlider()->getChildList()) + { + if (LLSlider* candidate = ALViewType::as(child)) + { + bar = candidate; + } + } + ensure("the row's slider has a bar", bar != nullptr); + + // On the track and off the thumb, which sits at the middle for 0.5: + // a press on the thumb measures the skin's thumb image, and the + // headless skin has none. + const S32 x = 1; + const S32 y = bar->getRect().getHeight() / 2; + bar->handleMouseDown(x, y, MASK_NONE); + ensure_equals("the press is told once", pressed, 1); + ensure_equals("and no release yet", released, 0); + bar->handleMouseUp(x, y, MASK_NONE); + ensure_equals("the release is told once", released, 1); + ensure_equals("and no second press", pressed, 1); + + // A row that names neither is a row like every other. + ALSettingRow* plain = row("SettingRowTestHeld"); + ensure("no press asked for", !params("SettingRowTestHeld").mouse_down_callback.isProvided()); + plain->die(); + r->die(); + } +} diff --git a/indra/llui/tests/alspecimenlist_test.cpp b/indra/llui/tests/alspecimenlist_test.cpp index eefb5a665bc..cf9dde81dc6 100644 --- a/indra/llui/tests/alspecimenlist_test.cpp +++ b/indra/llui/tests/alspecimenlist_test.cpp @@ -241,4 +241,214 @@ namespace tut row->handleMouseUp(x + 40, y, MASK_NONE); delete list; } + + // Cells: pictures across and down under their headings, chosen by the + // arrows as well as the pointer, and narrowed by the filter the same + // way rows are. + template<> template<> + void alspecimenlist_object::test<6>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALSpecimenList::Params p(LLUICtrlFactory::getDefaultParams()); + p.rect = LLRect(0, 300, 330, 0); + p.cell_width = 100; + p.cell_height = 60; + p.empty_headline = "Nothing here."; + ALSpecimenList* list = LLUICtrlFactory::create(p); + ensure("a list given a cell size is cells", list->cells()); + + std::vector pictures; + for (const char* name : { "PushButton_Off", "PushButton_Over", "Icon_Close" }) + { + ALSpecimenList::Specimen one; + one.group = std::string(name).starts_with("Push") ? "Buttons" : "Icons"; + one.label = name; + one.value = name; + one.image = name; + pictures.push_back(one); + } + list->setSpecimens(pictures); + ensure_equals("every picture shown", list->shown(), size_t(3)); + ensure("no row was built for a cell", list->findChild("row_PushButton_Off", true) == nullptr); + + std::vector chosen; + list->onChose([&chosen](const std::string& value) { chosen.push_back(value); }); + + // Nothing chosen: the first arrow takes the first cell; right takes + // the next; down takes the one below, which is the other heading's. + list->handleKeyHere(KEY_RIGHT, MASK_NONE); + ensure_equals("the first cell", list->chosen(), std::string("PushButton_Off")); + list->handleKeyHere(KEY_RIGHT, MASK_NONE); + ensure_equals("the next across", list->chosen(), std::string("PushButton_Over")); + list->handleKeyHere(KEY_DOWN, MASK_NONE); + ensure_equals("the one below", list->chosen(), std::string("Icon_Close")); + list->handleKeyHere(KEY_UP, MASK_NONE); + ensure_equals("and back up", list->chosen(), std::string("PushButton_Off")); + ensure_equals("each said", chosen.size(), size_t(4)); + + list->setChosen("Icon_Close"); + ensure_equals("chosen quietly", list->chosen(), std::string("Icon_Close")); + ensure_equals("and not said", chosen.size(), size_t(4)); + + list->filter("icon"); + ensure_equals("the filter narrows the cells", list->shown(), size_t(1)); + list->filter("zzz"); + ensure_equals("to nothing", list->shown(), size_t(0)); + ensure("which the pane says", list->findChild("empty", true)->getVisible()); + delete list; + } + + // Several cells at once: control adds one and takes it out again, + // shift takes the run between the chosen one and the pressed one, and + // a plain press is that one thing again. + template<> template<> + void alspecimenlist_object::test<7>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALSpecimenList::Params p(LLUICtrlFactory::getDefaultParams()); + p.rect = LLRect(0, 300, 330, 0); + p.cell_width = 100; + p.cell_height = 60; + ALSpecimenList* list = LLUICtrlFactory::create(p); + + std::vector pictures; + for (const char* name : { "A", "B", "C", "D" }) + { + ALSpecimenList::Specimen one; + one.group = "Letters"; + one.label = name; + one.value = name; + one.image = name; + pictures.push_back(one); + } + list->setSpecimens(pictures); + + S32 changes = 0; + list->onSelectionChanged([&changes]() { ++changes; }); + + list->cellPressed(0, MASK_NONE); + ensure_equals("chosen", list->chosen(), std::string("A")); + ensure_equals("and alone selected", list->selection().size(), size_t(1)); + + list->cellPressed(2, MASK_CONTROL); + ensure_equals("still chosen", list->chosen(), std::string("A")); + ensure_equals("two selected", list->selection().size(), size_t(2)); + list->cellPressed(2, MASK_CONTROL); + ensure_equals("and out again", list->selection().size(), size_t(1)); + list->cellPressed(0, MASK_CONTROL); + ensure_equals("the chosen one cannot be taken out", list->selection().size(), size_t(1)); + + list->cellPressed(3, MASK_SHIFT); + ensure_equals("the run from the chosen one", list->selection().size(), size_t(4)); + ensure_equals("with the chosen one still A", list->chosen(), std::string("A")); + + list->cellPressed(1, MASK_NONE); + ensure_equals("a plain press is one thing", list->selection().size(), size_t(1)); + ensure_equals("that one", list->chosen(), std::string("B")); + ensure("every change was said", changes >= 6); + delete list; + } + template<> template<> + void alspecimenlist_object::test<8>() + { + if (!ui.ok()) { skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); } + auto* list = make(); + list->setCellSize(100, 60); + list->setSpecimens(three()); + ensure("select all is handled", list->handleKeyHere('A', MASK_CONTROL)); + ensure_equals("all visible cells selected", list->selection().size(), size_t(3)); + list->filter("panel"); + ensure("filtered select all is handled", list->handleKeyHere('A', MASK_CONTROL)); + ensure_equals("only the visible cell remains selected", list->selection().size(), size_t(1)); + ensure_equals("the chosen cell follows the visible selection", list->chosen(), std::string("panel")); + delete list; + } + + // A drag from a cell carries the cell that was pressed, which a control + // click leaves short of being the chosen one. + template<> template<> + void alspecimenlist_object::test<9>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALSpecimenList::Params p(LLUICtrlFactory::getDefaultParams()); + p.rect = LLRect(0, 300, 330, 0); + p.cell_width = 100; + p.cell_height = 60; + ALSpecimenList* list = LLUICtrlFactory::create(p); + std::vector pictures; + for (const char* name : { "A", "B", "C" }) + { + ALSpecimenList::Specimen one; + one.group = "Letters"; + one.label = name; + one.value = name; + one.image = name; + pictures.push_back(one); + } + list->setSpecimens(pictures); + std::vector carried; + list->setDragStarter([&carried](const std::string& value) + { + carried.push_back(value); + return true; + }); + + // The cells sit across under their heading: B is the second, at + // the tiles' own height less the heading and half a cell. + LLView* tiles = list->findChild("tiles", true); + const S32 x = 150; + const S32 y = tiles->getRect().getHeight() - 20 - 30; + list->cellPressed(0, MASK_NONE); + tiles->handleMouseDown(x, y, MASK_CONTROL); + ensure_equals("a control click leaves the chosen one", list->chosen(), std::string("A")); + ensure_equals("and takes the pressed one too", list->selection().size(), size_t(2)); + tiles->handleHover(x + 20, y, MASK_CONTROL); + ensure_equals("moved, it carries the pressed one", carried.size(), 1u); + ensure_equals("B, not the chosen A", carried.front(), std::string("B")); + tiles->handleMouseUp(x + 20, y, MASK_CONTROL); + delete list; + } + + // A picture beside a row's label is the row's own, so a list that has + // been rows draws its cells the way one that has not does: from the + // picture, with the specimen's view left to the caller. And a cell size + // with either side missing is no cell size. + template<> template<> + void alspecimenlist_object::test<10>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + ALSpecimenList* list = make(); + std::vector pictures; + ALSpecimenList::Specimen one; + one.group = "Icons"; + one.label = "Icon_Close"; + one.value = "Icon_Close"; + one.image = "Icon_Close"; + pictures.push_back(one); + list->setSpecimens(pictures); + + LLView* row = list->findChild("row_Icon_Close", true); + ensure("the row shows the picture", row != nullptr && row->findChild("picture", true) != nullptr); + + list->setCellSize(100, 0); + ensure("a width alone is not a cell size", !list->cells()); + list->setCellSize(100, 60); + ensure("both sides are", list->cells()); + LLView* tiles = list->findChild("tiles", true); + ensure("the picture stayed with the row", tiles->findChild("picture", true) == nullptr + && row->findChild("picture", true) != nullptr); + delete list; + } } diff --git a/indra/llui/tests/alundostack_test.cpp b/indra/llui/tests/alundostack_test.cpp new file mode 100644 index 00000000000..cfc279a1109 --- /dev/null +++ b/indra/llui/tests/alundostack_test.cpp @@ -0,0 +1,158 @@ +/** + * @file alundostack_test.cpp + * @brief The steps back and forward: joined runs, labels, depth + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alundostack.h" + +#include "../test/lltut.h" + +#include +#include + +namespace tut +{ + // A step here is the names a change touched. + struct Step + { + std::vector mNames; + std::string mLabel; + }; + + struct alundostack_data + { + static void join(Step& last, Step&& step) + { + for (auto& name : step.mNames) + { + if (std::find(last.mNames.begin(), last.mNames.end(), name) == last.mNames.end()) + { + last.mNames.push_back(std::move(name)); + } + } + } + }; + + typedef test_group alundostack_group; + typedef alundostack_group::object alundostack_object; + alundostack_group alundostack_instance("alundostack"); + + // A change noted is a step back; taking it makes it a step forward + // once the caller says so; a new change forgets the steps forward. + template<> template<> + void alundostack_object::test<1>() + { + ALUndoStack stack; + ensure("nothing to undo yet", !stack.canUndo()); + + stack.note({ { "a" } }, "", 0.0, 1.0, join); + stack.note({ { "b" } }, "", 5.0, 1.0, join); + ensure_equals("two steps back", stack.inForce(), size_t(2)); + + std::optional back = stack.takeUndo(); + ensure("one taken", back.has_value() && back->mNames.front() == "b"); + stack.pushRedo(*back); + ensure("and it is the step forward", stack.canRedo()); + ensure_equals("one in force", stack.inForce(), size_t(1)); + + stack.note({ { "c" } }, "", 10.0, 1.0, join); + ensure("a new change forgets the way forward", !stack.canRedo()); + ensure_equals("two in force", stack.inForce(), size_t(2)); + } + + // A run of changes to one thing within the window is one step, grown + // to cover what each change touched; another thing, or a pause, is a + // new step. + template<> template<> + void alundostack_object::test<2>() + { + ALUndoStack stack; + stack.note({ { "slider" } }, "shape:fill", 0.0, 1.0, join); + stack.note({ { "slider" } }, "shape:fill", 0.3, 1.0, join); + stack.note({ { "slider", "shadow" } }, "shape:fill", 0.6, 1.0, join); + ensure_equals("one step for the run", stack.inForce(), size_t(1)); + ensure_equals("grown to cover what the run touched", stack.undone().front().mNames.size(), size_t(2)); + + stack.note({ { "slider" } }, "shape:fill", 2.0, 1.0, join); + ensure_equals("a pause starts another", stack.inForce(), size_t(2)); + stack.note({ { "other" } }, "shape:radius", 2.1, 1.0, join); + ensure_equals("and so does another thing", stack.inForce(), size_t(3)); + stack.note({ { "nothing" } }, "", 2.2, 1.0, join); + stack.note({ { "nothing" } }, "", 2.3, 1.0, join); + ensure_equals("no key never joins", stack.inForce(), size_t(5)); + } + + // The first thing said after a change names it; the next thing said + // is about something else, and a frame later so is anything. + template<> template<> + void alundostack_object::test<3>() + { + ALUndoStack stack; + stack.label("said before anything"); + stack.note({ { "a" } }, "", 0.0, 1.0, join); + stack.label("Set the fill"); + stack.label("Something else"); + ensure_equals("the first word after the change names it", stack.undoLabel(), std::string("Set the fill")); + + stack.note({ { "b" } }, "", 5.0, 1.0, join); + stack.closeLabel(); + stack.label("Too late"); + ensure_equals("a frame later names nothing", stack.undoLabel(), std::string()); + + std::optional back = stack.takeUndo(); + stack.pushRedo(*back); + ensure_equals("the step forward keeps its name", stack.redoLabel(), std::string()); + back = stack.takeUndo(); + stack.pushRedo(*back); + ensure_equals("and so does the next", stack.redoLabel(), std::string("Set the fill")); + } + + // Past the depth the oldest step is forgotten. + template<> template<> + void alundostack_object::test<4>() + { + ALUndoStack stack(3); + for (int i = 0; i < 5; ++i) + { + stack.note({ { std::to_string(i) } }, "", i * 10.0, 1.0, join); + } + ensure_equals("three kept", stack.inForce(), size_t(3)); + ensure_equals("the oldest gone", stack.undone().front().mNames.front(), std::string("2")); + stack.clear(); + ensure("cleared", !stack.canUndo() && !stack.canRedo()); + } + template<> template<> + void alundostack_object::test<5>() + { + ALUndoStack stack; + stack.note({ { "a" } }, "slider", 0.0, 1.0, join); + stack.note({ { "b" } }, "other", 0.1, 1.0, join); + auto step = stack.takeUndo(); + stack.pushRedo(*step); + stack.note({ { "c" } }, "other", 0.2, 1.0, join); + ensure_equals("an edit after undo starts a new step", stack.inForce(), size_t(2)); + ensure_equals("the earlier step is unchanged", stack.undone().front().mNames.size(), size_t(1)); + } + +} diff --git a/indra/llui/tests/alxuidocuments_test.cpp b/indra/llui/tests/alxuidocuments_test.cpp index 40beabb3827..f0590ea866e 100644 --- a/indra/llui/tests/alxuidocuments_test.cpp +++ b/indra/llui/tests/alxuidocuments_test.cpp @@ -374,4 +374,59 @@ namespace tut ensure("closes it with work in the history", open.close(other)); ensure("and the history is gone, since half of it named a file that is", open.history().empty()); } + + // Given a window, a run of edits to one field is one action: a number + // stepped three times comes back in one undo, as the field it is, and + // is put on again in one redo. Another field is another action, an + // Action over several steps is never joined, and an edit after an undo + // is a new run rather than more of the one put back. + template<> template<> + void alxuidocuments_object::test<11>() + { + const std::string base = write("base.xml", panel("a")); + ALXUIDocuments open; + open.setCoalesceWindow(60.0); + ALXUIEdit* held = open.open(base); + ensure("opens", held != nullptr); + + for (const char* value : { "11", "12", "13" }) + { + ensure("writes", held->setAttribute({ "a" }, "width", value)); + open.settle(); + } + std::vector all = open.history(); + ensure_equals("three steps are one action", all.size(), 1u); + ensure_equals("of three steps", all[0].steps, 3); + ensure("to one field", all[0].sameField); + ensure_equals("which it names", all[0].change.field, std::string("width")); + + ensure("writes another field", held->setAttribute({ "a" }, "height", "20")); + open.settle(); + ensure_equals("which is its own action", open.history().size(), 2u); + { + ALXUIDocuments::Action together(open); + ensure("writes", held->setAttribute({ "a" }, "height", "21")); + ensure("writes", held->setAttribute({ "a" }, "height", "22")); + } + all = open.history(); + ensure_equals("an Action is never joined", all.size(), 3u); + ensure("and is not one field", !all[2].sameField); + + ensure("put back", open.undo()); + ensure("put back", open.undo()); + ensure("put back", open.undo()); + ensure_equals("the run went as one", held->resolve({ "a" }).attribute("width").as_int(), 10); + ensure("and says which field it was", open.lastChange().oneField && open.lastChange().field == "width"); + ensure("nothing else to put back", !open.canUndo()); + + ensure("done again", open.redo()); + ensure_equals("the whole run", held->resolve({ "a" }).attribute("width").as_int(), 13); + + ensure("put back", open.undo()); + ensure("writes after an undo", held->setAttribute({ "a" }, "width", "14")); + open.settle(); + all = open.history(); + ensure_equals("a new run, and the one put back is gone", all.size(), 1u); + ensure_equals("of one step", all[0].steps, 1); + } } diff --git a/indra/llui/tests/llaccordionctrltab_test.cpp b/indra/llui/tests/llaccordionctrltab_test.cpp index 5f3bed53edb..f47db66902e 100644 --- a/indra/llui/tests/llaccordionctrltab_test.cpp +++ b/indra/llui/tests/llaccordionctrltab_test.cpp @@ -26,6 +26,7 @@ #include "../llaccordionctrltab.h" +#include "../llaccordionctrl.h" #include "../llpanel.h" #include "../lluictrlfactory.h" #include "../llxuiparser.h" @@ -147,4 +148,114 @@ namespace tut tab->die(); } + + // A fitted panel saying how tall it wants to be is how a tab learns its + // height without anyone writing it down: the tab takes the height plus + // its header and padding, the panel is squeezed to exactly the height it + // asked for, what follows the panel's top stays where it was against it, + // and a tab that is shut opens to the new height when it next opens. + // The Lightbox sizes every one of its sections this way. + template<> template<> + void llaccordionctrltab_object::test<4>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLAccordionCtrlTab::Params tp(LLUICtrlFactory::getDefaultParams()); + tp.name = "tab"; + tp.title = "Tab"; + tp.display_children = true; + tp.fit_panel = true; + tp.rect = LLRect(0, 109, 300, 0); + LLAccordionCtrlTab* tab = LLUICtrlFactory::create(tp); + + LLPanel::Params pp; + pp.name = "rows"; + pp.rect = LLRect(0, 80, 300, 0); + LLPanel* rows = LLUICtrlFactory::create(pp); + LLPanel::Params cp; + cp.name = "row"; + cp.rect = LLRect(8, 72, 100, 56); + cp.follows.flags = FOLLOWS_LEFT | FOLLOWS_TOP; + LLPanel* row = LLUICtrlFactory::create(cp); + rows->addChild(row); + tab->setAccordionView(rows); + + const S32 chrome = tab->getHeaderHeight() + tab->getPaddingTop() + tab->getPaddingBottom(); + const S32 below_top = rows->getRect().getHeight() - row->getRect().mTop; + + rows->notifyParent(LLSD().with("action", "size_changes").with("height", 120)); + ensure_equals("the tab is the height asked for and its chrome", tab->getRect().getHeight(), 120 + chrome); + ensure_equals("the panel is the height it asked for", rows->getRect().getHeight(), 120); + ensure_equals("and the row is as far below its top as it was", + rows->getRect().getHeight() - row->getRect().mTop, below_top); + + tab->setDisplayChildren(false); + rows->notifyParent(LLSD().with("action", "size_changes").with("height", 150)); + ensure("shut, the tab stays shut", tab->getRect().getHeight() < 150); + tab->setDisplayChildren(true); + ensure_equals("and opens to the height asked for while it was shut", + tab->getRect().getHeight(), 150 + chrome); + + tab->die(); + } + + // A hidden tab takes no room. Once its accordion is arranged, the tab + // below closes up under the one above, and showing it and arranging again + // puts it back between them. It is the arrange that moves them, not the + // hiding, so a caller that hides a tab arranges afterwards. The Lightbox + // hides whichever of its bloom and legacy glow sections the renderer is + // not running this way. + template<> template<> + void llaccordionctrltab_object::test<5>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + LLAccordionCtrl::Params ap(LLUICtrlFactory::getDefaultParams()); + ap.name = "accordion"; + ap.rect = LLRect(0, 400, 300, 0); + ap.single_expansion = false; + LLAccordionCtrl* accordion = LLUICtrlFactory::create(ap); + + // Open, and as tall as their rects say: 180 of the 400, so the + // accordion has no scrollbar to move them by. + const auto add_tab = [accordion](const std::string& name, S32 height) + { + LLAccordionCtrlTab::Params tp(LLUICtrlFactory::getDefaultParams()); + tp.name = name; + tp.title = name; + tp.display_children = true; + tp.fit_panel = false; + tp.rect = LLRect(0, height, 300, 0); + LLAccordionCtrlTab* tab = LLUICtrlFactory::create(tp); + LLPanel::Params pp; + pp.name = name + "_rows"; + pp.rect = LLRect(0, 10, 300, 0); + tab->setAccordionView(LLUICtrlFactory::create(pp)); + accordion->addCollapsibleCtrl(tab); + return tab; + }; + LLAccordionCtrlTab* above = add_tab("above", 50); + LLAccordionCtrlTab* middle = add_tab("middle", 60); + LLAccordionCtrlTab* below = add_tab("below", 70); + accordion->arrange(); + ensure_equals("the middle tab is under the first", middle->getRect().mTop, above->getRect().mBottom); + ensure_equals("and the last under it", below->getRect().mTop, middle->getRect().mBottom); + + middle->setVisible(false); + accordion->arrange(); + ensure_equals("hidden, it leaves no gap", below->getRect().mTop, above->getRect().mBottom); + + middle->setVisible(true); + accordion->arrange(); + ensure_equals("shown, it is back under the first", middle->getRect().mTop, above->getRect().mBottom); + ensure_equals("and the last is under it again", below->getRect().mTop, middle->getRect().mBottom); + + accordion->die(); + } } diff --git a/indra/llui/tests/llfloater_test.cpp b/indra/llui/tests/llfloater_test.cpp index 71ae58f4059..f78dbbe4d08 100644 --- a/indra/llui/tests/llfloater_test.cpp +++ b/indra/llui/tests/llfloater_test.cpp @@ -24,6 +24,7 @@ #include "linden_common.h" +#include "../alxuidiagnostics.h" #include "../llfloater.h" #include "../lluictrlfactory.h" @@ -135,4 +136,74 @@ namespace tut fv.reset(); gFloaterView = nullptr; } + + // A floater's strings are parameters written as elements, and every + // file has several: the parser reads each into the block and takes it + // out of the tree so the factory, which builds what is left as child + // widgets, never sees it. Taking one out is a removal from a run of + // same-named siblings, which has to work from anywhere in the run -- + // it once worked only from wherever multimap::find happened to land, + // and the string it missed reached the factory as a widget it could + // not build. Panel strings under a nested panel are the same case one + // level down. + template<> template<> + void llfloater_object::test<3>() + { + if (!ui.ok()) + { + skip("no UI: LLUI_TEST_APP_DIR does not point at the source tree"); + } + + std::unique_ptr fv(floaterView()); + gFloaterView = fv.get(); + + const std::string xml = + "" + "A" + "B" + "C" + "D" + "" + "E" + "F" + "G" + "" + ""; + LLXMLNodePtr root; + ensure("the tree parses", LLXMLNode::parseBuffer(xml.data(), (U32)xml.size(), root)); + + LLFloater::Params fp; + fp.name = "shell"; + fp.rect = LLRect(0, 10, 10, 0); + TestFloater* f = new TestFloater(fp); + + ALXUIDiagnostics sink; + ensure("it builds", f->buildFromXML(root, "floater_of_no_file.xml")); + ensure_equals("nothing reached the factory that it could not build", + sink.count(ALXUIDiagnostics::Kind::CreateFailed), (size_t)0); + + for (const char* name : { "a", "b", "c", "d" }) + { + std::string upper(name); + upper[0] = (char)toupper(upper[0]); + ensure_equals(std::string("floater string ") + name, f->getString(name), upper); + } + LLPanel* inner = f->findChild("inner"); + ensure("the panel was built", inner != nullptr); + for (const char* name : { "e", "f", "g" }) + { + std::string upper(name); + upper[0] = (char)toupper(upper[0]); + ensure_equals(std::string("panel string ") + name, inner->getString(name), upper); + } + + // The strings were taken out of the tree; the panel is what is left. + ensure_equals("one child left under the floater", root->getChildCount(), 1u); + LLXMLNodePtr panel_node; + ensure("and it is the panel", root->getChild("panel", panel_node)); + ensure_equals("with nothing left under it", panel_node->getChildCount(), 0u); + + fv.reset(); + gFloaterView = nullptr; + } } diff --git a/indra/llui/tests/lltabcontainer_test.cpp b/indra/llui/tests/lltabcontainer_test.cpp index 254c74e4f81..a3c2070edfb 100644 --- a/indra/llui/tests/lltabcontainer_test.cpp +++ b/indra/llui/tests/lltabcontainer_test.cpp @@ -880,8 +880,7 @@ namespace tut // shares out only what tab_padding_right leaves; a child that is not a // panel stays a child rather than becoming a tab; and a button in the // kept room gets its own clicks, because the tabs' mouse capture stops - // where that room begins. The Lightbox keeps Scopes, Find and its - // pop-out there. + // where that room begins. The Lightbox keeps its pop-out button there. template<> template<> void lltabcontainer_object::test<28>() { diff --git a/indra/llui/tests/lluicolortable_test.cpp b/indra/llui/tests/lluicolortable_test.cpp index fc9a71edae0..2849ec93605 100644 --- a/indra/llui/tests/lluicolortable_test.cpp +++ b/indra/llui/tests/lluicolortable_test.cpp @@ -230,4 +230,85 @@ namespace tut documents.erase(documents.begin()); ensure("no good file is not", !table.load(documents, nullptr, "user.xml")); } + + // Putting a colour back removes the user's entry rather than copying + // the skin's value into it, and every handle taken before or during + // the edit reads what is in force; a colour only the user made goes + // away with it, its handles reading magenta until it is set again. + template<> template<> + void lluicolortable_object::test<6>() + { + ensure("loads", load({ "" })); + auto& table = LLUIColorTable::instance(); + const LLUIColor before = table.getColor("T6Base"); + table.setColor("T6Base", LLColor4::blue); + const LLUIColor during = table.getColor("T6Base"); + table.resetToDefault("T6Base"); + ensure("reset removes the override", !has(table.getUserColors(), "T6Base")); + ensure("old handles read the default", before.get() == LLColor4::red && during.get() == LLColor4::red); + table.setColor("T6Base", LLColor4::green); + ensure("both handles follow another edit", before.get() == LLColor4::green && during.get() == LLColor4::green); + table.resetToDefault("T6Base"); + table.setColor("T6New", LLColor4::blue); + const LLUIColor added = table.getColor("T6New"); + table.resetToDefault("T6New"); + ensure("reset removes a new user colour too", !has(table.getUserColors(), "T6New")); + ensure("the removed colour's handle stays valid", added.get() == LLColor4::magenta); + ensure("the removed name no longer exists", !table.colorExists("T6New")); + ensure("fresh lookups use the supplied fallback", table.getColor("T6New", LLColor4::yellow).get() == LLColor4::yellow); + ensure_does_not_contain("the removed colour is not saved", written(), "T6New"); + table.setColor("T6New", LLColor4::green); + ensure("redo updates the existing handle", added.get() == LLColor4::green); + } + + // A colour the skins declare as a reference follows its target when the + // user sets the target, at runtime and from the user's document, and + // goes back with it; a colour the user set for itself does not. + template<> template<> + void lluicolortable_object::test<7>() + { + ensure("loads", load({ "\n" + "\n" + "\n" + "\n" })); + auto& table = LLUIColorTable::instance(); + const LLUIColor link = table.getColor("T7Link"); + const LLUIColor chain = table.getColor("T7Chain"); + + table.setColor("T7Own", LLColor4::green); + table.setColor("T7Accent", LLColor4::blue); + ensure("a reference follows the accent it names", link.get() == LLColor4::blue); + ensure("and a reference to that reference", chain.get() == LLColor4::blue); + ensure("a colour the user set keeps it", is("T7Own", 0, 1, 0)); + ensure("the references stay default", table.isDefault("T7Link") && table.isDefault("T7Chain")); + ensure_does_not_contain("and are not written as the user's", written(), "T7Link"); + + table.setColor("T7Link", LLColor4::yellow); + ensure("a chain takes the nearest colour the user set", chain.get() == LLColor4::yellow); + table.resetToDefault("T7Link"); + ensure("and follows the accent again once that is reset", chain.get() == LLColor4::blue); + table.resetToDefault("T7Accent"); + ensure("resetting the accent takes its references back", link.get() == LLColor4::red && chain.get() == LLColor4::red); + + ensure("loads with a user accent", load({ "\n" + "\n" }, + "\n")); + ensure("a reference follows the user's saved accent", is("T7Link", 0, 0, 1)); + } + + // The generation moves with every change and not otherwise. + template<> template<> + void lluicolortable_object::test<8>() + { + ensure("loads", load({ "" })); + auto& table = LLUIColorTable::instance(); + const U32 loaded = table.generation(); + table.resetToDefault("T8Base"); + ensure_equals("putting back what was never set moves nothing", table.generation(), loaded); + table.setColor("T8Base", LLColor4::blue); + ensure("a change moves it", table.generation() > loaded); + const U32 set = table.generation(); + table.resetToDefault("T8Base"); + ensure("and so does putting it back", table.generation() > set); + } } diff --git a/indra/llwebrtc/llwebrtc.cpp b/indra/llwebrtc/llwebrtc.cpp index f5241f493c9..7cf0b54248c 100644 --- a/indra/llwebrtc/llwebrtc.cpp +++ b/indra/llwebrtc/llwebrtc.cpp @@ -536,7 +536,7 @@ bool LLWebRTCImpl::terminate() { for (auto& connection : connections) { - connection->closeOnSignalingThread(); + connection->closeOnSignalingThread(true); } // Destroy the connections here, on the signaling thread, while // it's still running. @@ -829,8 +829,14 @@ void LLWebRTCImpl::workerDeployDevices() // Stop first so the start helpers (which no-op when already running) will // re-select the now-current device. - mDeviceModule->StopPlayout(); - mDeviceModule->ForceStopRecording(); + if (mDeviceModule->Playing()) + { + mDeviceModule->StopPlayout(); + } + if (mDeviceModule->Recording()) + { + mDeviceModule->ForceStopRecording(); + } workerStartRecording(); workerStartPlayout(); @@ -1146,7 +1152,10 @@ void LLWebRTCImpl::freePeerConnection(LLWebRTCPeerConnectionInterface* peer_conn { if (mDeviceModule) { - mDeviceModule->StopPlayout(); + if (mDeviceModule->Playing()) + { + mDeviceModule->StopPlayout(); + } if (!mVoiceEnabled) { mDeviceModule->ForceStopRecording(); @@ -1222,13 +1231,13 @@ void LLWebRTCPeerConnectionImpl::terminate() mWebRTCImpl->PostSignalingTask( [self]() { - self->closeOnSignalingThread(); + self->closeOnSignalingThread(false); self->mPendingJobs--; }); } // Signaling thread only. -void LLWebRTCPeerConnectionImpl::closeOnSignalingThread() +void LLWebRTCPeerConnectionImpl::closeOnSignalingThread(bool webrtc_terminate) { // Stop issuing stats requests; one may already be in flight, and // Close() below will flush it. @@ -1279,13 +1288,16 @@ void LLWebRTCPeerConnectionImpl::closeOnSignalingThread() observer->OnPeerConnectionClosed(); } - // Nothing may call back into the viewer past this point. Connections - // closed while the viewer is still running unset themselves as observers - // when they're destroyed, but any that are left for llwebrtc::terminate() - // to close deliberately don't -- they're torn down as soon as it returns, - // so a late callback would be reaching into freed memory. - mSignalingObserverList.clear(); - mDataObserverList.clear(); + if (webrtc_terminate) + { + // Nothing may call back into the viewer past this point. Connections + // closed while the viewer is still running unset themselves as observers + // when they're destroyed, but any that are left for llwebrtc::terminate() + // to close deliberately don't -- they're torn down as soon as it returns, + // so a late callback would be reaching into freed memory. + mSignalingObserverList.clear(); + mDataObserverList.clear(); + } } void LLWebRTCPeerConnectionImpl::setSignalingObserver(LLWebRTCSignalingObserver *observer) { mSignalingObserverList.emplace_back(observer); } @@ -1311,6 +1323,12 @@ bool LLWebRTCPeerConnectionImpl::initializeConnection(const LLWebRTCPeerConnecti mWebRTCImpl->PostSignalingTask( [self,options]() { + // A connection closed for a retry is this same object, and the + // close raised the flag that quiets the stats poll: lowered + // here, on the thread that raised it, or the new connection + // would report no stats once it connects. + self->mShuttingDown = false; + webrtc::PeerConnectionInterface::RTCConfiguration config; for (auto server : options.mServers) { diff --git a/indra/llwebrtc/llwebrtc_impl.h b/indra/llwebrtc/llwebrtc_impl.h index 16c9c8f831f..dfcc48a1bcb 100644 --- a/indra/llwebrtc/llwebrtc_impl.h +++ b/indra/llwebrtc/llwebrtc_impl.h @@ -630,10 +630,13 @@ class LLWebRTCPeerConnectionImpl : public LLWebRTCPeerConnectionInterface, void init(LLWebRTCImpl * webrtc_impl); // Posts closeOnSignalingThread() and returns immediately. void terminate(); + // The actual close. Signaling thread only. Callable directly (via a // BlockingCall) when the caller needs the connection to be fully closed // before it continues -- see LLWebRTCImpl::terminate(). - void closeOnSignalingThread(); + // webrtc_terminate indicates we're shutting down the webrtc library, + // if not, we'll be reusing the peer connection for a reconnection + void closeOnSignalingThread(bool webrtc_terminate); virtual void AddRef() const override = 0; virtual webrtc::RefCountReleaseStatus Release() const override = 0; diff --git a/indra/llxml/llcontrol.cpp b/indra/llxml/llcontrol.cpp index 275f87cf26c..7ecd26c8669 100644 --- a/indra/llxml/llcontrol.cpp +++ b/indra/llxml/llcontrol.cpp @@ -44,6 +44,7 @@ #include "llrect.h" #include "llxmltree.h" #include "llsdserialize.h" +#include "llsdutil.h" #include "llfile.h" #include "lltimer.h" #include "lldir.h" @@ -104,6 +105,60 @@ settings_vec_t getCount_v; F64 start_time = 0; std::string SETTINGS_PROFILE = "settings_profile.log"; +namespace +{ +// llsd_equals, except that an Integer and a Real holding the same number are +// equal. For a setting they are one value: a point list that arrived as +// [[0,0],[1,1]] from the notation parser or a hand edit is the same curve as +// its real-typed default, and must count as at default and as unchanged. +bool llsd_settings_equal(const LLSD& a, const LLSD& b) +{ + const bool a_number = a.isInteger() || a.isReal(); + const bool b_number = b.isInteger() || b.isReal(); + if (a_number && b_number) + { + return a.asReal() == b.asReal(); + } + if (a.type() != b.type()) + { + return false; + } + if (a.isArray()) + { + if (a.size() != b.size()) + { + return false; + } + LLSD::array_const_iterator ai = a.beginArray(); + LLSD::array_const_iterator bi = b.beginArray(); + for (; ai != a.endArray() && bi != b.endArray(); ++ai, ++bi) + { + if (!llsd_settings_equal(*ai, *bi)) + { + return false; + } + } + return true; + } + if (a.isMap()) + { + if (a.size() != b.size()) + { + return false; + } + for (LLSD::map_const_iterator it = a.beginMap(); it != a.endMap(); ++it) + { + if (!b.has(it->first) || !llsd_settings_equal(it->second, b[it->first])) + { + return false; + } + } + return true; + } + return llsd_equals(a, b); +} +} // namespace + bool LLControlVariable::llsd_compare(const LLSD& a, const LLSD & b) { bool result = false; @@ -138,6 +193,13 @@ bool LLControlVariable::llsd_compare(const LLSD& a, const LLSD & b) case TYPE_STRING: result = a.asString() == b.asString(); break; + case TYPE_LLSD: + // Deep equality by value, so that a write of the value already held + // fires nothing, a control holding its default is at default and not + // persisted, and a reset from a value equal to the default is not a + // change. + result = llsd_settings_equal(a, b); + break; default: break; } diff --git a/indra/llxml/llxmlnode.cpp b/indra/llxml/llxmlnode.cpp index e7da2ec52b7..a326b9f6e16 100644 --- a/indra/llxml/llxmlnode.cpp +++ b/indra/llxml/llxmlnode.cpp @@ -161,8 +161,12 @@ bool LLXMLNode::removeChild(LLXMLNode *target_child) } else if (mChildren.notNull()) { - LLXMLChildList::iterator children_itr = mChildren->map.find(target_child->mName); - while (children_itr != mChildren->map.end()) + // Siblings share names, so the walk must begin at the first entry + // under this one, and multimap::find promises only some entry with + // the key: libc++ 22 returns whichever the tree descent reaches, so + // a walk from there can start past the target and miss it. + auto [children_itr, children_end] = mChildren->map.equal_range(target_child->mName); + for (; children_itr != children_end; ++children_itr) { if (target_child == children_itr->second) { @@ -190,14 +194,6 @@ bool LLXMLNode::removeChild(LLXMLNode *target_child) } return true; } - else if (children_itr->first != target_child->mName) - { - break; - } - else - { - ++children_itr; - } } } return false; @@ -937,8 +933,11 @@ bool LLXMLNode::getChild(const LLStringTableEntry* name, LLXMLNodePtr& node, boo { if (mChildren.notNull()) { - LLXMLChildList::const_iterator child_itr = mChildren->map.find(name); - if (child_itr != mChildren->map.end()) + // The first child of the name in document order: equal keys sit in + // insertion order, and lower_bound is the front of the run where + // find would land anywhere in it. + LLXMLChildList::const_iterator child_itr = mChildren->map.lower_bound(name); + if (child_itr != mChildren->map.end() && child_itr->first == name) { node = (*child_itr).second; return true; @@ -961,20 +960,11 @@ void LLXMLNode::getChildren(const LLStringTableEntry* name, LLXMLNodeList &child { if (mChildren.notNull()) { - LLXMLChildList::const_iterator child_itr = mChildren->map.find(name); - if (child_itr != mChildren->map.end()) + auto [child_itr, children_end] = mChildren->map.equal_range(name); + for (; child_itr != children_end; ++child_itr) { - LLXMLChildList::const_iterator children_end = mChildren->map.end(); - while (child_itr != children_end) - { - LLXMLNodePtr child = (*child_itr).second; - if (name != child->mName) - { - break; - } - children.emplace(child->mName->mString, child); - child_itr++; - } + const LLXMLNodePtr& child = child_itr->second; + children.emplace(child->mName->mString, child); } } if (children.size() == 0 && use_default_if_missing && !mDefault.isNull()) diff --git a/indra/llxml/tests/llcontrol_test.cpp b/indra/llxml/tests/llcontrol_test.cpp index e15117fc29e..0b28e20ff55 100644 --- a/indra/llxml/tests/llcontrol_test.cpp +++ b/indra/llxml/tests/llcontrol_test.cpp @@ -151,4 +151,52 @@ namespace tut ensure("listener fired on changed setting", mListenerFired); } + + // An LLSD-typed setting compares by value, and numbers compare as + // numbers: an integer-typed list equal to the real-typed default is at + // default, and writing it changes nothing and fires nothing. + template<> template<> + void control_group_t::test<5>() + { + auto pair_of = [](const LLSD& x, const LLSD& y) { + LLSD p = LLSD::emptyArray(); + p.append(x); + p.append(y); + return p; + }; + LLSD real_default = LLSD::emptyArray(); + real_default.append(pair_of(LLSD::Real(0.0), LLSD::Real(0.0))); + real_default.append(pair_of(LLSD::Real(1.0), LLSD::Real(1.0))); + mCG->declareLLSD("CurveSetting", real_default, "Dummy LLSD setting", LLControlVariable::PERSIST_NONDFT); + LLControlVariable* control = mCG->getControl("CurveSetting"); + ensure("declared at default", control->isDefault()); + + bool fired = false; + control->getSignal()->connect([&fired](LLControlVariable*, const LLSD&, const LLSD&) { fired = true; }); + + LLSD same = LLSD::emptyArray(); + same.append(pair_of(LLSD::Real(0.0), LLSD::Real(0.0))); + same.append(pair_of(LLSD::Real(1.0), LLSD::Real(1.0))); + control->setValue(same, true); + ensure("an equal value does not fire", !fired); + ensure("and leaves the control at default", control->isDefault()); + + LLSD integers = LLSD::emptyArray(); + integers.append(pair_of(LLSD::Integer(0), LLSD::Integer(0))); + integers.append(pair_of(LLSD::Integer(1), LLSD::Integer(1))); + control->setValue(integers, true); + ensure("an integer-typed equal value does not fire", !fired); + ensure("and is still at default", control->isDefault()); + + LLSD other = LLSD::emptyArray(); + other.append(pair_of(LLSD::Real(0.0), LLSD::Real(0.0))); + other.append(pair_of(LLSD::Real(0.5), LLSD::Real(0.25))); + other.append(pair_of(LLSD::Real(1.0), LLSD::Real(1.0))); + control->setValue(other, true); + ensure("a different value fires", fired); + ensure("and is no longer default", !control->isDefault()); + + control->resetToDefault(true); + ensure("reset restores the default", control->isDefault()); + } } diff --git a/indra/llxml/tests/llxmlnode_test.cpp b/indra/llxml/tests/llxmlnode_test.cpp index a0bb13bfaa7..9b38d064098 100644 --- a/indra/llxml/tests/llxmlnode_test.cpp +++ b/indra/llxml/tests/llxmlnode_test.cpp @@ -31,6 +31,8 @@ #include #include +#include +#include namespace tut { @@ -223,4 +225,90 @@ namespace tut ensure("getChild c", node->getChild("c", c)); ensure_equals("c line", c->getLineNumber(), 3); } + + // Siblings that share a name. The child map is a multimap keyed by the + // name, and multimap::find promises only some entry with the key: since + // libc++ 22 it is whichever the tree descent reaches rather than the + // first, so anything that walked forward from find() could start past + // the child it wanted. Every same-named child must be deletable whatever + // its place in the run, getChild must answer the first in document + // order, and getChildren all of them. Eight children make the tree deep + // enough that a descent rarely lands on the first. + template<> template<> + void llxmlnode_object::test<13>() + { + const std::string xml = + "" + "" + "" + "" + ""; + LLXMLNodePtr root; + ensure("parse", LLXMLNode::parseBuffer(xml.data(), xml.size(), root, nullptr)); + ensure_equals("nine children", root->getChildCount(), 9u); + + LLXMLNodePtr first; + ensure("getChild finds one", root->getChild("s", first)); + std::string n; + ensure("it has n", first->getAttributeString("n", n)); + ensure_equals("and is the first in the file", n, std::string("0")); + + LLXMLNodeList all; + root->getChildren("s", all); + ensure_equals("getChildren finds them all", all.size(), 8u); + + // Delete in document order: each deletion is of the front of the + // run, which is where a descent is least likely to land. + for (int expected = 0; expected < 8; ++expected) + { + LLXMLNodePtr child; + ensure("still one to find", root->getChild("s", child)); + ensure("attribute", child->getAttributeString("n", n)); + ensure_equals("in document order", n, std::to_string(expected)); + ensure("deleted", root->deleteChild(child)); + ensure("and orphaned", child->mParent == nullptr); + ensure_equals("count follows", root->getChildCount(), (U32)(8 - expected)); + + // The sibling list agrees with the map about what is left. + U32 listed = 0; + for (LLXMLNodePtr c = root->getFirstChild(); c.notNull(); c = c->getNextSibling()) + { + ++listed; + } + ensure_equals("sibling list agrees", listed, root->getChildCount()); + } + + LLXMLNodePtr none; + ensure("no s is left", !root->getChild("s", none)); + LLXMLNodePtr other; + ensure("the other child remains", root->getChild("other", other)); + } + + // The same run deleted from the back and from the middle, so the + // target is never at the front of the equal range either. + template<> template<> + void llxmlnode_object::test<14>() + { + const std::string xml = + "" + "" + "" + ""; + LLXMLNodePtr root; + ensure("parse", LLXMLNode::parseBuffer(xml.data(), xml.size(), root, nullptr)); + + std::vector children; + for (LLXMLNodePtr c = root->getFirstChild(); c.notNull(); c = c->getNextSibling()) + { + children.push_back(c); + } + ensure_equals("eight in the list", children.size(), 8u); + + for (size_t index : { 7u, 3u, 0u, 5u, 1u, 6u, 2u, 4u }) + { + ensure("deleted from wherever it sits", root->deleteChild(children[index])); + } + ensure_equals("none left", root->getChildCount(), 0u); + ensure("and the list is empty", root->getFirstChild().isNull()); + } } diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 79688ab0a10..6168e9aa86a 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -162,8 +162,10 @@ if(AL_BUILD_VIEWER) alfloaterwebprofile.cpp alfloaterxuilibrary.cpp alfloaterxuistudio.cpp + alviewcapture.cpp algradehistory.cpp allegacynotificationwellwindow.cpp + allightboxdirectory.cpp almotionpredictor.cpp alobjectproperties.cpp alagentlocationformat.cpp @@ -320,7 +322,6 @@ if(AL_BUILD_VIEWER) llfloaterassetrecovery.cpp llfloateravatarwelcomepack.cpp llfloaterbvhpreview.cpp - llfloateraddpaymentmethod.cpp llfloaterauction.cpp llfloaterautoreplacesettings.cpp llfloateravatarpicker.cpp @@ -949,8 +950,10 @@ if(AL_BUILD_VIEWER) alfloaterwebprofile.h alfloaterxuilibrary.h alfloaterxuistudio.h + alviewcapture.h algradehistory.h allegacynotificationwellwindow.h + allightboxdirectory.h almotionpredictor.h alobjectproperties.h alagentlocationformat.h @@ -1110,7 +1113,6 @@ if(AL_BUILD_VIEWER) llfloaterassetrecovery.h llfloateravatarwelcomepack.h llfloaterbvhpreview.h - llfloateraddpaymentmethod.h llfloaterauction.h llfloaterautoreplacesettings.h llfloateravatarpicker.h diff --git a/indra/newview/alcurveeditorctrl.cpp b/indra/newview/alcurveeditorctrl.cpp index fbdb303cd13..cd7dba131f0 100644 --- a/indra/newview/alcurveeditorctrl.cpp +++ b/indra/newview/alcurveeditorctrl.cpp @@ -30,6 +30,7 @@ #include "lllocalcliprect.h" #include "llrender.h" #include "llrender2dutils.h" +#include "llstring.h" #include "lluicolortable.h" #include "llwindow.h" @@ -53,7 +54,9 @@ ALCurveEditorCtrl::Params::Params() curve_samples("curve_samples", 96), handle_radius("handle_radius", 4), curve_width("curve_width", 1.6f), - draw_diagonal("draw_diagonal", true) + draw_diagonal("draw_diagonal", true), + points_editable("points_editable", false), + edited_settings("edited_settings") { changeDefault(mouse_opaque, true); } @@ -69,8 +72,12 @@ ALCurveEditorCtrl::ALCurveEditorCtrl(const ALCurveEditorCtrl::Params& p) mCurveSamples(llmax(2, p.curve_samples())), mHandleRadius(llmax(2, p.handle_radius())), mCurveWidth(llmax(0.5f, p.curve_width())), - mDrawDiagonal(p.draw_diagonal) + mDrawDiagonal(p.draw_diagonal), + mPointsEditable(p.points_editable) { + // "A,B, C" -> {A, B, C}: runs of separators collapse, so a space after a + // comma in the XUI is harmless. + LLStringUtil::getTokens(p.edited_settings(), mSettingNames, ", "); } void ALCurveEditorCtrl::addGhostCurve(curve_fn_t fn, const LLColor4& color) @@ -83,18 +90,6 @@ void ALCurveEditorCtrl::addFillCurve(curve_fn_t fn, const LLColor4& color) mFills.emplace_back(std::move(fn), color); } -std::string ALCurveEditorCtrl::getActiveHandleName() const -{ - if (mDragIndex < 0 || mDragIndex >= (S32)mHandles.size()) - { - return std::string(); - } - // By value on purpose: a consumer typically reacts to the commit by - // writing a setting and calling setHandles(), which would leave a - // reference into the old vector dangling. - return mHandles[mDragIndex].mName; -} - LLRect ALCurveEditorCtrl::plotRect() const { LLRect rect = getLocalRect(); @@ -173,6 +168,8 @@ bool ALCurveEditorCtrl::handleMouseDown(S32 x, S32 y, MASK mask) graphToPixel(mHandles[index].mX, mHandles[index].mY, hx, hy); mGrabOffsetX = x - hx; mGrabOffsetY = y - hy; + mLastPointerX = x; + mLastPointerY = y; gFocusMgr.setMouseCapture(this); setFocus(true); @@ -185,10 +182,25 @@ bool ALCurveEditorCtrl::handleHover(S32 x, S32 y, MASK mask) // control to the consumer, which is free to replace the handle list. if (gFocusMgr.getMouseCapture() != this || mDragIndex < 0 || mDragIndex >= (S32)mHandles.size()) { - getWindow()->setCursor(UI_CURSOR_ARROW); + // Not dragging: remember the handle under the pointer, so the target + // of a double-click is visible before it is clicked. + mHoverIndex = getEnabled() ? hitTest(x, y) : -1; + getWindow()->setCursor(mHoverIndex >= 0 ? UI_CURSOR_HAND : UI_CURSOR_ARROW); return true; } + // The viewer calls the captor's handleHover every frame (updateUI in + // llviewerwindow.cpp), not only on motion. A press that never moves must + // not commit: it would rewrite the value at pixel resolution, dirty the + // Look and put a step on the undo stack for a click -- and the first + // half of a double-click is exactly such a press. + if (x == mLastPointerX && y == mLastPointerY) + { + return true; + } + mLastPointerX = x; + mLastPointerY = y; + F32 gx, gy; pixelToGraph(x - mGrabOffsetX, y - mGrabOffsetY, gx, gy); @@ -205,7 +217,9 @@ bool ALCurveEditorCtrl::handleHover(S32 x, S32 y, MASK mask) // up next frame, and the drag previews live. The handle list is the // widget's own state, so a consumer that clamps differently is free to // write back a corrected position via setHandles(). + mAction = ACTION_DRAG; onCommit(); + mAction = ACTION_NONE; getWindow()->setCursor(UI_CURSOR_ARROW); return true; @@ -223,6 +237,61 @@ bool ALCurveEditorCtrl::handleMouseUp(S32 x, S32 y, MASK mask) return true; } +bool ALCurveEditorCtrl::handleDoubleClick(S32 x, S32 y, MASK mask) +{ + // Without point editing the second press stays inert, as it is today: the + // widget is mouse_opaque, so the parent chain reports the event handled + // and the single-click fallback in LLViewerWindow never runs. + if (!mPointsEditable || !getEnabled()) + { + return LLUICtrl::handleDoubleClick(x, y, mask); + } + + // The first press and its release were delivered before this (DOWN, UP, + // DBLCLK, UP on Win32 and SDL alike), so no drag is live. Drop capture + // anyway: a REMOVE must not leave a drag pointing at a handle that is + // about to go. + if (gFocusMgr.getMouseCapture() == this) + { + gFocusMgr.setMouseCapture(nullptr); + } + mDragIndex = -1; + + const S32 index = hitTest(x, y); + if (index >= 0) + { + // Name the target through the same API a drag uses, for the duration + // of the callback only. + mDragIndex = index; + mAction = ACTION_REMOVE; + onCommit(); + mAction = ACTION_NONE; + mDragIndex = -1; + mHoverIndex = -1; // the list may be one shorter; the next hover re-finds it + } + else if (plotRect().pointInRect(x, y)) + { + pixelToGraph(x, y, mActionX, mActionY); + mAction = ACTION_ADD; + onCommit(); + mAction = ACTION_NONE; + mHoverIndex = -1; // the list may be one longer; the next hover re-finds it + } + // A double-click on the frame outside the plot asks for nothing. The plot + // is inset by the handle radius, and pixelToGraph clamps, so a click out + // there would resolve to an exact axis extreme and plant a point on the + // edge -- a click that missed the graph, not a request for a point. + + setFocus(true); + return true; +} + +void ALCurveEditorCtrl::onMouseLeave(S32 x, S32 y, MASK mask) +{ + mHoverIndex = -1; + LLUICtrl::onMouseLeave(x, y, mask); +} + std::vector ALCurveEditorCtrl::sampleCurve(const curve_fn_t& fn) const { std::vector pts; @@ -321,7 +390,8 @@ void ALCurveEditorCtrl::draw() // Filled disc first, then an anti-aliased ring over its edge. The // fan's own outline is aliased and there is no cheap way to feather a // fan, but the ring sits exactly on that edge and covers it. - const bool active = (i == mDragIndex); + const bool active = (i == mDragIndex); + const bool hovered = !active && (i == mHoverIndex); gGL.color4fv(handle.mColor.mV); gl_circle_2d(c.mV[VX], c.mV[VY], (F32)mHandleRadius, 16, true); @@ -334,7 +404,13 @@ void ALCurveEditorCtrl::draw() ring.emplace_back(c.mV[VX] + cosf(a) * (F32)mHandleRadius, c.mV[VY] + sinf(a) * (F32)mHandleRadius); } - gl_polyline_2d(ring, active ? LLColor4::white : mHandleColor.get(), 1.5f, true); + // The ring, not the disc, carries the highlight: the disc is the + // handle's own colour, which a channel or a band may be using to say + // what it is. + gl_polyline_2d(ring, + active ? LLColor4::white + : hovered ? LLColor4(1.f, 1.f, 1.f, 0.8f) : mHandleColor.get(), + hovered ? 2.5f : 1.5f, true); } gl_rect_2d(bounds, mBorderColor.get(), false); diff --git a/indra/newview/alcurveeditorctrl.h b/indra/newview/alcurveeditorctrl.h index ad24f254db0..d1992f29057 100644 --- a/indra/newview/alcurveeditorctrl.h +++ b/indra/newview/alcurveeditorctrl.h @@ -44,14 +44,22 @@ * * Both axes run 0..1 with y up, i.e. graph coordinates, not screen ones. * - * Handles carry axis locks because most real curves have some. A filmic toe - * moves only horizontally; a locked spline endpoint moves only vertically. - * Rather than have every consumer re-derive that from its own model, the lock - * rides on the handle and the drag honours it. + * Handles carry axis locks because most real curves have some. A split-tone + * edge moves only horizontally; a locked spline endpoint moves only + * vertically; an edge held at the plot boundary moves not at all. Rather than + * have every consumer re-derive that from its own model, the lock rides on + * the handle and the drag honours it. * * A drag fires the commit callback on every mouse move (so the preview tracks * the pointer, which is the whole point of a graph) with @ref getActiveHandle - * naming which one moved. + * naming which one moved. A press that never moves commits nothing. + * + * With @c points_editable set, a double-click on empty plot area asks for a + * point there and a double-click on a handle asks for its removal. The widget + * still owns no curve: it reports the request through @ref getAction and the + * consumer decides against its own model whether to honour it, then hands back + * a new handle list. Handles under the pointer are drawn brighter so the + * target of a double-click is visible before it is clicked. */ class ALCurveEditorCtrl final : public LLUICtrl { @@ -65,7 +73,7 @@ class ALCurveEditorCtrl final : public LLUICtrl F32 mY = 0.f; ///< 0..1, bottom to top bool mLockX = false; ///< drag cannot change x bool mLockY = false; ///< drag cannot change y - std::string mName; ///< reported to the consumer on commit + std::string mName; ///< for the consumer to tell handles apart LLColor4 mColor = LLColor4::white; }; @@ -86,6 +94,16 @@ class ALCurveEditorCtrl final : public LLUICtrl Optional handle_radius; Optional curve_width; Optional draw_diagonal; + /// Double-click adds a point on empty plot area and removes the handle + /// under the pointer, reported through getAction(). Off by default: a + /// graph whose handles stand for fixed things (the split-tone bands) + /// has nothing to add. + Optional points_editable; + /// Comma-separated names of the settings this graph edits. The widget + /// never reads them; they exist so a consumer that resets a section by + /// walking its widgets can find settings that are not bound through + /// control_name (an LLSD point list cannot be). + Optional edited_settings; Params(); }; @@ -97,6 +115,8 @@ class ALCurveEditorCtrl final : public LLUICtrl bool handleMouseDown(S32 x, S32 y, MASK mask) override; bool handleMouseUp(S32 x, S32 y, MASK mask) override; bool handleHover(S32 x, S32 y, MASK mask) override; + bool handleDoubleClick(S32 x, S32 y, MASK mask) override; + void onMouseLeave(S32 x, S32 y, MASK mask) override; /// The curve to plot. Passing an empty function draws handles only. void setCurve(curve_fn_t fn) { mCurve = std::move(fn); } @@ -114,14 +134,42 @@ class ALCurveEditorCtrl final : public LLUICtrl void clearFillCurves() { mFills.clear(); } void addFillCurve(curve_fn_t fn, const LLColor4& color); - void setHandles(std::vector handles) { mHandles = std::move(handles); } + void setHandles(std::vector handles) + { + mHandles = std::move(handles); + // A shorter list must not leave the highlight pointing past its end. + if (mHoverIndex >= (S32)mHandles.size()) + { + mHoverIndex = -1; + } + } const std::vector& getHandles() const { return mHandles; } /// Which handle the pointer is dragging, or -1 between drags. Valid inside /// the commit callback; that is how a consumer tells one handle's move - /// from another's without diffing the whole list. + /// from another's without diffing the whole list. During an ACTION_REMOVE + /// it names the handle to remove. S32 getActiveHandle() const { return mDragIndex; } - std::string getActiveHandleName() const; + + /// What the commit callback is being asked to do. Valid only inside the + /// callback; ACTION_NONE between commits. + /// - ACTION_DRAG: getActiveHandle() moved; read its new mX / mY. + /// - ACTION_ADD: a point is wanted at getActionX() / getActionY(). + /// - ACTION_REMOVE: getActiveHandle() names the handle to remove. + enum EAction + { + ACTION_NONE, + ACTION_DRAG, + ACTION_ADD, + ACTION_REMOVE, + }; + EAction getAction() const { return mAction; } + F32 getActionX() const { return mActionX; } + F32 getActionY() const { return mActionY; } + + /// The settings named in XUI as the ones this graph edits. See the + /// edited_settings param. + const std::vector& getEditedSettingNames() const { return mSettingNames; } private: /// Graph coordinates (0..1, y up) to local widget pixels and back. The @@ -161,6 +209,19 @@ class ALCurveEditorCtrl final : public LLUICtrl S32 mDragIndex = -1; S32 mGrabOffsetX = 0; S32 mGrabOffsetY = 0; + + bool mPointsEditable = false; + std::vector mSettingNames; + EAction mAction = ACTION_NONE; + F32 mActionX = 0.f; + F32 mActionY = 0.f; + /// Handle under the pointer between drags, or -1. + S32 mHoverIndex = -1; + /// Pointer position at the last drag commit, so a press that does not + /// move never commits: the viewer calls a captured widget's handleHover + /// every frame, not only on motion. + S32 mLastPointerX = -1; + S32 mLastPointerY = -1; }; #endif // AL_CURVEEDITORCTRL_H diff --git a/indra/newview/alcurvemodel.cpp b/indra/newview/alcurvemodel.cpp index 56ae431c801..60524660538 100644 --- a/indra/newview/alcurvemodel.cpp +++ b/indra/newview/alcurvemodel.cpp @@ -1,6 +1,6 @@ /** * @file alcurvemodel.cpp - * @brief Curve shapes for the curve editor widget + * @brief Curve shapes for the curve editor widget and the tone curve bake * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -31,40 +31,55 @@ #include #include +// ============================================================================= +// Split-tone ramps +// ============================================================================= + // static -F32 ALCurveModel::smoothstep(F32 x, F32 toe, F32 shoulder, F32 strength) +ALCurveModel::SplitToneRamp ALCurveModel::splitToneRamp(F32 edge0, F32 width) { - // cg_sCurve, colorGradeUtilF.glsl. The reciprocal is guarded the same way - // pipeline.cpp guards it when it uploads uCurveInvRange, so a shoulder at - // or below the toe degenerates identically on both sides: the ramp becomes - // a step at the toe rather than a division by zero. - const F32 inv_range = 1.f / llmax(shoulder - toe, 1e-4f); - const F32 t = llclamp((x - toe) * inv_range, 0.f, 1.f); - const F32 s = t * t * (3.f - 2.f * t); - const F32 k = llclamp(strength, 0.f, 1.f); - return x + (s - x) * k; + // The reciprocal is guarded here and nowhere else, so the upload in + // pipeline.cpp and the bands the Lightbox draws degenerate identically: a + // zero width is a step at edge0, not a division by zero. + const F32 w = llmax(width, SPLIT_TONE_MIN_WIDTH); + SplitToneRamp ramp; + ramp.mScale = 1.f / w; + ramp.mBias = -edge0 / w; + return ramp; } -namespace +// static +ALCurveModel::SplitToneRamp ALCurveModel::splitToneShadowRamp(F32 mid, F32 width) +{ + // Floor before forming the left edge, so edge0 + w lands on mid exactly. + // Flooring inside splitToneRamp alone would leave the ramp ending short + // of the split for a sub-floor width, and the shadow and highlight bands + // would then overlap there. + const F32 w = llmax(width, SPLIT_TONE_MIN_WIDTH); + return splitToneRamp(mid - w, w); +} + +// static +ALCurveModel::SplitToneRamp ALCurveModel::splitToneHighlightRamp(F32 mid, F32 width) { -/// GLSL's smoothstep: the clamped Hermite, edges given rather than derived. -/// Guarded against a zero-width pair, which GLSL leaves undefined; ours are -/// always SPLIT_TONE_HALF_WIDTH apart, so the guard never engages in practice -/// and exists so a future caller cannot make it divide by zero. -F32 hermiteStep(F32 edge0, F32 edge1, F32 x) + return splitToneRamp(mid, width); +} + +// static +F32 ALCurveModel::splitToneRampValue(F32 l, const SplitToneRamp& ramp) { - const F32 t = llclamp((x - edge0) / llmax(edge1 - edge0, 1e-6f), 0.f, 1.f); + // cg_ramp, colorGradeUtilF.glsl. + const F32 t = llclamp(l * ramp.mScale + ramp.mBias, 0.f, 1.f); return t * t * (3.f - 2.f * t); } -} // namespace // static -ALCurveModel::SplitToneWeights ALCurveModel::splitToneWeights(F32 l, F32 mid) +ALCurveModel::SplitToneWeights ALCurveModel::splitToneWeights(F32 l, F32 mid, F32 shadow_width, F32 highlight_width) { // applySplitToning, colorGradeUtilF.glsl. SplitToneWeights w; - w.mHighlight = hermiteStep(mid, mid + SPLIT_TONE_HALF_WIDTH, l); - w.mShadow = 1.f - hermiteStep(mid - SPLIT_TONE_HALF_WIDTH, mid, l); + w.mHighlight = splitToneRampValue(l, splitToneHighlightRamp(mid, highlight_width)); + w.mShadow = 1.f - splitToneRampValue(l, splitToneShadowRamp(mid, shadow_width)); w.mMidtone = llmax(1.f - w.mHighlight - w.mShadow, 0.f); return w; } @@ -81,12 +96,9 @@ F32 ALCurveModel::splitToneBalance(F32 mid) return llclamp((mid - 0.5f) / 0.4f, -1.f, 1.f); } -void ALCurveModel::setSmoothstep(F32 toe, F32 shoulder, F32 strength) -{ - mToe = toe; - mShoulder = shoulder; - mStrength = strength; -} +// ============================================================================= +// Control points +// ============================================================================= void ALCurveModel::setPoints(std::vector points) { @@ -102,9 +114,21 @@ void ALCurveModel::setPoints(std::vector points) S32 ALCurveModel::addPoint(F32 x, F32 y) { + if (getPointCount() >= MAX_POINTS) + { + return -1; + } + Point p; p.mX = llclamp(x, 0.f, 1.f); p.mY = llclamp(y, 0.f, 1.f); + if (mEndpointsLocked) + { + // Strictly inside the pinned ends. A point at exactly 1 would sort + // after the right end and then be the one pinned there, leaving the + // old end interior and the curve ending on the new height. + p.mX = llclamp(p.mX, MIN_POINT_GAP, 1.f - MIN_POINT_GAP); + } auto it = std::upper_bound(mPoints.begin(), mPoints.end(), p, [](const Point& a, const Point& b) { return a.mX < b.mX; }); @@ -197,10 +221,22 @@ void ALCurveModel::normalize() mPoints.back().mX = 1.f; } - // Push coincident points apart from the left. Walking left to right keeps - // the ordering the sort established; the trailing clamp to 1 can only - // matter if a caller supplied more points than 1/MIN_POINT_GAP allows, in - // which case the tail collapses onto 1 and evaluate() still terminates. + // Cap first, keeping both ends: the last point is the one pinned to x = 1, + // so dropping it would leave a locked curve that no longer spans the + // domain. Only a caller feeding setPoints directly can reach this; the + // LLSD path rejects an oversized list outright. + if (mPoints.size() > static_cast(MAX_STORED_POINTS)) + { + mPoints.erase(mPoints.begin() + (MAX_STORED_POINTS - 1), mPoints.end() - 1); + } + + // Push coincident points apart: left to right, then right to left for + // whatever the first pass could only pile up against 1. A pinned end is + // already at 1 before this runs, so a second point there has nowhere to + // go on the first pass; the second moves it, and any run behind it, down + // instead. Both passes keep the order the sort established, and + // MAX_STORED_POINTS gaps fit in the domain many times over, so the clamps + // at 0 and 1 never engage in practice. for (size_t i = 1; i < mPoints.size(); ++i) { const F32 floor_x = mPoints[i - 1].mX + MIN_POINT_GAP; @@ -209,17 +245,27 @@ void ALCurveModel::normalize() mPoints[i].mX = llmin(floor_x, 1.f); } } + if (mPoints.size() >= 2) + { + for (size_t i = mPoints.size() - 1; i-- > 0;) + { + const F32 ceil_x = mPoints[i + 1].mX - MIN_POINT_GAP; + if (mPoints[i].mX > ceil_x) + { + mPoints[i].mX = llmax(ceil_x, 0.f); + } + } + } } +// ============================================================================= +// Evaluation +// ============================================================================= + F32 ALCurveModel::evaluate(F32 x) const { x = llclamp(x, 0.f, 1.f); - if (mKind == KIND_SMOOTHSTEP) - { - return smoothstep(x, mToe, mShoulder, mStrength); - } - const size_t n = mPoints.size(); if (n == 0) { @@ -331,9 +377,187 @@ void ALCurveModel::sample(std::vector& out, S32 count) const return; } out.reserve(count); - const F32 step = 1.f / static_cast(count - 1); for (S32 i = 0; i < count; ++i) { - out.push_back(evaluate(static_cast(i) * step)); + // A division rather than a multiplied step, so the last sample sits + // on exactly 1 and the ends of a baked row read back exactly. + out.push_back(evaluate(static_cast(i) / static_cast(count - 1))); } } + +// ============================================================================= +// ALToneCurveSet +// ============================================================================= + +// static +ALToneCurveSet::EChannel ALToneCurveSet::channelFromCombo(S32 combo_value) +{ + switch (combo_value) + { + case 0: return CH_RED; + case 1: return CH_GREEN; + case 2: return CH_BLUE; + default: return CH_MASTER; + } +} + +// static +const char* ALToneCurveSet::settingName(EChannel c) +{ + static const char* const NAMES[CH_COUNT] = { + "RenderColorGradeCurveMaster", + "RenderColorGradeCurveRed", + "RenderColorGradeCurveGreen", + "RenderColorGradeCurveBlue" }; + return NAMES[c]; +} + +namespace +{ +bool isFiniteNumber(const LLSD& v, F32& out) +{ + // Reject by type rather than trusting asReal(): a string converts to 0 + // silently, and a map to 0 too, and either would plant a point at the + // origin instead of being noticed. + if (v.type() != LLSD::TypeReal && v.type() != LLSD::TypeInteger) + { + return false; + } + const F64 d = v.asReal(); + if (!std::isfinite(d)) + { + return false; + } + out = static_cast(d); + return true; +} +} // namespace + +// static +bool ALToneCurveSet::pointsFromLLSD(const LLSD& sd, std::vector& out) +{ + out = { { 0.f, 0.f }, { 1.f, 1.f } }; + if (!sd.isArray()) + { + return false; + } + + std::vector points; + for (LLSD::array_const_iterator it = sd.beginArray(); it != sd.endArray(); ++it) + { + const LLSD& pair = *it; + if (!pair.isArray() || pair.size() < 2) + { + continue; + } + ALCurveModel::Point p; + if (!isFiniteNumber(pair[0], p.mX) || !isFiniteNumber(pair[1], p.mY)) + { + continue; + } + points.push_back(p); + } + + if (points.size() < 2 || points.size() > static_cast(ALCurveModel::MAX_STORED_POINTS)) + { + // Too few is not a curve; too many is not a curve the editor could + // have made, and truncating it would render something the file does + // not describe. Identity is the honest answer for both. + return false; + } + out = std::move(points); + return true; +} + +// static +LLSD ALToneCurveSet::pointsToLLSD(const std::vector& points) +{ + LLSD out = LLSD::emptyArray(); + for (const ALCurveModel::Point& p : points) + { + LLSD pair = LLSD::emptyArray(); + pair.append(LLSD::Real(p.mX)); + pair.append(LLSD::Real(p.mY)); + out.append(pair); + } + return out; +} + +// static +LLSD ALToneCurveSet::identityLLSD() +{ + return pointsToLLSD({ { 0.f, 0.f }, { 1.f, 1.f } }); +} + +bool ALToneCurveSet::setCurveFromLLSD(EChannel c, const LLSD& sd) +{ + std::vector points; + const bool ok = pointsFromLLSD(sd, points); + mCurves[c].setPoints(std::move(points)); + return ok; +} + +LLSD ALToneCurveSet::getCurveAsLLSD(EChannel c) const +{ + return pointsToLLSD(mCurves[c].getPoints()); +} + +F32 ALToneCurveSet::evaluate(EChannel c, F32 x) const +{ + if (c == CH_MASTER) + { + return mCurves[CH_MASTER].evaluate(x); + } + return mCurves[CH_MASTER].evaluate(mCurves[c].evaluate(x)); +} + +// static +bool ALToneCurveSet::isIdentityCurve(const ALCurveModel& curve) +{ + for (const ALCurveModel::Point& p : curve.getPoints()) + { + if (fabsf(p.mY - p.mX) >= IDENTITY_EPS) + { + return false; + } + } + return true; +} + +bool ALToneCurveSet::isIdentity() const +{ + for (const ALCurveModel& curve : mCurves) + { + if (!isIdentityCurve(curve)) + { + return false; + } + } + return true; +} + +void ALToneCurveSet::bake(std::vector& out, S32 size) const +{ + size = llmax(size, 2); + out.assign(static_cast(size) * 4, ALPHA_OPAQUE); + for (S32 i = 0; i < size; ++i) + { + // Division, not a multiplied step: i / (size - 1) is exactly 1 for the + // last texel, so the top of the row is the curve's value at 1 and not + // at a float short of it. + const F32 x = static_cast(i) / static_cast(size - 1); + for (S32 c = 0; c < 3; ++c) + { + const F32 y = llclamp(evaluate(static_cast(CH_RED + c), x), 0.f, 1.f); + out[static_cast(i) * 4 + c] = static_cast(y * 65535.f + 0.5f); + } + } +} + +// static +void ALToneCurveSet::lutScaleBias(S32 size, F32& scale, F32& bias) +{ + size = llmax(size, 2); + scale = 1.f - 1.f / static_cast(size); + bias = 0.5f / static_cast(size); +} diff --git a/indra/newview/alcurvemodel.h b/indra/newview/alcurvemodel.h index f6f67a716da..2fb1d013cbf 100644 --- a/indra/newview/alcurvemodel.h +++ b/indra/newview/alcurvemodel.h @@ -1,7 +1,8 @@ /** * @file alcurvemodel.h - * @brief Curve shapes for the curve editor widget: an N-point spline and the - * renderer's filmic S-curve + * @brief Curve shapes for the curve editor widget: an N-point monotone spline, + * the split-tone luma ramps, and the four-curve tone curve set the + * renderer bakes into its lookup texture * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -27,6 +28,7 @@ #define AL_CURVEMODEL_H #include "stdtypes.h" +#include "llsd.h" #include @@ -37,29 +39,21 @@ * directly by @c alcurvemodel_test. @ref ALCurveEditorCtrl owns the pixels and * the mouse; everything that decides *what the curve does* lives here. * - * Two kinds share one @ref evaluate: + * The curve is a monotone cubic (Fritsch-Carlson) through arbitrary control + * points. Monotone rather than natural or Catmull-Rom because a tone curve + * that overshoots between two points inverts contrast there: drag one handle + * down and a band *above* it gets brighter. Fritsch-Carlson clamps the + * tangents so that can never happen, at the cost of a little smoothness. * - * - @c KIND_SMOOTHSTEP mirrors @c cg_sCurve in - * @c class1/alchemy/colorGradeUtilF.glsl bit for bit, including the CPU-side - * @c 1/max(shoulder-toe, 1e-4) that pipeline.cpp pre-computes. A graph of it - * is therefore a picture of what the shader will actually do, not an - * illustration of it. If either side is ever retuned, @c alcurvemodel_test - * is where the divergence surfaces. - * - @c KIND_SPLINE is a monotone cubic (Fritsch-Carlson) through arbitrary - * control points. Monotone rather than natural or Catmull-Rom because a tone - * curve that overshoots between two points inverts contrast there: drag one - * handle down and a band *above* it gets brighter. Fritsch-Carlson clamps the - * tangents so that can never happen, at the cost of a little smoothness. + * The renderer never evaluates this itself. @ref ALToneCurveSet bakes it into + * the 1D lookup texture that @c applyChannelCurves in + * @c class1/alchemy/colorGradeUtilF.glsl samples, so a graph of the model is a + * picture of what the shader will do to within the texture's resolution -- + * and @c alcurvemodel_test is where that bound is checked. */ class ALCurveModel { public: - enum EKind - { - KIND_SMOOTHSTEP, ///< toe / shoulder / strength, as the renderer does it - KIND_SPLINE, ///< monotone cubic through N control points - }; - struct Point { F32 mX = 0.f; @@ -70,31 +64,36 @@ class ALCurveModel /// never have zero width and the Hermite division stays finite. static constexpr F32 MIN_POINT_GAP = 1e-3f; - ALCurveModel() = default; + /// Cap on the points the editor will add: a tone curve is single digits, + /// and sixteen is room for an eccentric one. addPoint returns -1 at it. + static constexpr S32 MAX_POINTS = 16; - EKind getKind() const { return mKind; } - void setKind(EKind k) { mKind = k; } + /// Cap on the points a stored curve may carry. A file can only come from + /// outside the editor, so it is allowed more than the editor adds, and a + /// curve of that size still renders exactly what the file describes; past + /// this it is rejected as garbage rather than silently reshaped. Far below + /// 1 / MIN_POINT_GAP, so the separation pass can always make room. + static constexpr S32 MAX_STORED_POINTS = 64; - /// @name Smoothstep - /// @{ - void setSmoothstep(F32 toe, F32 shoulder, F32 strength); - F32 getToe() const { return mToe; } - F32 getShoulder() const { return mShoulder; } - F32 getStrength() const { return mStrength; } - /// @} + ALCurveModel() = default; - /// @name Spline control points + /// @name Control points /// Points are kept sorted by x. Movement clamps into the gap between the /// neighbours rather than reordering, which is what every other curve /// editor does and what keeps a drag predictable. /// @{ - /// Replace the point list. Sorted and separated on the way in. + /// Replace the point list. Sorted, separated, clamped and capped at + /// MAX_STORED_POINTS on the way in; fewer than two points resets to the + /// identity ramp. void setPoints(std::vector points); const std::vector& getPoints() const { return mPoints; } S32 getPointCount() const { return static_cast(mPoints.size()); } - /// Insert a point, returning its index after ordering. + /// Insert a point, returning its index after ordering, or -1 when the + /// curve already holds MAX_POINTS. While the endpoints are locked the + /// point is kept strictly inside them, so an add at an edge lands beside + /// the end rather than on it. S32 addPoint(F32 x, F32 y); /// Remove a point. Refuses to leave fewer than two, and refuses to remove @@ -120,21 +119,42 @@ class ALCurveModel /// 2 yields an empty result. void sample(std::vector& out, S32 count) const; - /// The renderer's per-channel filmic curve, exactly as the shader computes - /// it. Kept static so callers with loose parameters (a settings triplet, - /// say) need not build a model first. - static F32 smoothstep(F32 x, F32 toe, F32 shoulder, F32 strength); - /// @name Split-tone bands /// The three luma masks @c applySplitToning blends through, so a graph of /// them is a picture of which tones each tint actually reaches. Same - /// contract as @ref smoothstep: it mirrors the shader, and - /// @c alcurvemodel_test is where a divergence surfaces. + /// contract as the tone curve: the shader consumes exactly the {scale, + /// bias} pairs made here, and @c alcurvemodel_test is where a divergence + /// surfaces. /// @{ - /// Half-width of the shadow and highlight ramps. Hard-coded in the shader, - /// so it is hard-coded here too rather than invented as a setting. - static constexpr F32 SPLIT_TONE_HALF_WIDTH = 0.35f; + /// Default half-width of the shadow and highlight ramps: what the width + /// settings default to, and so what a Look that does not carry them gets. + static constexpr F32 SPLIT_TONE_DEFAULT_WIDTH = 0.35f; + + /// Floor applied before the reciprocal: a zero-width setting becomes a + /// step at the split rather than a division by zero. The only clamp on the + /// render path; the Lightbox sliders have their own, wider, range. + static constexpr F32 SPLIT_TONE_MIN_WIDTH = 1e-3f; + + /// One ramp in the form the shader consumes: t = l * mScale + mBias, then + /// clamp and the Hermite cubic. This is what pipeline.cpp uploads. + struct SplitToneRamp + { + F32 mScale = 1.f; + F32 mBias = 0.f; + }; + + /// Ramp rising 0 -> 1 over [edge0, edge0 + width]; width floored at + /// SPLIT_TONE_MIN_WIDTH. + static SplitToneRamp splitToneRamp(F32 edge0, F32 width); + /// Shadow ramp rises over [mid - width, mid]; the shadow weight is one + /// minus it. The floor is applied before the left edge is formed, so the + /// ramp always ends exactly at mid and the weights still partition unity. + static SplitToneRamp splitToneShadowRamp(F32 mid, F32 width); + /// Highlight ramp rises over [mid, mid + width]. + static SplitToneRamp splitToneHighlightRamp(F32 mid, F32 width); + /// cg_ramp, colorGradeUtilF.glsl: clamp, then t*t*(3-2t). + static F32 splitToneRampValue(F32 l, const SplitToneRamp& ramp); struct SplitToneWeights { @@ -144,29 +164,121 @@ class ALCurveModel }; /// Weights at luma @a l for a split point @a mid. The three always sum to - /// exactly 1: the shadow and highlight ramps meet at @a mid without - /// overlapping, so the midtone remainder is never clamped away. - static SplitToneWeights splitToneWeights(F32 l, F32 mid); + /// exactly 1: the shadow ramp ends and the highlight ramp starts at @a mid + /// without overlapping, whatever the two widths, so the midtone remainder + /// is never clamped away. + static SplitToneWeights splitToneWeights(F32 l, F32 mid, + F32 shadow_width = SPLIT_TONE_DEFAULT_WIDTH, + F32 highlight_width = SPLIT_TONE_DEFAULT_WIDTH); /// The CPU's balance -> split point mapping, and its inverse. pipeline.cpp - /// uploads @c 0.5 + balance * 0.4, so balance +-1 puts the split at 0.9 or - /// 0.1 and the graph's handle can be read straight back into the setting. + /// uploads ramps built on @c 0.5 + balance * 0.4, so balance +-1 puts the + /// split at 0.9 or 0.1 and the graph's handle can be read straight back + /// into the setting. static F32 splitToneMid(F32 balance); static F32 splitToneBalance(F32 mid); /// @} private: - /// Sort by x and push apart any pair closer than MIN_POINT_GAP. + /// Sort by x, clamp, cap at MAX_STORED_POINTS keeping both ends, and push + /// apart any pair closer than MIN_POINT_GAP. void normalize(); - EKind mKind = KIND_SMOOTHSTEP; - - F32 mToe = 0.f; - F32 mShoulder = 1.f; - F32 mStrength = 0.f; - std::vector mPoints{ { 0.f, 0.f }, { 1.f, 1.f } }; bool mEndpointsLocked = true; }; +/** + * The four tone curves the renderer bakes: master plus one per channel. + * + * Photoshop order -- the channel curve first, then master on its output -- so + * a master contrast curve shapes whatever the channel curves have done rather + * than being reshaped by them. GL-free, like the model, so + * @c alcurvemodel_test can cover the whole path from a setting's LLSD to the + * texels the shader reads. + * + * The settings hold each curve as an LLSD array of [x, y] pairs, e.g. + * @c [[0,0],[0.25,0.2],[1,1]]. Anything else is treated as "no curve": a + * corrupt setting renders as identity rather than as garbage. + */ +class ALToneCurveSet +{ +public: + enum EChannel + { + CH_MASTER = 0, + CH_RED, + CH_GREEN, + CH_BLUE, + CH_COUNT + }; + + /// Width of the row the renderer bakes. Linear filtering between 512 + /// samples keeps the reconstruction of a hard toe below 1/1024, four times + /// under the 8-bit display quantum; 256 is marginal against it. + static constexpr S32 LUT_SIZE = 512; + + /// A point closer than this to the diagonal counts as on it. Far above the + /// float noise of a settings round trip, forty times below the display + /// quantum, and exact for this spline: a monotone cubic through diagonal + /// points has every secant and tangent equal to 1 and reproduces the line. + static constexpr F32 IDENTITY_EPS = 1e-4f; + + static constexpr U16 ALPHA_OPAQUE = 65535; + + ALToneCurveSet() = default; + + /// The channel combo's value space: -1 is Master, 0..2 are R, G, B. + static EChannel channelFromCombo(S32 combo_value); + + /// The gSavedSettings key each curve lives in. The one list the renderer, + /// the Lightbox and the Looks whitelist all iterate, so a channel cannot be + /// renamed in one of them and silently dropped by another. + static const char* settingName(EChannel c); + + ALCurveModel& curve(EChannel c) { return mCurves[c]; } + const ALCurveModel& curve(EChannel c) const { return mCurves[c]; } + + /// @name LLSD <-> points + /// @{ + + /// Read an array of [x, y] pairs. Entries that are not a two-element array + /// of finite numbers are skipped; if fewer than two survive, or more than + /// ALCurveModel::MAX_STORED_POINTS, @a out is the identity pair and the + /// result is false. Survivors are not yet sorted or clamped -- setPoints + /// does that. + static bool pointsFromLLSD(const LLSD& sd, std::vector& out); + static LLSD pointsToLLSD(const std::vector& points); + static LLSD identityLLSD(); + + /// Returns what pointsFromLLSD returned; the curve is set either way, to + /// the identity on failure. + bool setCurveFromLLSD(EChannel c, const LLSD& sd); + LLSD getCurveAsLLSD(EChannel c) const; + /// @} + + /// master(channel(x)) for a colour channel; master(x) for CH_MASTER. + F32 evaluate(EChannel c, F32 x) const; + + /// Every point within IDENTITY_EPS of the diagonal. + static bool isIdentityCurve(const ALCurveModel& curve); + /// All four curves identity, in which case the renderer skips the lookup. + bool isIdentity() const; + + /// Fill @a out with @a size RGBA16 texels: texel i holds the composite + /// R, G and B at x = i / (size - 1), alpha ALPHA_OPAQUE. A @a size below 2 + /// is raised to 2. + void bake(std::vector& out, S32 size) const; + + /// The half-texel mapping the shader applies before sampling a row of + /// @a size texels: u = x * scale + bias puts x = 0 on the centre of texel 0 + /// and x = 1 on the centre of texel size - 1, so both ends read back + /// exactly and the hardware's linear filter interpolates between the + /// baked samples everywhere else. + static void lutScaleBias(S32 size, F32& scale, F32& bias); + +private: + ALCurveModel mCurves[CH_COUNT]; +}; + #endif // AL_CURVEMODEL_H diff --git a/indra/newview/alfloaterlightbox.cpp b/indra/newview/alfloaterlightbox.cpp index 30ea8b513b6..03c084659c3 100644 --- a/indra/newview/alfloaterlightbox.cpp +++ b/indra/newview/alfloaterlightbox.cpp @@ -31,8 +31,20 @@ #include "llviewerprecompiledheaders.h" #include "alfloaterlightbox.h" +#include "alcolorpicker.h" +#include "alemptystate.h" +#include "alhistorylist.h" +#include "alpopover.h" +#include "alquickopen.h" +#include "alsettingrow.h" +#include "llaccordionctrl.h" #include "llaccordionctrltab.h" +#include "llbutton.h" +#include "llcolorswatch.h" #include "llcombobox.h" +#include "llfocusmgr.h" +#include "llgl.h" +#include "lltextbox.h" #include "llfloaterreg.h" #include "alcurveeditorctrl.h" #include "alcurvemodel.h" @@ -44,14 +56,19 @@ #include "llnotificationsutil.h" #include "llpanel.h" #include "llpresetsmanager.h" +#include "llsdutil.h" #include "llsettingsvo.h" +#include "llsliderctrl.h" #include "llspinctrl.h" +#include "lltabcontainer.h" #include "lltimer.h" #include "lltoolmgr.h" #include "llviewercontrol.h" #include "pipeline.h" #include "rlvactions.h" +#include + #include #include #include @@ -59,10 +76,15 @@ namespace { -/// Everything recorded while this lives becomes a single undo step. +/// Everything recorded while this lives becomes a single undo step, called +/// @a label in the history list. struct ScopedHistoryGroup { - explicit ScopedHistoryGroup(ALGradeHistory& history) : mHistory(history) { mHistory.beginGroup(); } + explicit ScopedHistoryGroup(ALGradeHistory& history, const std::string& label = std::string()) + : mHistory(history) + { + mHistory.beginGroup(label); + } ~ScopedHistoryGroup() { mHistory.endGroup(); } ScopedHistoryGroup(const ScopedHistoryGroup&) = delete; ScopedHistoryGroup& operator=(const ScopedHistoryGroup&) = delete; @@ -82,79 +104,97 @@ struct ScopedTrue bool& mFlag; }; -// Vector-valued rows follow the widget naming contract "vec3__<0|1|2>"; -// setting names never contain '_', so the parse is unambiguous. -bool parseVec3WidgetName(const std::string& name, std::string& setting, S32& component) -{ - static const std::string prefix = "vec3_"; - if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) - { - return false; - } - size_t sep = name.rfind('_'); - if (sep <= prefix.size() || sep + 2 != name.size()) - { - return false; - } - S32 comp = name[sep + 1] - '0'; - if (comp < 0 || comp > 2) - { - return false; - } - setting = name.substr(prefix.size(), sep - prefix.size()); - component = comp; - return true; -} - -// Any LLUICtrl will do; the name is the whole contract. Descending into -// composites is safe because their internal children are named for their role -// ("Slider", "value") and cannot parse as vec3__. -void collectVec3Spinners(LLView* viewp, std::map>& rows) +/// How long a caption stays lit after going to its setting: long enough to +/// find on a busy page, short enough not to be mistaken for a state. +constexpr F32 LIT_CAPTION_SECONDS = 2.5f; + +/// The inline picker for a colour row: XUI Studio's hue ring, shade square and +/// channel tracks, under the row's name, with a way on to the viewer's full +/// picker for typed numbers and the eyedropper. +/// +/// The picker is a child of the popover itself and not of a panel inside it, +/// and that is load-bearing: a panel takes Escape for itself and drops the +/// keyboard, which closes the popover as *settled*, and Escape here has to +/// mean "put the colour back". +class ALLightboxColorPopover final : public ALPopover { - for (LLView* childp : *viewp->getChildList()) - { - if (LLUICtrl* ctrlp = ALViewType::as(childp)) +public: + // The picker gives its channel sliders 218px and the ring the rest of the + // width, up to its height. So its width is the ring plus 218: 440 by 220 + // is a ring as tall as the picker. Narrower, and the ring is what shrinks + // -- at 300 it is 74px across. + static constexpr S32 PICKER_WIDTH = 440; + static constexpr S32 PICKER_HEIGHT = 220; + // As small as it may be dragged: the sliders and a ring still worth + // aiming at. + static constexpr S32 MIN_PICKER_WIDTH = 330; + static constexpr S32 MIN_PICKER_HEIGHT = 150; + static constexpr S32 BUTTON_HEIGHT = 20; + static constexpr S32 BUTTON_WIDTH = 110; + static constexpr S32 GAP = 4; + + /// The popover around a picker of @a picker_width by @a picker_height, + /// laid out under the title, which is drawn across the top of the rect. + static S32 widthFor(S32 picker_width) { return GAP + picker_width + GAP; } + static S32 heightFor(S32 header, S32 picker_height) + { + return header + GAP + picker_height + GAP + BUTTON_HEIGHT + GAP; + } + + /// What the popover remembers its size as, for the next colour. + static constexpr const char* SIZE_KIND = "lightbox_color"; + + ALLightboxColorPopover(const LLFloater::Params& p, const LLColor4& start, + std::function on_change, + std::function on_full_picker, + const std::string& full_picker_label, const std::string& full_picker_tooltip) + : ALPopover(p) + { + const S32 width = getRect().getWidth(); + const S32 top = getRect().getHeight() - getHeaderHeight(); + + ALColorPicker::Params pp(LLUICtrlFactory::getDefaultParams()); + pp.name = "picker"; + // Everything between the title and the button row, so a popover + // dragged bigger is a bigger wheel. + pp.rect = LLRect(GAP, top - GAP, width - GAP, GAP + BUTTON_HEIGHT + GAP); + pp.follows.flags = FOLLOWS_ALL; + // Every colour row here is a Color3 setting: an alpha track would be a + // control that does nothing. + pp.show_alpha = false; + ALColorPicker* picker = LLUICtrlFactory::create(pp); + picker->setColor(start); + // Its value is text, "r, g, b, a"; the colour is what is wanted. + picker->setCommitCallback([picker, on_change](LLUICtrl*, const LLSD&) { - std::string setting; - S32 component = 0; - if (parseVec3WidgetName(ctrlp->getName(), setting, component)) + if (on_change) { - rows[setting][component] = ctrlp; + on_change(picker->color()); } - } - collectVec3Spinners(childp, rows); - } -} - -void collectBoundControls(LLView* viewp, std::set& keys) -{ - for (LLView* childp : *viewp->getChildList()) - { - if (LLUICtrl* ctrlp = ALViewType::as(childp)) + }); + addChild(picker); + + LLButton::Params bp(LLUICtrlFactory::getDefaultParams()); + bp.name = "full_picker"; + bp.label = full_picker_label; + bp.tool_tip = full_picker_tooltip; + bp.rect = LLRect(width - GAP - BUTTON_WIDTH, GAP + BUTTON_HEIGHT, width - GAP, GAP); + bp.follows.flags = FOLLOWS_RIGHT | FOLLOWS_BOTTOM; + LLButton* full = LLUICtrlFactory::create(bp); + full->setCommitCallback([on_full_picker](LLUICtrl*, const LLSD&) { - if (LLControlVariable* controlp = ctrlp->getControlVariable()) + if (on_full_picker) { - // Only reset controls owned by gSavedSettings; enabled/visibility - // bindings live in separate slots and are not touched here. - if (gSavedSettings.getControl(controlp->getName()) == controlp) - { - keys.insert(controlp->getName()); - } + on_full_picker(); } - std::string setting; - S32 component = 0; - if (parseVec3WidgetName(ctrlp->getName(), setting, component)) - { - keys.insert(setting); - } - } - collectBoundControls(childp, keys); + }); + addChild(full); } -} +}; } // namespace ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) -: LLFloater(key) +: ALStudioFloater(key, "ALLightboxState") { mCommitCallbackRegistrar.add("LightBox.ResetControlDefault", std::bind(&ALFloaterLightBox::onClickResetControlDefault, this, std::placeholders::_2)); mCommitCallbackRegistrar.add("LightBox.ResetSection", std::bind(&ALFloaterLightBox::onClickResetSection, this, std::placeholders::_2)); @@ -174,18 +214,37 @@ ALFloaterLightBox::ALFloaterLightBox(const LLSD& key) mCommitCallbackRegistrar.add("LightBox.CommitVec3", std::bind(&ALFloaterLightBox::onCommitVec3, this, std::placeholders::_1)); mCommitCallbackRegistrar.add("LightBox.CommitToneCurve", std::bind(&ALFloaterLightBox::onCommitToneCurve, this)); mCommitCallbackRegistrar.add("LightBox.RefreshToneCurve", std::bind(&ALFloaterLightBox::refreshToneCurve, this)); + mCommitCallbackRegistrar.add("LightBox.ResetToneCurveChannel", std::bind(&ALFloaterLightBox::onClickResetToneCurveChannel, this)); + mCommitCallbackRegistrar.add("LightBox.ToneCurvePreset", std::bind(&ALFloaterLightBox::onToneCurvePreset, this, std::placeholders::_2)); mCommitCallbackRegistrar.add("LightBox.CommitSplitToneGraph", std::bind(&ALFloaterLightBox::onCommitSplitToneGraph, this)); mCommitCallbackRegistrar.add("LightBox.PickWhiteBalance", std::bind(&ALFloaterLightBox::onClickWhiteBalancePicker, this)); mCommitCallbackRegistrar.add("LightBox.OpenLUTFolder", std::bind(&ALFloaterLightBox::onClickOpenLUTFolder, this)); + mCommitCallbackRegistrar.add("LightBox.LensDirtSliderDown", std::bind(&ALFloaterLightBox::onLensDirtSliderHeld, this, true)); + mCommitCallbackRegistrar.add("LightBox.LensDirtSliderUp", std::bind(&ALFloaterLightBox::onLensDirtSliderHeld, this, false)); mCommitCallbackRegistrar.add("LightBox.LookSelected", std::bind(&ALFloaterLightBox::onLookSelected, this)); mCommitCallbackRegistrar.add("LightBox.LookSave", std::bind(&ALFloaterLightBox::onClickLookSave, this)); mCommitCallbackRegistrar.add("LightBox.LookSaveAs", std::bind(&ALFloaterLightBox::onClickLookSaveAs, this)); mCommitCallbackRegistrar.add("LightBox.LookDelete", std::bind(&ALFloaterLightBox::onClickLookDelete, this)); mCommitCallbackRegistrar.add("LightBox.LookRevert", std::bind(&ALFloaterLightBox::onClickLookRevert, this)); + mCommitCallbackRegistrar.add("LightBox.Find", std::bind(&ALFloaterLightBox::openFind, this)); + mCommitCallbackRegistrar.add("LightBox.History", std::bind(&ALFloaterLightBox::openHistory, this)); + mCommitCallbackRegistrar.add("LightBox.PopOut", std::bind(&ALFloaterLightBox::togglePane, this)); } ALFloaterLightBox::~ALFloaterLightBox() { + // onClose has settled any popover already; this is for a floater that + // dies without closing. die() and not a close, so nothing it holds calls + // back into a floater half torn down. + if (ALPopover* popover = mPopover.get()) + { + popover->die(); + } + // onClose has put every page back; this is for a floater that dies + // without closing, which would otherwise leave a page in a window of its + // own wired to callbacks on a floater that is gone. + mFolds.dockAll(); + // The handle in the pick callback already makes a late sample harmless, but // an armed picker outliving its floater would leave the user holding an // eyedropper cursor that has nothing left to tell. Put the previous tool @@ -211,12 +270,60 @@ ALFloaterLightBox::~ALFloaterLightBox() bool ALFloaterLightBox::postBuild() { + mTabs = getChild("lightbox_tabs"); + for (S32 i = 0; i < mTabs->getTabCount(); ++i) + { + if (LLPanel* page = mTabs->getPanelByIndex(i)) + { + mTabPages.push_back(page); + // Each page's tool_tip is written for its tab button, and + // addTabPanel has already copied it there. Left on the page too it + // is worse than useless: LLView::handleToolTip offers a parent's + // tooltip before its children's, so it would pop up over every + // caption, gap and header on the page that has none of its own. + page->setToolTip(LLStringUtil::null); + } + } + // Normally clear here -- a new Lightbox opens with every section switched + // on -- but the mask belongs to the pipeline, so read it rather than + // assume it. + refreshBypassBadge(); + + // Every section and setting, while they are all still where the XUI put + // them. See mDirectory. + mDirectory.build(mTabPages); + mTopBar = findChild("lightbox_topbar"); + populateLUTCombo(); + // Khronos Neutral (0), ACES (1) and GT (5) take no parameters, so their + // selection leaves every one of these rows greyed. + static const std::pair tonemapper_rows[] = { + { "tone_aces_white", 2 }, + { "tone_reinhard_white", 3 }, + { "tone_filmic_white", 4 }, + { "tone_agx_contrast", 6 }, + { "tone_agx_white", 6 }, + }; + for (const auto& row : tonemapper_rows) + { + // Setting rows, which grey their own reset buttons along with them. + mTonemapperRows.push_back({ row.second, findChild(row.first) }); + } mTonemapConnection = gSavedSettings.getControl("AlchemyRenderTonemapType")->getSignal()->connect( [this](LLControlVariable*, const LLSD&, const LLSD&) { updateTonemapperRows(); }); updateTonemapperRows(); + // Bloom (HDR) or Glow (Legacy), whichever the renderer is running. HDR is + // switched from Preferences ("HDR and Emissive", which sets + // RenderHDREnabled), and possibly with this floater open. + mHDRConnection = gSavedSettings.getControl("RenderHDREnabled")->getSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { refreshBloomSections(); }); + refreshBloomSections(); + + mLookSave = findChild("look_save"); + mLookDelete = findChild("look_delete"); + mLookRevert = findChild("look_revert"); mLooksCombo = getChild("looks_combo"); mLooksListConnection = LLPresetsManager::instance().setPresetListChangeLooksCallback( std::bind(&ALFloaterLightBox::refreshLooksBar, this)); @@ -226,6 +333,7 @@ bool ALFloaterLightBox::postBuild() mUndoButton = findChild("look_undo"); mRedoButton = findChild("look_redo"); + mHistoryButton = findChild("look_history"); mReferenceClear = findChild("reference_clear"); mReferenceMode = findChild("reference_mode"); mReferencePosition = findChild("reference_position"); @@ -260,9 +368,43 @@ bool ALFloaterLightBox::postBuild() mHistoryConnections.emplace_back(controlp->getSignal()->connect( [this, setting](LLControlVariable*, const LLSD& new_value, const LLSD& old_value) { onGradeSettingChanged(setting, old_value, new_value); })); + mRecordedKeys.insert(setting); + } + + LLHandle self = getDerivedHandle(); + for (const ALLightboxDirectory::Setting& setting : mDirectory.settings()) + { + const std::string key = setting.mKey; + + // Colour rows pick inline. Only Color3 swatches: the inline picker has + // no alpha, and a Color4 row would lose its fourth number to it. + LLColorSwatchCtrl* swatch = ALViewType::as(setting.mCtrl); + LLControlVariable* controlp = gSavedSettings.getControl(key); + if (swatch && controlp && controlp->type() == TYPE_COL3) + { + swatch->setPickerOverride([self, key](LLColorSwatchCtrl* from) + { + ALFloaterLightBox* floater = self.get(); + return floater && floater->openColorPopover(from, key); + }); + } + + // A setting row's reset comes through here, the same as a reset + // button's: a named undo step of its own rather than a bare write + // that folds into a drag just before it. + if (ALSettingRow* row = ALViewType::as(setting.mCtrl)) + { + row->setResetHandler([self, key](ALSettingRow*) + { + if (ALFloaterLightBox* floater = self.get()) + { + floater->onClickResetControlDefault(LLSD(key)); + } + }); + } } - collectVec3Spinners(this, mVec3Rows); + ALLightboxDirectory::collectVec3Controls(this, mVec3Rows); for (const auto& row : mVec3Rows) { const std::string& setting = row.first; @@ -280,29 +422,84 @@ bool ALFloaterLightBox::postBuild() setupToneCurve(); setupSplitToneGraph(); - return LLFloater::postBuild(); + fitSections(); + + // Last, after everything above has found what it needs: from here a page + // can be taken out of the floater, and nothing may look for a widget + // through it again. Each page is a region of the folds, named after its + // panel, with no button to fold it -- a page does not fold -- and a + // title for the window it goes out in. + mPopOutButton = findChild("lightbox_pop_out"); + std::vector panes; + for (LLPanel* page : mTabPages) + { + LLStringUtil::format_map_t args; + args["[TAB]"] = page->getLabel(); + panes.push_back({ page->getName(), page->getName(), std::string(), getString("pane_title", args) }); + } + mFolds.bind(this, std::move(panes)); + + for (size_t index = 0; index < mTabPages.size(); ++index) + { + LLPanel* page = mTabPages[index]; + LLStringUtil::format_map_t args; + args["[TAB]"] = page->getLabel(); + + // What the page says while its contents are away: added after the + // folds have wrapped them, so it stays on the page when they go. + ALEmptyState::Params ep(LLUICtrlFactory::getDefaultParams()); + ep.name = page->getName() + "_away"; + ep.rect = page->getLocalRect(); + ep.follows.flags = FOLLOWS_ALL; + ep.background_visible = false; + ep.visible = false; + ALEmptyState* empty = LLUICtrlFactory::create(ep); + page->addChild(empty); + empty->say(getString("pane_away_headline", args), getString("pane_away_sentence", args), + getString("pane_put_back")); + empty->onAction([self, index]() + { + if (ALFloaterLightBox* floater = self.get()) + { + floater->dockPane(index); + } + }); + mPaneEmpties.push_back(empty); + } + // The pages that were out last time come out again, and the window goes + // back to the shape it was left at. + loadState(); + + return ALStudioFloater::postBuild(); } -void ALFloaterLightBox::populateLUTCombo() +// Shared by the colour LUT and lens dirt pickers. Both enumerate a bundled +// directory and a user directory of the same name, list the bundled entries +// first and the user ones behind a separator, and select whatever the setting +// currently holds. Generalised rather than cloned: the two differ only in +// directory, accepted extensions, and which setting they write. +// +// `extensions` must list only what the corresponding loader can actually +// handle. Anything else in the directory -- a readme, a subfolder, a stray +// .bak -- would become a selectable entry that fails at apply time with +// nothing but a log line to say why. getExtension lowercases, so a .CUBE +// passes here the same way it does when the renderer resolves it. +void ALFloaterLightBox::populateAssetCombo(const std::string& combo_name, + const std::string& dir_name, + const std::vector& extensions, + const std::string& setting_name) { - LLComboBox* lut_combo = getChild("colorlut_combo"); - - // Only what setupGradingLUT can actually load. Anything else in the - // directory -- a readme, a subfolder, a stray .bak -- would become a - // selectable entry that fails at apply time with nothing but a log line - // to say why. getExtension lowercases, so a .CUBE passes here the same - // way it does when the renderer resolves it. - static const char* const LUT_EXTENSIONS[] = { "cube", "tga", "png", "jpg", "jpeg", "bmp", "webp" }; + LLComboBox* combo = getChild(combo_name); // Collected rather than added on the spot, so the caller can see whether // a directory contributed anything before committing to the separator. - auto collect_luts_from = [](const std::string& dir_name) + auto collect_from = [&extensions](const std::string& scan_dir) { std::vector> found; // stem, filename std::error_code ec; - std::filesystem::path luts_path = fsyspath(dir_name); - if (!std::filesystem::is_directory(luts_path, ec) || ec) + std::filesystem::path scan_path = fsyspath(scan_dir); + if (!std::filesystem::is_directory(scan_path, ec) || ec) { return found; } @@ -312,73 +509,110 @@ void ALFloaterLightBox::populateLUTCombo() // parks the iterator at end instead, which is why ec is looked at // again once the loop is done. std::filesystem::directory_iterator end; - for (std::filesystem::directory_iterator lut(luts_path, ec); lut != end && !ec; lut.increment(ec)) + for (std::filesystem::directory_iterator entry(scan_path, ec); entry != end && !ec; entry.increment(ec)) { std::error_code entry_ec; - if (!lut->is_regular_file(entry_ec) || entry_ec) + if (!entry->is_regular_file(entry_ec) || entry_ec) { continue; } #if LL_WINDOWS - std::string lut_stem = ll_convert_wide_to_string(lut->path().stem().native()); - std::string lut_filename = ll_convert_wide_to_string(lut->path().filename().native()); + std::string entry_stem = ll_convert_wide_to_string(entry->path().stem().native()); + std::string entry_filename = ll_convert_wide_to_string(entry->path().filename().native()); #else - std::string lut_stem = lut->path().stem().native(); - std::string lut_filename = lut->path().filename().native(); + std::string entry_stem = entry->path().stem().native(); + std::string entry_filename = entry->path().filename().native(); #endif - const std::string exten = gDirUtilp->getExtension(lut_filename); - if (std::find(std::begin(LUT_EXTENSIONS), std::end(LUT_EXTENSIONS), exten) == std::end(LUT_EXTENSIONS)) + const std::string exten = gDirUtilp->getExtension(entry_filename); + if (std::find(extensions.begin(), extensions.end(), exten) == extensions.end()) { continue; } - found.emplace_back(std::move(lut_stem), std::move(lut_filename)); + found.emplace_back(std::move(entry_stem), std::move(entry_filename)); } if (ec) { - LL_WARNS() << "Error reading LUT directory " << dir_name << ": " << ec.message() << LL_ENDL; + LL_WARNS() << "Error reading asset directory " << scan_dir << ": " << ec.message() << LL_ENDL; } return found; }; - // Bundled LUTs first, then user LUTs behind a separator — the same order + // Bundled entries first, then user entries behind a separator — the same order // the renderer resolves a name in, where the user dir wins. - for (const auto& lut : collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, "colorlut"))) + for (const auto& entry : collect_from(gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, dir_name))) { - lut_combo->add(lut.first, lut.second); + combo->add(entry.first, entry.second); } - const auto user_luts = collect_luts_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut")); - if (!user_luts.empty()) + const auto user_entries = collect_from(gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, dir_name)); + if (!user_entries.empty()) { - lut_combo->addSeparator(); - for (const auto& lut : user_luts) + combo->addSeparator(); + for (const auto& entry : user_entries) { - lut_combo->add(lut.first, lut.second); + combo->add(entry.first, entry.second); } } - lut_combo->selectByValue(gSavedSettings.getString("RenderColorGradeLUT")); - lut_combo->resetDirty(); + // Mandatory: the combo is allow_text_entry, so nothing else selects the + // saved value when the floater opens. + combo->selectByValue(gSavedSettings.getString(setting_name)); + combo->resetDirty(); } -void ALFloaterLightBox::onClickOpenLUTFolder() +void ALFloaterLightBox::populateLUTCombo() +{ + static const std::vector LUT_EXTENSIONS = { "cube", "tga", "png", "jpg", "jpeg", "bmp", "webp" }; + populateAssetCombo("colorlut_combo", "colorlut", LUT_EXTENSIONS, "RenderColorGradeLUT"); +} + +// Parameterised by directory even though the colour LUT is once again the only +// caller: the lens dirt plate that shared it is generated now. Kept general +// because the shape is the shared part -- user folder, created on demand -- and +// collapsing it back to a constant only has to be undone for the next asset. +void ALFloaterLightBox::openUserAssetFolder(const std::string& dir_name) { // The user's folder, not the bundled one: it is the half of the pair that // is theirs to put files in, and the one the renderer prefers when a name // exists in both. Nothing creates it until there is something to put in // it, which is exactly now -- and LLFile::mkdir is quiet about a // directory that already exists. - const std::string dir = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, "colorlut"); + const std::string dir = gDirUtilp->getExpandedFilename(LL_PATH_USER_SETTINGS, dir_name); LLFile::mkdir(dir); gDirUtilp->openDir(dir); } +// The lens dirt plate is regenerated whenever one of its parameters changes, +// and at full resolution that is too expensive to do on every frame of a slider +// drag. Rather than guess when a drag has ended from a timer, say so: the +// renderer holds off while this is raised and rebuilds once on release. +// +// Only the generation sliders carry these. Strength and the two response +// controls are applied per frame at composite time and rebuild nothing, so +// holding them off would only make them feel broken. +void ALFloaterLightBox::onLensDirtSliderHeld(bool held) +{ + gPipeline.mLensDirtSliderHeld = held; +} + +void ALFloaterLightBox::onClickOpenLUTFolder() +{ + openUserAssetFolder("colorlut"); +} + void ALFloaterLightBox::onClickResetControlDefault(const LLSD& userdata) { const std::string& control_name = userdata.asString(); LLControlVariable* controlp = gSavedSettings.getControl(control_name); if (controlp) { + // A group, though it is one control: it names the step, and a reset + // straight after a drag of the same control is a second thing done + // rather than the end of the drag, which it would otherwise coalesce + // into. + LLStringUtil::format_map_t args; + args["[SETTING]"] = mDirectory.captionFor(control_name); + ScopedHistoryGroup group(mHistory, getString("history_reset_setting", args)); controlp->resetToDefault(true); } } @@ -391,14 +625,18 @@ void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) return; } + // From the directory, not by name: the section may be on a page that has + // been taken out into a window of its own, out of reach of a search from + // this floater. std::set keys; - if (LLPanel* panelp = findChild(section)) + const ALLightboxDirectory::Section* mainp = mDirectory.section(section); + if (mainp) { - collectBoundControls(panelp, keys); + ALLightboxDirectory::collectBoundKeys(mainp->mPanel, keys); } - if (LLPanel* advp = findChild(section + "_adv")) + if (const ALLightboxDirectory::Section* advp = mDirectory.section(section + "_adv")) { - collectBoundControls(advp, keys); + ALLightboxDirectory::collectBoundKeys(advp->mPanel, keys); } // A section's own header can carry a bound checkbox -- Color Grading's @@ -406,17 +644,19 @@ void ALFloaterLightBox::onClickResetSection(const LLSD& userdata) // drawn. It is not in the panel, so nothing above would find it. Walk the // control rather than reading it directly: LLCheckBoxCtrl hands // control_name down to its button, so the binding is on the child. - if (auto* tabp = findChild("atab_" + section)) + if (mainp && mainp->mTab) { - if (LLCheckBoxCtrl* checkp = tabp->getHeaderCheckBox()) + if (LLCheckBoxCtrl* checkp = mainp->mTab->getHeaderCheckBox()) { - collectBoundControls(checkp, keys); + ALLightboxDirectory::collectBoundKeys(checkp, keys); } } // One thing the user did, however many controls it moves: undoing a Reset // All eleven times would be absurd. - ScopedHistoryGroup group(mHistory); + LLStringUtil::format_map_t args; + args["[SECTION]"] = mainp ? mainp->mTitle : section; + ScopedHistoryGroup group(mHistory, getString("history_reset_section", args)); for (const std::string& key : keys) { if (LLControlVariable* controlp = gSavedSettings.getControl(key)) @@ -462,6 +702,45 @@ void ALFloaterLightBox::onToggleSection(LLUICtrl* ctrl, const LLSD& userdata) { LLPipeline::sGradeBypassMask |= found->second; } + + // The only writer of the mask, so the only place the badge can go stale. + refreshBypassBadge(); +} + +void ALFloaterLightBox::refreshBypassBadge() +{ + if (!mTabs || mTabPages.empty()) + { + return; + } + + // Counted from the mask rather than from the checkboxes: the mask is what + // the renderer obeys, and the badge is there to say what it is obeying. + static constexpr U32 section_bits[] = { + LLPipeline::GRADE_BYPASS_BASIC, + LLPipeline::GRADE_BYPASS_PRIMARIES, + LLPipeline::GRADE_BYPASS_SPLIT, + LLPipeline::GRADE_BYPASS_LUT, + LLPipeline::GRADE_BYPASS_CURVE, + }; + S32 bypassed = 0; + for (U32 bit : section_bits) + { + if (LLPipeline::sGradeBypassMask & bit) + { + ++bypassed; + } + } + + std::string label; + if (bypassed > 0) + { + LLStringUtil::format_map_t args; + args["[COUNT]"] = fmt::format("{}", bypassed); + label = getString("bypass_badge", args); + } + // The Look tab is the first page; the grading sections all live on it. + mTabs->setTabBadge(mTabPages.front(), label); } void ALFloaterLightBox::onClickReferenceGrab() @@ -547,7 +826,575 @@ void ALFloaterLightBox::draw() refreshReferenceRow(); refreshHistoryButtons(); refreshDayCycleRow(); - LLFloater::draw(); + refreshPaneRow(); + + if (LLView* lit = mLitCaption.get()) + { + if (mLitTimer.getElapsedTimeF32() > LIT_CAPTION_SECONDS) + { + if (LLTextBox* box = ALViewType::as(lit)) + { + box->setHighlighted(false); + } + mLitCaption.markDead(); + } + } + + // The history list follows the stack while it is up: Ctrl+Z pressed in + // it, a drag in the floater behind it, a Look applied from the bar. + if (mHistoryList.get() && mHistory.revision() != mHistoryListRevision) + { + fillHistoryList(); + } + + ALStudioFloater::draw(); +} + +void ALFloaterLightBox::onClose(bool app_quitting) +{ + closePopover(false); + // The base saves which pages are out before it puts them back, and puts + // them back even when the viewer is quitting: windows are closed in no + // particular order then, and this is the last point at which both this + // floater and every torn-off window are sure to still be whole. + ALStudioFloater::onClose(app_quitting); +} + +ALPopover* ALFloaterLightBox::showPopover(PopoverKind kind, LLView* anchor, LLPanel* content) +{ + // No title: the content is laid out over the whole popover, and a title + // bar would be drawn under it. The floater's own shortcuts still answer + // while the popover has the keyboard, since a popover sends the keys it + // does not take home. + // Closed before the new one opens: a popover that loses the keyboard to + // another settles, and what is up now is not what was chosen in it. + closePopover(true); + ALPopover* popover = ALPopover::show(anchor, content); + if (!popover) + { + return nullptr; + } + adoptPopover(kind, popover, nullptr); + return popover; +} + +void ALFloaterLightBox::adoptPopover(PopoverKind kind, ALPopover* popover, LLView* anchor) +{ + closePopover(true); + + mPopover = popover->getDerivedHandle(); + mPopoverKind = kind; + // Handles both ways: the popover is a top-level window and can outlive + // this floater by a frame, and this floater can be closed under it. + LLHandle self = getDerivedHandle(); + LLHandle which = mPopover; + popover->onClosed([self, which](bool escaped) + { + if (ALFloaterLightBox* floater = self.get()) + { + floater->onPopoverClosed(which.get(), escaped); + } + }); + + if (anchor) + { + popover->openBeside(anchor); + } +} + +void ALFloaterLightBox::closePopover(bool escape) +{ + if (ALPopover* popover = mPopover.get()) + { + if (escape) + { + popover->escape(); + } + else + { + popover->settle(); + } + } +} + +void ALFloaterLightBox::onPopoverClosed(const LLView* which, bool escaped) +{ + // One that was replaced before it finished closing has nothing to say + // about the one up now. + if (!which || which != mPopover.get()) + { + return; + } + const PopoverKind kind = mPopoverKind; + mPopover.markDead(); + mPopoverKind = PopoverKind::None; + mHistoryList.markDead(); + + if (kind == PopoverKind::Color) + { + endColorSession(escaped); + } +} + +bool ALFloaterLightBox::openColorPopover(LLColorSwatchCtrl* swatch, const std::string& key) +{ + LLControlVariable* control = gSavedSettings.getControl(key); + if (!swatch || !control) + { + return false; + } + // Closed before the session below starts, so a colour popover already up + // finishes its own session with its own setting. + closePopover(true); + + // A Color3 arrives as three numbers, which LLColor4 reads as transparent. + LLColor4 start(control->getValue()); + start.mV[VALPHA] = 1.f; + mColorKey = key; + mColorOriginal = control->getValue(); + + LLHandle self = getDerivedHandle(); + LLHandle swatch_handle = swatch->getHandle(); + // Resizable, and opening at the size the last one was left at: the wheel + // is what grows. + using Popover = ALLightboxColorPopover; + const S32 header = LLFloater::getDefaultParams().header_height; + LLFloater::Params params = ALPopover::paramsRemembered( + Popover::SIZE_KIND, + Popover::widthFor(Popover::PICKER_WIDTH), + Popover::heightFor(header, Popover::PICKER_HEIGHT), + mDirectory.captionFor(key)); + params.min_width = Popover::widthFor(Popover::MIN_PICKER_WIDTH); + params.min_height = Popover::heightFor(header, Popover::MIN_PICKER_HEIGHT); + ALLightboxColorPopover* popover = new ALLightboxColorPopover( + params, + start, + [self](const LLColor4& color) + { + if (ALFloaterLightBox* floater = self.get()) + { + floater->onColorPicked(color); + } + }, + [self, swatch_handle]() + { + // Settled rather than escaped: the full picker starts from what + // was picked here, and keeps it if it is closed. + if (ALFloaterLightBox* floater = self.get()) + { + floater->closePopover(false); + } + if (LLColorSwatchCtrl* swatch = ALViewType::as(swatch_handle.get())) + { + swatch->showClassicPicker(true); + } + }, + getString("color_full_picker"), getString("color_full_picker_tooltip")); + adoptPopover(PopoverKind::Color, popover, swatch); + return true; +} + +void ALFloaterLightBox::onColorPicked(const LLColor4& color) +{ + LLControlVariable* control = gSavedSettings.getControl(mColorKey); + if (!control) + { + return; + } + // Live, as every row here is, and written the way the swatch writes a + // Color3: three numbers, never a fourth. + ScopedTrue writing(mColorWriting); + control->setValue(LLColor3(color).getValue()); +} + +void ALFloaterLightBox::endColorSession(bool escaped) +{ + const std::string key = mColorKey; + mColorKey.clear(); + LLControlVariable* control = key.empty() ? nullptr : gSavedSettings.getControl(key); + if (!control) + { + return; + } + + if (escaped) + { + // Escape keeps nothing, and nothing was recorded to take back. + ScopedTrue writing(mColorWriting); + control->setValue(mColorOriginal); + return; + } + + // The whole pick, as one step, from the colour it started at to the one it + // was left at -- and none at all if it ended where it began. + const LLSD picked = control->getValue(); + if (mRecordedKeys.count(key) && !llsd_equals(picked, mColorOriginal)) + { + ScopedHistoryGroup group(mHistory); + mHistory.record(key, mColorOriginal, picked, (F32)LLTimer::getElapsedSeconds().value()); + } +} + +void ALFloaterLightBox::fitSections() +{ + // The margin under a section's last row. + constexpr S32 BOTTOM_MARGIN = 8; + + for (const ALLightboxDirectory::Section& section : mDirectory.sections()) + { + LLPanel* panel = section.mPanel; + if (!panel) + { + continue; + } + // Every row follows the panel's top, so how far the lowest one reaches + // down from it is the height the panel needs, whatever it was given. + const S32 height = panel->getRect().getHeight(); + S32 lowest = height; + for (const LLView* child : *panel->getChildList()) + { + lowest = llmin(lowest, child->getRect().mBottom); + } + // Said to the tab the way everything that grows inside an accordion + // says it: the tab takes the height plus its header and padding (or + // remembers it, while it is shut) and asks the accordion to lay out + // again. Sent whether or not it differs, so a tab declared at the + // wrong height is put right as well. + panel->notifyParent(LLSD().with("action", "size_changes") + .with("height", height - lowest + BOTTOM_MARGIN)); + } +} + +const std::string& ALFloaterLightBox::paneKey(size_t page) const +{ + return page < mTabPages.size() ? mTabPages[page]->getName() : LLStringUtil::null; +} + +void ALFloaterLightBox::togglePane() +{ + const S32 current = mTabs ? mTabs->getCurrentPanelIndex() : -1; + if (current < 0 || (size_t)current >= mTabPages.size()) + { + return; + } + mFolds.toggleOut(paneKey((size_t)current)); + refreshPaneRow(); +} + +void ALFloaterLightBox::dockPane(size_t page) +{ + if (mFolds.out(paneKey(page))) + { + mFolds.toggleOut(paneKey(page)); + } + refreshPaneRow(); +} + +void ALFloaterLightBox::refreshPaneRow() +{ + if (mPaneEmpties.empty() || !mTabs) + { + return; + } + + U32 out = 0; + for (size_t index = 0; index < mTabPages.size(); ++index) + { + if (mFolds.out(paneKey(index))) + { + out |= 1u << index; + } + } + const S32 current = mTabs->getCurrentPanelIndex(); + const S32 state = (S32)(out << 4) | (current & 0xF); + if (state == mPaneRowState) + { + return; + } + mPaneRowState = state; + + for (size_t index = 0; index < mPaneEmpties.size(); ++index) + { + mPaneEmpties[index]->setVisible(((out >> index) & 1) != 0); + } + + // The button speaks for the tab that is up: out, it brings the page + // back; here, it sends it out. + if (mPopOutButton && current >= 0) + { + const bool current_out = ((out >> current) & 1) != 0; + mPopOutButton->setImageOverlay(current_out ? "Conv_toolbar_arrow_sw" : "Conv_toolbar_arrow_ne"); + LLStringUtil::format_map_t args; + args["[TAB]"] = pageLabel((size_t)current); + mPopOutButton->setToolTip(getString(current_out ? "pane_put_back_tooltip" : "pane_take_out_tooltip", args)); + } + + // Written down as it changes, not only on close: a torn-off window's own + // close box puts its page back without telling anyone, and a viewer that + // goes down with the page out should still find it out next time. + if (out != mPanesOutSaved) + { + mPanesOutSaved = out; + saveState(); + } +} + +void ALFloaterLightBox::openHistory() +{ + constexpr S32 WIDTH = 340; + constexpr S32 HEIGHT = 280; + ALHistoryList::Params hp(LLUICtrlFactory::getDefaultParams()); + hp.name = "lightbox_history_list"; + hp.rect = LLRect(0, HEIGHT, WIDTH, 0); + hp.empty_headline = getString("history_empty_headline"); + hp.empty_sentence = getString("history_empty_sentence"); + ALHistoryList* list = LLUICtrlFactory::create(hp); + + // The floater's own keys work while the list is up -- Ctrl+Z steps it + // with the marker moving, which is the point of having it open -- since + // the popover sends home the keys it does not take. Return in the list + // goes to the selected step, as a double click does. + LLView* anchor = mHistoryButton ? static_cast(mHistoryButton) + : (mTopBar ? static_cast(mTopBar) : static_cast(this)); + if (!showPopover(PopoverKind::History, anchor, list)) + { + return; + } + + LLHandle self = getDerivedHandle(); + mHistoryList = list->getHandle(); + list->onGoTo([self](size_t in_force) + { + // The list counts its first row, which is the start of the history + // rather than a step, so the cursor is one less than it asks for. + ALFloaterLightBox* floater = self.get(); + if (floater && in_force > 0) + { + floater->goToHistory(in_force - 1); + } + }); + fillHistoryList(); +} + +void ALFloaterLightBox::fillHistoryList() +{ + ALHistoryList* list = ALViewType::as(mHistoryList.get()); + if (!list) + { + return; + } + mHistoryListRevision = mHistory.revision(); + + // Nothing done yet: an empty list, which is what makes it say so. + std::vector steps; + if (mHistory.depth() == 0) + { + showHistory(list, std::move(steps), 0); + return; + } + + // A first row that is no step at all: where the history starts. Without + // it no row could ask for nothing to be in force, and undoing everything + // is the one trip back a person is most likely to want. + steps.push_back({ getString("history_start"), std::string() }); + for (size_t index = 0; index < mHistory.depth(); ++index) + { + const ALGradeHistory::Transaction& changes = mHistory.at(index); + ALHistoryList::Step step; + step.what = mHistory.labelOf(index); + if (step.what.empty()) + { + if (changes.size() == 1) + { + step.what = mDirectory.captionFor(changes.front().mName); + } + else + { + LLStringUtil::format_map_t args; + args["[COUNT]"] = fmt::format("{}", changes.size()); + step.what = getString("history_settings", args); + } + } + + // Where, when every change in the step is in one section: that is + // what tells five Strength steps apart. A step across sections -- + // a Look, a section with an Advanced tail -- says it in its name. + const ALLightboxDirectory::Section* common = nullptr; + bool one_section = !changes.empty(); + for (const ALGradeHistory::Change& change : changes) + { + const ALLightboxDirectory::Section* in = mDirectory.sectionOf(change.mName); + if (!in || (common && in != common)) + { + one_section = false; + break; + } + common = in; + } + if (one_section && common) + { + step.where = common->mTitle; + } + steps.push_back(std::move(step)); + } + showHistory(list, std::move(steps), mHistory.cursor() + 1); +} + +void ALFloaterLightBox::goToHistory(size_t cursor) +{ + // A step at a time through applyHistory, so every step is written back + // exactly the way Ctrl+Z and Ctrl+Y write it. The guard is for a stack + // that stops moving, which applyHistory reports rather than loops on. + size_t guard = mHistory.depth() + 1; + while (mHistory.cursor() > cursor && guard-- > 0) + { + if (!applyHistory(false)) + { + break; + } + } + while (mHistory.cursor() < cursor && guard-- > 0) + { + if (!applyHistory(true)) + { + break; + } + } +} + +std::string ALFloaterLightBox::pageLabel(size_t page) const +{ + return page < mTabPages.size() ? mTabPages[page]->getLabel() : std::string(); +} + +void ALFloaterLightBox::openFind() +{ + // Sections as well as settings: the list matches what a row is called, + // and "bloom" should reach the Bloom section even though none of its rows + // says bloom. Each setting says which section it is in, which is what + // tells the five Strength rows apart. A section hidden for the renderer's + // mode is nowhere to go, and neither is anything in it. + const std::vector& sections = mDirectory.sections(); + std::vector> rows(sections.size()); + for (const ALLightboxDirectory::Setting& setting : mDirectory.settings()) + { + ALQuickOpen::Candidate row; + row.label = setting.mCaption; + row.detail = sections[setting.mSection].mTitle; + row.value = "k:" + setting.mKey; + rows[setting.mSection].push_back(std::move(row)); + } + std::vector candidates; + for (size_t index = 0; index < sections.size(); ++index) + { + const ALLightboxDirectory::Section& section = sections[index]; + if (section.mTab && !section.mTab->getVisible()) + { + continue; + } + ALQuickOpen::Candidate here; + here.label = section.mTitle; + here.detail = pageLabel(section.mPage); + here.value = "s:" + section.mName; + candidates.push_back(std::move(here)); + for (ALQuickOpen::Candidate& row : rows[index]) + { + candidates.push_back(std::move(row)); + } + } + + // Hung from the bar and as wide as it, so it reads as the bar opening + // rather than as another window. The popover the base opens is closed + // before the jump acts, so the keyboard is back here to hand on. + LLView* anchor = mTopBar ? static_cast(mTopBar) : static_cast(this); + LLHandle self = getDerivedHandle(); + quickOpen(std::move(candidates), getString("find_placeholder"), LLStringUtil::null, + [self](const std::string& target) + { + if (ALFloaterLightBox* floater = self.get()) + { + floater->jumpTo(target); + } + }, + anchor, llmax(anchor->getRect().getWidth(), 300)); +} + +void ALFloaterLightBox::jumpTo(const std::string& target) +{ + const ALLightboxDirectory::Section* section = nullptr; + const ALLightboxDirectory::Setting* setting = nullptr; + if (target.compare(0, 2, "s:") == 0) + { + section = mDirectory.section(target.substr(2)); + } + else if (target.compare(0, 2, "k:") == 0) + { + setting = mDirectory.setting(target.substr(2)); + if (setting) + { + section = &mDirectory.sections()[setting->mSection]; + } + } + // Nor a section hidden since the list was made: HDR switched with Find up. + if (!section || (section->mTab && !section->mTab->getVisible())) + { + return; + } + + if (isMinimized()) + { + setMinimized(false); + } + // A page out in a window of its own is shown by raising the window; its + // tab here only says where it went. + if (LLFloater* window = mFolds.window(paneKey(section->mPage))) + { + if (window->isMinimized()) + { + window->setMinimized(false); + } + window->setFrontmost(true); + } + else if (section->mPage < mTabPages.size()) + { + mTabs->selectTabPanel(mTabPages[section->mPage]); + } + + // Only the target's own tab is opened: an Advanced section is a sibling of + // its essentials, not inside them. setDisplayChildren does not lay the + // accordion out again, and the scroll below needs it laid out. + if (section->mTab && !section->mTab->getDisplayChildren()) + { + section->mTab->setDisplayChildren(true); + if (section->mAccordion) + { + section->mAccordion->arrange(); + } + } + + if (LLTextBox* was_lit = ALViewType::as(mLitCaption.get())) + { + was_lit->setHighlighted(false); + } + mLitCaption.markDead(); + + if (setting && setting->mCtrl) + { + // Scrolled to the row, not to the section: a tall section scrolled to + // shows its bottom, which is not where this row need be. + setting->mCtrl->onUpdateScrollToChild(setting->mCtrl); + setting->mCtrl->setFocus(true); + if (setting->mCaptionBox) + { + setting->mCaptionBox->setHighlighted(true); + mLitCaption = setting->mCaptionBox->getHandle(); + mLitTimer.reset(); + } + } + else if (section->mTab) + { + section->mTab->showAndFocusHeader(); + } } std::shared_ptr ALFloaterLightBox::getScrubbableDay() const @@ -873,6 +1720,11 @@ void ALFloaterLightBox::onGradeSettingChanged(const std::string& name, const LLS // between two values instead of walking backwards. return; } + if (mColorWriting) + { + // The inline picker, moving. The pick is recorded whole when it ends. + return; + } // A monotonic clock is all the history wants: it compares two of these to // decide whether one drag is still in progress, and never reads the value @@ -882,6 +1734,13 @@ void ALFloaterLightBox::onGradeSettingChanged(const std::string& name, const LLS bool ALFloaterLightBox::applyHistory(bool redo_direction) { + // A pick still open has not been recorded yet, so the stack does not know + // about it: put it back before stepping past it. + if (mPopoverKind == PopoverKind::Color) + { + closePopover(true); + } + const ALGradeHistory::Transaction* stepp = redo_direction ? mHistory.redo() : mHistory.undo(); if (!stepp) { @@ -912,24 +1771,16 @@ bool ALFloaterLightBox::applyHistory(bool redo_direction) bool ALFloaterLightBox::handleKeyHere(KEY key, MASK mask) { - // Reached only after the focus chain has declined the key, so a text field - // in the middle of an edit keeps Ctrl+Z for its own undo. - if (key == 'Z' && mask == MASK_CONTROL) + if (key == 'F' && mask == MASK_CONTROL) { - applyHistory(false); + openFind(); return true; } - - // Both spellings: Ctrl+Y is the Windows convention and Ctrl+Shift+Z the one - // every grading application uses. - if ((key == 'Y' && mask == MASK_CONTROL) || - (key == 'Z' && mask == (MASK_CONTROL | MASK_SHIFT))) + if (handleUndoKeys(key, mask)) { - applyHistory(true); return true; } - - return LLFloater::handleKeyHere(key, mask); + return ALStudioFloater::handleKeyHere(key, mask); } void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) @@ -941,7 +1792,7 @@ void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) std::string setting; S32 component = 0; - if (!parseVec3WidgetName(ctrl->getName(), setting, component)) + if (!ALLightboxDirectory::parseVec3Name(ctrl->getName(), setting, component)) { return; } @@ -966,69 +1817,66 @@ void ALFloaterLightBox::onCommitVec3(LLUICtrl* ctrl) namespace { -// The three settings the tone curve graph edits, and the order the handles -// are built in. Kept together so the graph and the spinner rows below it can -// never disagree about which key is which. -const char* const TONE_CURVE_TOE = "RenderColorGradeCurveToe"; -const char* const TONE_CURVE_SHOULDER = "RenderColorGradeCurveShoulder"; -const char* const TONE_CURVE_STRENGTH = "RenderColorGradeCurveStrength"; - -// The split-toning settings the band graph reads. The tints colour the bands, -// the amounts fade them, and the balance is the only one the graph writes. -const char* const SPLIT_TONE_SHADOW = "RenderSplitToneShadowTint"; -const char* const SPLIT_TONE_MIDTONE = "RenderSplitToneMidtoneTint"; -const char* const SPLIT_TONE_HIGHLIGHT = "RenderSplitToneHighlightTint"; -const char* const SPLIT_TONE_AMOUNT = "RenderSplitToneAmount"; -const char* const SPLIT_TONE_MIDTONE_AMOUNT = "RenderSplitToneMidtoneAmount"; -const char* const SPLIT_TONE_BALANCE = "RenderSplitToneBalance"; - -LLColor3 getColor3(const char* key) +// The four curve settings are loaded fresh on every refresh and commit rather +// than mirrored in a member: the settings are the truth, and undo, Looks and +// Debug Settings all write them behind the floater's back. Their names live +// on ALToneCurveSet, the one list the renderer and the Looks whitelist share. +ALToneCurveSet loadToneCurveSet() { - return gSavedSettings.getColor3(key); + ALToneCurveSet curves; + for (S32 c = 0; c < ALToneCurveSet::CH_COUNT; ++c) + { + const ALToneCurveSet::EChannel channel = static_cast(c); + curves.setCurveFromLLSD(channel, gSavedSettings.getLLSD(ALToneCurveSet::settingName(channel))); + } + return curves; } -// Write one component, or all three when channel is negative. Rebuilds the -// whole array the way onCommitVec3 does, so an untouched component keeps its -// value rather than being re-derived from a rounded read-back. -void setColor3Component(const char* key, S32 channel, F32 value) +// Ghosts of the channels not being edited, and the solid handle colour of the +// one that is: the same three hues, dim and bright. +const LLColor4 CHANNEL_TINT[3] = { + LLColor4(0.8f, 0.25f, 0.25f, 0.55f), + LLColor4(0.25f, 0.8f, 0.35f, 0.55f), + LLColor4(0.35f, 0.5f, 0.9f, 0.55f) }; +const LLColor4 CHANNEL_SOLID[3] = { + LLColor4(0.9f, 0.3f, 0.3f, 1.f), + LLColor4(0.3f, 0.85f, 0.4f, 1.f), + LLColor4(0.4f, 0.55f, 0.95f, 1.f) }; +// What a channel actually gets once Master is applied after it. +const LLColor4 COMPOSITE_TINT(0.85f, 0.85f, 0.85f, 0.4f); + +// The split-toning settings the band graph reads. The tints colour the bands, +// the amounts fade them, and the balance and the two widths are what its +// three handles write. +const char* const SPLIT_TONE_SHADOW = "RenderSplitToneShadowTint"; +const char* const SPLIT_TONE_MIDTONE = "RenderSplitToneMidtoneTint"; +const char* const SPLIT_TONE_HIGHLIGHT = "RenderSplitToneHighlightTint"; +const char* const SPLIT_TONE_AMOUNT = "RenderSplitToneAmount"; +const char* const SPLIT_TONE_MIDTONE_AMOUNT = "RenderSplitToneMidtoneAmount"; +const char* const SPLIT_TONE_BALANCE = "RenderSplitToneBalance"; +const char* const SPLIT_TONE_SHADOW_WIDTH = "RenderSplitToneShadowWidth"; +const char* const SPLIT_TONE_HIGHLIGHT_WIDTH = "RenderSplitToneHighlightWidth"; + +// The range an edge handle clamps a width into is the width slider's own, +// read from the widget so the XUI stays the single home of it. The renderer +// itself only floors a width (ALCurveModel::SPLIT_TONE_MIN_WIDTH), so Debug +// Settings can still ask for more; the graph and the render agree either +// way, because both go through the model. A dropped slider falls back to the +// floor and the domain. +void widthRange(const LLSliderCtrl* slider, F32& lo, F32& hi) { - LLControlVariable* controlp = gSavedSettings.getControl(key); - if (!controlp) + lo = ALCurveModel::SPLIT_TONE_MIN_WIDTH; + hi = 1.f; + if (slider) { - return; + lo = slider->getMinValue(); + hi = slider->getMaxValue(); } - const LLSD current = controlp->getValue(); - LLSD updated = LLSD::emptyArray(); - for (S32 i = 0; i < 3; ++i) - { - const bool touched = (channel < 0) || (channel == i); - updated.append(LLSD::Real(touched ? value : current[i].asReal())); - } - controlp->set(updated); } -/// Where the curve departs furthest from the identity. That is the most -/// sensitive place to read strength back from a dragged handle -- solving -/// k = (y - x) / (s - x) anywhere the two are close would amplify a pixel of -/// pointer movement into a wild swing -- and it is also where the eye reads -/// the curve's effect, so it is where the handle belongs. -F32 findMaxDeviation(F32 toe, F32 shoulder) +LLColor3 getColor3(const char* key) { - constexpr S32 SAMPLES = 64; - F32 best_x = 0.5f; - F32 best_gap = -1.f; - for (S32 i = 1; i < SAMPLES; ++i) - { - const F32 x = (F32)i / (F32)SAMPLES; - // The pure curve at full strength: the deviation the blend scales. - const F32 gap = fabsf(ALCurveModel::smoothstep(x, toe, shoulder, 1.f) - x); - if (gap > best_gap) - { - best_gap = gap; - best_x = x; - } - } - return best_x; + return gSavedSettings.getColor3(key); } } // namespace @@ -1047,13 +1895,14 @@ void ALFloaterLightBox::setupToneCurve() mToneCurveChannel = findChild("tone_curve_channel"); if (mToneCurveChannel) { - // Start linked. Without an explicit selection an unselected combo + // Start on Master. Without an explicit selection an unselected combo // reads back as 0, which would silently mean "red only". mToneCurveChannel->selectByValue(LLSD(-1)); } - for (const char* key : { TONE_CURVE_TOE, TONE_CURVE_SHOULDER, TONE_CURVE_STRENGTH }) + for (S32 c = 0; c < ALToneCurveSet::CH_COUNT; ++c) { + const char* key = ALToneCurveSet::settingName(static_cast(c)); if (LLControlVariable* controlp = gSavedSettings.getControl(key)) { mToneCurveConnections.emplace_back(controlp->getSignal()->connect( @@ -1061,6 +1910,16 @@ void ALFloaterLightBox::setupToneCurve() } } + // The graph names the settings it edits in XUI so the section's Reset All + // can find them; a typo there fails silently, so say so once. + for (const std::string& name : mToneCurve->getEditedSettingNames()) + { + if (!gSavedSettings.getControl(name)) + { + LL_WARNS() << "tone_curve_graph names an unknown setting: " << name << LL_ENDL; + } + } + refreshToneCurve(); } @@ -1071,69 +1930,50 @@ void ALFloaterLightBox::refreshToneCurve() return; } - const LLColor3 toe = getColor3(TONE_CURVE_TOE); - const LLColor3 shoulder = getColor3(TONE_CURVE_SHOULDER); - const LLColor3 strength = getColor3(TONE_CURVE_STRENGTH); - - // Linked edits all three at once and shows one curve; a single channel - // shows its own curve solid with the other two behind it, so you can see - // how far apart you have pulled them. - const S32 channel = getToneCurveChannel(); - const S32 plotted = (channel < 0) ? 0 : channel; - - const F32 t = toe.mV[plotted]; - const F32 s = shoulder.mV[plotted]; - const F32 k = strength.mV[plotted]; + const ALToneCurveSet curves = loadToneCurveSet(); + const ALToneCurveSet::EChannel channel = ALToneCurveSet::channelFromCombo(getToneCurveChannel()); + // A copy: the lambdas below outlive this call. + const ALCurveModel selected = curves.curve(channel); - mToneCurve->setCurve([t, s, k](F32 x) { return ALCurveModel::smoothstep(x, t, s, k); }); + // The selected curve alone is what is drawn solid. Its points are the + // handles, so what is seen is exactly what a drag edits. + mToneCurve->setCurve([selected](F32 x) { return selected.evaluate(x); }); mToneCurve->clearGhostCurves(); - if (channel >= 0) + if (channel != ALToneCurveSet::CH_MASTER && + !ALToneCurveSet::isIdentityCurve(curves.curve(ALToneCurveSet::CH_MASTER))) { - static const LLColor4 channel_tint[3] = { - LLColor4(0.8f, 0.25f, 0.25f, 0.55f), - LLColor4(0.25f, 0.8f, 0.35f, 0.55f), - LLColor4(0.35f, 0.5f, 0.9f, 0.55f) }; - for (S32 i = 0; i < 3; ++i) + // What this channel actually gets once Master is applied after it. + mToneCurve->addGhostCurve([curves, channel](F32 x) { return curves.evaluate(channel, x); }, COMPOSITE_TINT); + } + for (S32 i = 0; i < 3; ++i) + { + const ALToneCurveSet::EChannel other = static_cast(ALToneCurveSet::CH_RED + i); + if (other == channel || ALToneCurveSet::isIdentityCurve(curves.curve(other))) { - if (i == channel) - { - continue; - } - const F32 gt = toe.mV[i]; - const F32 gs = shoulder.mV[i]; - const F32 gk = strength.mV[i]; - mToneCurve->addGhostCurve( - [gt, gs, gk](F32 x) { return ALCurveModel::smoothstep(x, gt, gs, gk); }, - channel_tint[i]); + continue; } + const ALCurveModel ghost = curves.curve(other); + mToneCurve->addGhostCurve([ghost](F32 x) { return ghost.evaluate(x); }, CHANNEL_TINT[i]); } - const F32 strength_x = findMaxDeviation(t, s); - + // One handle per point, in point order: getActiveHandle() is then the + // point index, which is the whole contract onCommitToneCurve relies on. std::vector handles; - ALCurveEditorCtrl::Handle h; - - h.mName = "toe"; - h.mX = t; - h.mY = ALCurveModel::smoothstep(t, t, s, k); - h.mLockY = true; - handles.push_back(h); - - h = ALCurveEditorCtrl::Handle(); - h.mName = "shoulder"; - h.mX = s; - h.mY = ALCurveModel::smoothstep(s, t, s, k); - h.mLockY = true; - handles.push_back(h); - - h = ALCurveEditorCtrl::Handle(); - h.mName = "strength"; - h.mX = strength_x; - h.mY = ALCurveModel::smoothstep(strength_x, t, s, k); - h.mLockX = true; - handles.push_back(h); - + const std::vector& points = selected.getPoints(); + for (size_t i = 0; i < points.size(); ++i) + { + ALCurveEditorCtrl::Handle h; + const bool endpoint = (i == 0 || i + 1 == points.size()); + h.mX = points[i].mX; + h.mY = points[i].mY; + // The model pins the ends too; the lock stops the pointer fighting it. + h.mLockX = endpoint; + h.mColor = (channel == ALToneCurveSet::CH_MASTER) + ? LLColor4::white + : CHANNEL_SOLID[channel - ALToneCurveSet::CH_RED]; + handles.push_back(h); + } mToneCurve->setHandles(std::move(handles)); } @@ -1143,56 +1983,120 @@ void ALFloaterLightBox::onCommitToneCurve() { return; } + const ALCurveEditorCtrl::EAction action = mToneCurve->getAction(); + if (action == ALCurveEditorCtrl::ACTION_NONE) + { + return; + } - const std::string which = mToneCurve->getActiveHandleName(); - const S32 index = mToneCurve->getActiveHandle(); - if (which.empty() || index < 0 || index >= (S32)mToneCurve->getHandles().size()) + const ALToneCurveSet::EChannel channel = ALToneCurveSet::channelFromCombo(getToneCurveChannel()); + LLControlVariable* controlp = gSavedSettings.getControl(ALToneCurveSet::settingName(channel)); + if (!controlp) { return; } - const ALCurveEditorCtrl::Handle handle = mToneCurve->getHandles()[index]; - const S32 channel = getToneCurveChannel(); - const S32 read = (channel < 0) ? 0 : channel; - const F32 toe = getColor3(TONE_CURVE_TOE).mV[read]; - const F32 shoulder = getColor3(TONE_CURVE_SHOULDER).mV[read]; - const F32 strength = getColor3(TONE_CURVE_STRENGTH).mV[read]; + ALToneCurveSet curves = loadToneCurveSet(); + ALCurveModel& curve = curves.curve(channel); - // Writing a setting re-enters through its signal; let the write land, then - // rebuild the handles once from the values that actually stuck. Scoped so - // no early return can ever leave the flag stuck and the graph dead -- and - // the scope must close before the refresh below, which reads the same - // flag and would otherwise skip the rebuild it exists to do. + bool changed = false; + switch (action) { - ScopedTrue updating(mToneCurveUpdating); - - if (which == "toe") - { - // Held at or below the shoulder. The shader tolerates the inversion - // (its reciprocal is guarded) but it renders as a hard step with no - // visible cause; the spinners remain the way to ask for that. - setColor3Component(TONE_CURVE_TOE, channel, llmin(handle.mX, shoulder)); - } - else if (which == "shoulder") + case ALCurveEditorCtrl::ACTION_DRAG: { - setColor3Component(TONE_CURVE_SHOULDER, channel, llmax(handle.mX, toe)); + const S32 index = mToneCurve->getActiveHandle(); + if (index < 0 || index >= curve.getPointCount() || index >= (S32)mToneCurve->getHandles().size()) + { + return; + } + // By value: the refresh below replaces the handle list. + const ALCurveEditorCtrl::Handle handle = mToneCurve->getHandles()[index]; + // Clamps into the neighbours' gap; the ends stay pinned. + curve.movePoint(index, handle.mX, handle.mY); + changed = true; + break; } - else if (which == "strength") + case ALCurveEditorCtrl::ACTION_ADD: + // Past the cap the click is simply ignored, which is the honest + // answer; the tooltip states the limit. + changed = curve.addPoint(mToneCurve->getActionX(), mToneCurve->getActionY()) >= 0; + break; + case ALCurveEditorCtrl::ACTION_REMOVE: { - // The handle rides the curve at a fixed x, so its height is - // mix(x, s, k) and k falls straight out of it. - const F32 x = handle.mX; - const F32 s = ALCurveModel::smoothstep(x, toe, shoulder, 1.f); - const F32 gap = s - x; - if (fabsf(gap) > 1e-3f) + // The same bound the drag has: the handle list and the point list + // are built from each other, but the setting can move between. + const S32 index = mToneCurve->getActiveHandle(); + if (index < 0 || index >= curve.getPointCount()) { - setColor3Component(TONE_CURVE_STRENGTH, channel, llclamp((handle.mY - x) / gap, 0.f, 1.f)); + return; } + // False for an endpoint or a two-point curve. + changed = curve.removePoint(index); + break; } + default: + break; + } + + if (!changed) + { + // Put the handle back where the model kept the point. + refreshToneCurve(); + return; + } + + // One setting per commit: that is what lets the history coalesce a drag + // into a single undo step. Writing a setting re-enters through its + // signal; let the write land, then rebuild the handles once from the + // values that actually stuck. Scoped so no early return can leave the + // flag stuck, and closed before the refresh below, which reads it. + { + ScopedTrue updating(mToneCurveUpdating); + controlp->set(ALToneCurveSet::pointsToLLSD(curve.getPoints())); } refreshToneCurve(); } +void ALFloaterLightBox::onClickResetToneCurveChannel() +{ + // resetToDefault fires the setting's signal, which refreshes the graph. + const ALToneCurveSet::EChannel channel = ALToneCurveSet::channelFromCombo(getToneCurveChannel()); + if (LLControlVariable* controlp = gSavedSettings.getControl(ALToneCurveSet::settingName(channel))) + { + controlp->resetToDefault(true); + } +} + +void ALFloaterLightBox::onToneCurvePreset(const LLSD& userdata) +{ + // Point lists, both axes 0..1, with the ends at x = 0 and 1 as the model + // pins them. Modest on purpose: a preset is a starting shape. + typedef std::vector points_t; + static const std::map presets = { + { "linear", { { 0.f, 0.f }, { 1.f, 1.f } } }, + { "soft_s", { { 0.f, 0.f }, { 0.25f, 0.21f }, { 0.75f, 0.79f }, { 1.f, 1.f } } }, + { "strong_s", { { 0.f, 0.f }, { 0.25f, 0.16f }, { 0.75f, 0.84f }, { 1.f, 1.f } } }, + { "fade", { { 0.f, 0.08f }, { 0.3f, 0.32f }, { 1.f, 1.f } } }, + { "matte", { { 0.f, 0.08f }, { 0.3f, 0.32f }, { 0.75f, 0.76f }, { 1.f, 0.94f } } }, + }; + const auto found = presets.find(userdata.asString()); + if (found == presets.end()) + { + return; + } + + ALCurveModel curve; + curve.setPoints(found->second); + + // One setting, the selected curve; its signal refreshes the graph. No + // re-entry guard: nothing is mid-drag. + const ALToneCurveSet::EChannel channel = ALToneCurveSet::channelFromCombo(getToneCurveChannel()); + if (LLControlVariable* controlp = gSavedSettings.getControl(ALToneCurveSet::settingName(channel))) + { + controlp->set(ALToneCurveSet::pointsToLLSD(curve.getPoints())); + } +} + void ALFloaterLightBox::setupSplitToneGraph() { mSplitToneGraph = findChild("split_tone_graph"); @@ -1200,13 +2104,24 @@ void ALFloaterLightBox::setupSplitToneGraph() { return; } + // The widths are setting rows; the range widthRange reads is still the + // slider's own, reached through its row. + if (ALSettingRow* row = findChild("split_shadow_width")) + { + mSplitShadowWidthSlider = row->getSlider(); + } + if (ALSettingRow* row = findChild("split_highlight_width")) + { + mSplitHighlightWidthSlider = row->getSlider(); + } - // Every input, not just the balance: the tints decide what colour a band - // is drawn in and the amounts decide how solid, so a graph that only - // watched the balance would sit there showing a tint the renderer had - // already stopped applying. + // Every input, not just the three the handles write: the tints decide + // what colour a band is drawn in and the amounts decide how solid, so a + // graph that only watched its own settings would sit there showing a + // tint the renderer had already stopped applying. for (const char* key : { SPLIT_TONE_SHADOW, SPLIT_TONE_MIDTONE, SPLIT_TONE_HIGHLIGHT, - SPLIT_TONE_AMOUNT, SPLIT_TONE_MIDTONE_AMOUNT, SPLIT_TONE_BALANCE }) + SPLIT_TONE_AMOUNT, SPLIT_TONE_MIDTONE_AMOUNT, SPLIT_TONE_BALANCE, + SPLIT_TONE_SHADOW_WIDTH, SPLIT_TONE_HIGHLIGHT_WIDTH }) { if (LLControlVariable* controlp = gSavedSettings.getControl(key)) { @@ -1228,6 +2143,11 @@ void ALFloaterLightBox::refreshSplitToneGraph() const F32 mid = ALCurveModel::splitToneMid(gSavedSettings.getF32(SPLIT_TONE_BALANCE)); const F32 amount = llclamp(gSavedSettings.getF32(SPLIT_TONE_AMOUNT), 0.f, 1.f); const F32 mid_amount = llclamp(gSavedSettings.getF32(SPLIT_TONE_MIDTONE_AMOUNT), 0.f, 1.f); + // Not clamped to the slider range: the model floors a width the way the + // renderer does, so the bands show what is actually being applied even + // when Debug Settings asked for something the sliders would not. + const F32 ws = gSavedSettings.getF32(SPLIT_TONE_SHADOW_WIDTH); + const F32 wh = gSavedSettings.getF32(SPLIT_TONE_HIGHLIGHT_WIDTH); // A band wears the tint it applies. Normalised the way pipeline.cpp // normalises it, so what the graph shows is the hue the renderer will @@ -1249,50 +2169,107 @@ void ALFloaterLightBox::refreshSplitToneGraph() // Alpha follows the amount, so a band the renderer is ignoring fades // instead of sitting at full strength claiming otherwise. It never // reaches zero: where the bands lie is worth seeing even when nothing - // is being applied, and that is what the balance handle moves. + // is being applied, and that is what the handles move. out.mV[VALPHA] = 0.16f + 0.44f * strength; return out; }; mSplitToneGraph->clearFillCurves(); mSplitToneGraph->addFillCurve( - [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mShadow; }, + [mid, ws, wh](F32 l) { return ALCurveModel::splitToneWeights(l, mid, ws, wh).mShadow; }, band_color(SPLIT_TONE_SHADOW, amount)); mSplitToneGraph->addFillCurve( - [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mMidtone; }, + [mid, ws, wh](F32 l) { return ALCurveModel::splitToneWeights(l, mid, ws, wh).mMidtone; }, band_color(SPLIT_TONE_MIDTONE, mid_amount)); mSplitToneGraph->addFillCurve( - [mid](F32 l) { return ALCurveModel::splitToneWeights(l, mid).mHighlight; }, + [mid, ws, wh](F32 l) { return ALCurveModel::splitToneWeights(l, mid, ws, wh).mHighlight; }, band_color(SPLIT_TONE_HIGHLIGHT, amount)); - // The handle rides the peak of the midtone band, which is exactly where the - // two ramps cross and hand over -- the split point the setting names. Held - // to the horizontal, because moving it up or down would mean nothing. - ALCurveEditorCtrl::Handle handle; - handle.mName = "balance"; - handle.mX = mid; - handle.mY = 1.f; - handle.mLockY = true; - mSplitToneGraph->setHandles({ handle }); + // Three handles along the top edge, all held to the horizontal because + // moving any of them up or down would mean nothing. Balance first, so a + // tie in the hit test goes to the split point. It rides the peak of the + // midtone band, where the two ramps hand over. + ALCurveEditorCtrl::Handle balance; + balance.mName = "balance"; + balance.mX = mid; + balance.mY = 1.f; + balance.mLockY = true; + + // The edges are the corners where the shadow band starts to fall away and + // where the highlight band reaches full strength, at the width the model + // actually renders -- floored like the ramp, so a zero or negative width + // from Debug Settings puts the handle where the band really ends. A ramp + // that runs off the plot has its handle held at the edge, locked and + // dimmed: the only positions a drag could report from there lie inside + // the plot, and writing one back would silently replace a wide ramp with + // a narrow one. The slider is the control for that case, and the commit + // refuses a locked handle. + auto edge_handle = [&band_color, amount](const char* name, F32 true_x, const char* tint_key) + { + ALCurveEditorCtrl::Handle h; + h.mName = name; + h.mX = llclamp(true_x, 0.f, 1.f); + h.mY = 1.f; + h.mLockY = true; + const bool parked = (true_x < 0.f || true_x > 1.f); + h.mLockX = parked; + h.mColor = band_color(tint_key, amount); + h.mColor.mV[VALPHA] = parked ? 0.5f : 1.f; + return h; + }; + const F32 ws_drawn = llmax(ws, ALCurveModel::SPLIT_TONE_MIN_WIDTH); + const F32 wh_drawn = llmax(wh, ALCurveModel::SPLIT_TONE_MIN_WIDTH); + + mSplitToneGraph->setHandles({ balance, + edge_handle("shadow_edge", mid - ws_drawn, SPLIT_TONE_SHADOW), + edge_handle("highlight_edge", mid + wh_drawn, SPLIT_TONE_HIGHLIGHT) }); } void ALFloaterLightBox::onCommitSplitToneGraph() { - if (!mSplitToneGraph || mSplitToneGraph->getHandles().empty()) + // Only a drag means anything here. The graph never asks for points, but a + // future XUI that set points_editable must not turn a double-click into a + // balance write. + if (!mSplitToneGraph || mSplitToneGraph->getAction() != ALCurveEditorCtrl::ACTION_DRAG) { return; } + const S32 index = mSplitToneGraph->getActiveHandle(); + if (index < 0 || index >= (S32)mSplitToneGraph->getHandles().size()) + { + return; + } + const ALCurveEditorCtrl::Handle handle = mSplitToneGraph->getHandles()[index]; + const F32 mid = ALCurveModel::splitToneMid(gSavedSettings.getF32(SPLIT_TONE_BALANCE)); - // Dragged past either end, the balance clamps and the refresh below puts - // the handle back where the setting actually landed, rather than leaving it - // parked somewhere the renderer will not follow. - const F32 mid = mSplitToneGraph->getHandles()[0].mX; - - // Scoped for the same reason onCommitToneCurve's guard is, and closed - // before the refresh for the same reason too. + // Dragged past its range, the value clamps and the refresh below puts the + // handle back where the setting actually landed, rather than leaving it + // parked somewhere the renderer will not follow. One setting per handle: + // the balance drag moves both edges by writing only the balance, which is + // what keeps a drag one undo step. An edge handle held at the plot edge + // (mLockX, see refreshSplitToneGraph) writes nothing: its position is not + // the width, and the width slider is the control for it. + // + // Scoped for the same reason the tone curve guard is, and closed before + // the refresh for the same reason too. { ScopedTrue updating(mSplitToneUpdating); - gSavedSettings.setF32(SPLIT_TONE_BALANCE, ALCurveModel::splitToneBalance(mid)); + if (handle.mName == "balance") + { + gSavedSettings.setF32(SPLIT_TONE_BALANCE, ALCurveModel::splitToneBalance(handle.mX)); + } + else if (handle.mName == "shadow_edge" && !handle.mLockX) + { + F32 lo, hi; + widthRange(mSplitShadowWidthSlider, lo, hi); + gSavedSettings.setF32(SPLIT_TONE_SHADOW_WIDTH, llclamp(mid - handle.mX, lo, hi)); + } + else if (handle.mName == "highlight_edge" && !handle.mLockX) + { + F32 lo, hi; + widthRange(mSplitHighlightWidthSlider, lo, hi); + gSavedSettings.setF32(SPLIT_TONE_HIGHLIGHT_WIDTH, llclamp(handle.mX - mid, lo, hi)); + } } refreshSplitToneGraph(); } @@ -1346,6 +2323,9 @@ void ALFloaterLightBox::onWhiteBalancePicked(const LLColor3& sample) return; } + // One pick is one thing the user did, though it moves two sliders: without + // the group it undid as two steps, temperature first and then tint. + ScopedHistoryGroup group(mHistory, getString("history_white_balance")); gSavedSettings.setF32("RenderColorGradeWhiteBalanceCCT", solved.mCCTOffset); gSavedSettings.setF32("RenderColorGradeWhiteBalanceDuv", solved.mDuv); } @@ -1358,7 +2338,9 @@ void ALFloaterLightBox::onLookSelected() // Applying a Look writes every whitelisted key it carries. That is one // choice, so it is one undo step -- and it is the step a user most // wants back, having tried a Look on top of work they liked. - ScopedHistoryGroup group(mHistory); + LLStringUtil::format_map_t args; + args["[NAME]"] = name; + ScopedHistoryGroup group(mHistory, getString("history_look", args)); LLPresetsManager::getInstance()->loadLooksPreset(name); } } @@ -1396,7 +2378,9 @@ void ALFloaterLightBox::onClickLookRevert() { // Same reasoning as onLookSelected: a revert throws away every edit // since the Look was applied, and that had better be one Ctrl+Z. - ScopedHistoryGroup group(mHistory); + LLStringUtil::format_map_t args; + args["[NAME]"] = last; + ScopedHistoryGroup group(mHistory, getString("history_revert", args)); LLPresetsManager::getInstance()->loadLooksPreset(last); } } @@ -1431,28 +2415,77 @@ void ALFloaterLightBox::refreshLooksBar() args["[NAME]"] = last; mLooksCombo->setLabel(getString("look_name_modified", args)); } - getChild("look_save")->setEnabled(!active.empty() || !last.empty()); - getChild("look_delete")->setEnabled(mLooksCombo->getItemCount() > 0); - getChild("look_revert")->setEnabled(modified); + if (mLookSave) + { + mLookSave->setEnabled(!active.empty() || !last.empty()); + } + if (mLookDelete) + { + mLookDelete->setEnabled(mLooksCombo->getItemCount() > 0); + } + if (mLookRevert) + { + mLookRevert->setEnabled(modified); + } } void ALFloaterLightBox::updateTonemapperRows() { - // Khronos Neutral (0), ACES (1), and GT (5) take no parameters, so their - // selection leaves every per-operator row disabled. const S32 type = gSavedSettings.getS32("AlchemyRenderTonemapType"); - static const std::pair param_rows[] = { - { "tone_aces_white", 2 }, - { "tone_reinhard_white", 3 }, - { "tone_filmic_white", 4 }, - { "tone_agx_contrast", 6 }, - { "tone_agx_white", 6 }, + for (const TonemapperRow& row : mTonemapperRows) + { + if (row.mCtrl) + { + row.mCtrl->setEnabled(type == row.mType); + } + } +} + +void ALFloaterLightBox::refreshBloomSections() +{ + // The renderer's own test (LLPipeline::createGLBuffers, renderFinalize): + // HDR bloom runs with RenderHDREnabled on GL above 4.05, and the legacy + // glow runs whenever it does not. Keep the two in step. The cross filter + // is on the HDR side with the bloom it is seeded from: it streaks what + // the bloom pyramid's top holds, and without the pyramid it draws nothing. + const bool hdr = gGLManager.mGLVersion > 4.05f && gSavedSettings.getBOOL("RenderHDREnabled"); + static const std::pair bloom_sections[] = { + { "sec_bloom", true }, + { "sec_bloom_adv", true }, + { "sec_crossfilter", true }, + { "sec_crossfilter_adv", true }, + { "sec_glow", false }, + { "sec_glow_adv", false }, }; - for (const auto& row : param_rows) + std::set changed; + for (const auto& [name, for_hdr] : bloom_sections) + { + const ALLightboxDirectory::Section* section = mDirectory.section(name); + if (!section || !section->mTab) + { + continue; + } + const bool shown = (for_hdr == hdr); + if (section->mTab->getVisible() == shown) + { + continue; + } + // The keyboard does not stay in a section that is going. + if (!shown && gFocusMgr.childHasKeyboardFocus(section->mTab)) + { + gFocusMgr.setKeyboardFocus(nullptr); + } + section->mTab->setVisible(shown); + if (section->mAccordion) + { + changed.insert(section->mAccordion); + } + } + // A tab's visibility does not lay its accordion out again; the gap it + // leaves, or the room it needs, only appears on the next arrange. + for (LLAccordionCtrl* accordion : changed) { - const bool active = (type == row.second); - getChild(row.first)->setEnabled(active); - getChild(std::string(row.first) + "_rst")->setEnabled(active); + accordion->arrange(); } } diff --git a/indra/newview/alfloaterlightbox.h b/indra/newview/alfloaterlightbox.h index 0dca86b29de..3988d99fdd2 100644 --- a/indra/newview/alfloaterlightbox.h +++ b/indra/newview/alfloaterlightbox.h @@ -33,25 +33,36 @@ #ifndef AL_FLOATERLIGHTBOX_H #define AL_FLOATERLIGHTBOX_H -#include "llfloater.h" +#include "alstudiofloater.h" +#include "llframetimer.h" #include "aldaycyclelandmarks.h" #include "algradehistory.h" +#include "allightboxdirectory.h" #include #include #include +#include #include #include class ALCurveEditorCtrl; +class ALEmptyState; +class ALPopover; +class LLButton; +class LLColor4; +class LLColorSwatchCtrl; class LLComboBox; +class LLPanel; class LLSettingsDay; class LLSettingsSky; class LLSettingsWater; +class LLSliderCtrl; class LLSpinCtrl; +class LLTabContainer; -class ALFloaterLightBox final : public LLFloater +class ALFloaterLightBox final : public ALStudioFloater { public: AL_VIEW_TYPE(ALFloaterLightBox, LLFloater); @@ -63,15 +74,96 @@ class ALFloaterLightBox final : public LLFloater /// /// Floater-local rather than a global action, unlike the hold-to-compare /// key: a global Ctrl+Z would fire while the user is typing anywhere in - /// the viewer. Being handled here also means a text field that wants - /// Ctrl+Z for its own undo gets it first, since the focus chain is offered - /// the key before the floater is. + /// the viewer. The studio base answers Ctrl+Z and Ctrl+Y/Ctrl+Shift+Z + /// through undo() and redo(), and Ctrl+F is this floater's own. bool handleKeyHere(KEY key, MASK mask) override; + bool undo() override { return applyHistory(false); } + bool redo() override { return applyHistory(true); } /// Only to notice the reference still appearing or going away, which is /// render state and so has no signal to hang on. See refreshReferenceRow. void draw() override; + /// A popover still up is closed as settled first: whatever it was showing + /// was chosen, and it must not outlive the floater its callbacks reach. + void onClose(bool app_quitting) override; private: + /// What the popover that is up, if any, is for. Find is the studio + /// base's own quick-open popover, and is not one of these. + enum class PopoverKind + { + None, + History, + Color, + }; + /// Put @a content in a popover under @a anchor, closing any other first: + /// the Lightbox has one popover at a time. + ALPopover* showPopover(PopoverKind kind, LLView* anchor, LLPanel* content); + /// Make @a popover the one that is up, and open it under @a anchor; a + /// null anchor says it is open already. + void adoptPopover(PopoverKind kind, ALPopover* popover, LLView* anchor); + /// Close the popover that is up, keeping what it chose or, escaping, + /// keeping nothing. + void closePopover(bool escape); + /// Told once per popover, as it closes. + void onPopoverClosed(const LLView* which, bool escaped); + + /// Find a setting or section by name (Ctrl+F): every section and every + /// setting, ranked against what is typed, and Return goes to it. The + /// studio base's Open Quickly, hung from the top bar. + void openFind(); + /// Show a section, or a setting and the section it is in: its tab chosen, + /// its section opened and scrolled to, and the setting given the keyboard + /// and its caption lit for a moment. @a target is "s:
" or + /// "k:", as findCandidates makes them. + void jumpTo(const std::string& target); + /// A tab page's label ("Look"), by index. + std::string pageLabel(size_t page) const; + + /// The undo stack as a list: every step by name, the present marked, and + /// a double-click on any step goes back or forward to it. + void openHistory(); + /// Put the stack into the open history list. Also run from draw() when the + /// stack has moved under it. + void fillHistoryList(); + /// Undo or redo, one step at a time, until @a cursor steps are in force. + void goToHistory(size_t cursor); + + /// A colour row's swatch asking to open its picker: open the inline one + /// under it instead. False lets the swatch open the picker floater. + bool openColorPopover(LLColorSwatchCtrl* swatch, const std::string& key); + /// The inline picker moved: write the colour live, unrecorded. + void onColorPicked(const LLColor4& color); + /// The inline picker closed: put the colour back if it was escaped, or + /// record the whole pick as one undo step if it changed anything. + void endColorSession(bool escaped); + + /// Size every section to what is in it: its lowest row's bottom plus a + /// margin, and its accordion tab to that plus the header. The heights the + /// XUI declares then only matter for the first layout and for XUI + /// Studio's preview, and a row added or moved can no longer leave a + /// section clipped or padded because nobody redid the sum. + void fitSections(); + + // --- Tabs in windows of their own --- + // + // Each page is a region of the studio base's folds, bound at the end of + // postBuild after the directory has found everything on it: taking a + // page out moves its widgets into another window, where no search from + // this floater would find them, and the directory's pointers are what + // keep working. The base saves which are out and where, and puts them + // back before the floater closes. + + /// Take the current tab out into a window of its own, or put it back. + void togglePane(); + /// Put one page back. + void dockPane(size_t page); + /// Keep the pages' empty states, the button and the saved state in step + /// with which pages are out. Polled from draw(), because a torn-off + /// window's own close box puts its page back without telling anyone. + void refreshPaneRow(); + /// What the folds call a page: its panel's name. + const std::string& paneKey(size_t page) const; + void onClickResetControlDefault(const LLSD& userdata); void onClickResetSection(const LLSD& userdata); /// Set or clear one section's bit in LLPipeline::sGradeBypassMask, from @@ -81,24 +173,47 @@ class ALFloaterLightBox final : public LLFloater /// here. The settings are never touched, so a comparison cannot dirty /// the Look. void onToggleSection(LLUICtrl* ctrl, const LLSD& userdata); + /// Put the number of bypassed grading sections on the Look tab, or take + /// it off when there are none. A bypass is state nothing else on screen + /// shows once its section is scrolled away or another tab is up, and it + /// is easy to forget one is in force while judging the grade. + void refreshBypassBadge(); void onCommitVec3(LLUICtrl* ctrl); void refreshVec3Row(const std::string& setting_name); void setupToneCurve(); void refreshToneCurve(); + /// Interpret the graph's action (drag, add, remove) against the selected + /// curve and write that curve's setting: exactly one setting per commit. void onCommitToneCurve(); - /// Which channel the tone curve graph edits: -1 for all three, else 0..2. + /// Reset the curve the channel combo selects, and nothing else. + void onClickResetToneCurveChannel(); + /// Replace the selected curve with a named preset; parameter = preset id. + void onToneCurvePreset(const LLSD& userdata); + /// The channel combo's value: -1 for Master, else 0..2 for R, G, B. S32 getToneCurveChannel() const; void setupSplitToneGraph(); void refreshSplitToneGraph(); + /// Write balance or one of the two widths, by which handle moved. void onCommitSplitToneGraph(); /// Arm the scene picker; the click that follows sets Temperature and Tint. void onClickWhiteBalancePicker(); void onWhiteBalancePicked(const LLColor3& sample); + void populateAssetCombo(const std::string& combo_name, + const std::string& dir_name, + const std::vector& extensions, + const std::string& setting_name); void populateLUTCombo(); + void openUserAssetFolder(const std::string& dir_name); /// Open the user's LUT folder in the platform file browser, creating it /// first if this is its first use. void onClickOpenLUTFolder(); + void onLensDirtSliderHeld(bool held); void updateTonemapperRows(); + /// Show the Bloom (HDR) sections, and the Cross Filter ones that run off + /// the same bloom, or the Glow (Legacy) ones, whichever the renderer is + /// running, and hide the other side: it does nothing until HDR is + /// switched the other way, and HDR is not switched from here. + void refreshBloomSections(); /// Freeze the frame about to be presented, and switch the wipe on so the /// grab is visibly a grab. void onClickReferenceGrab(); @@ -158,6 +273,42 @@ class ALFloaterLightBox final : public LLFloater void onClickLookRevert(); void refreshLooksBar(); + /// The tab strip and its pages, in tab order: Look, Lens, Scene, Sky. + LLTabContainer* mTabs = nullptr; + std::vector mTabPages; + /// What popovers hang from. + LLPanel* mTopBar = nullptr; + LLHandle mPopover; + PopoverKind mPopoverKind = PopoverKind::None; + /// The caption a jump lit, and how long ago; draw() puts it out. + LLHandle mLitCaption; + LLFrameTimer mLitTimer; + + /// One per page, in tab order: what the page says while its contents + /// are away in a window of their own. + std::vector mPaneEmpties; + LLButton* mPopOutButton = nullptr; + /// Which pages are out and which tab is up, as last shown; -1 before the + /// first refresh. + S32 mPaneRowState = -1; + /// Which pages were out when the state was last saved. + U32 mPanesOutSaved = 0; + /// Every section and setting, found in postBuild. After postBuild nothing + /// here looks a widget up by name: a page taken out into a window of its + /// own takes its widgets with it, out of reach of any search from this + /// floater, and the pointers held here are what keep working. + ALLightboxDirectory mDirectory; + + /// One per tonemapper parameter row: the operator it belongs to, and the + /// row, which updateTonemapperRows greys -- reset button and all, since + /// the rows are setting rows. + struct TonemapperRow + { + S32 mType = 0; + LLUICtrl* mCtrl = nullptr; + }; + std::vector mTonemapperRows; + // Spinner triplets named "vec3__<0|1|2>", keyed by setting name. // Rows are discovered by walking the widget tree in postBuild; adding a // vector-valued row is pure XUI. @@ -168,13 +319,19 @@ class ALFloaterLightBox final : public LLFloater std::map> mVec3Rows; std::vector mVec3Connections; boost::signals2::scoped_connection mTonemapConnection; + boost::signals2::scoped_connection mHDRConnection; boost::signals2::scoped_connection mLooksListConnection; boost::signals2::scoped_connection mLooksActiveConnection; LLComboBox* mLooksCombo = nullptr; + LLUICtrl* mLookSave = nullptr; + LLUICtrl* mLookDelete = nullptr; + LLUICtrl* mLookRevert = nullptr; bool mVec3Updating = false; - // Tone curve graph. Optional: the floater builds without it, so the XUI - // can drop the graph and keep the spinners. + // Tone curve graph. Optional: the floater builds without it. The four + // curve settings are read fresh on every refresh and commit rather than + // mirrored here -- undo, Looks and Debug Settings all write them behind + // the floater's back. std::vector mToneCurveConnections; ALCurveEditorCtrl* mToneCurve = nullptr; LLComboBox* mToneCurveChannel = nullptr; @@ -184,6 +341,10 @@ class ALFloaterLightBox final : public LLFloater // remain the full interface if the XUI drops it. std::vector mSplitToneConnections; ALCurveEditorCtrl* mSplitToneGraph = nullptr; + /// The width sliders, whose min_val/max_val are the range the edge + /// handles clamp into, so the XUI stays the single home of that range. + LLSliderCtrl* mSplitShadowWidthSlider = nullptr; + LLSliderCtrl* mSplitHighlightWidthSlider = nullptr; bool mSplitToneUpdating = false; // Undo. The history is a member, so it lives and dies with the floater: @@ -196,12 +357,30 @@ class ALFloaterLightBox final : public LLFloater /// signals a user edit does, and recording them would append the undo to /// the stack it came from. bool mApplyingHistory = false; + /// The settings undo watches, for deciding whether a pick is a step. + std::set mRecordedKeys; + + // The inline colour picker's session: the setting, what it held when the + // picker opened, and a flag held while the picker writes it. Its writes + // are not recorded one by one -- a pick that took longer than the + // coalescing window would become several steps -- and the pick is + // recorded once, whole, when the picker closes. No undo group is held + // open for the length of it: a popover can die without saying it closed, + // and a group left open would take every later write into itself. + std::string mColorKey; + LLSD mColorOriginal; + bool mColorWriting = false; // Undo and Redo in the top bar, polled by draw() like the row below: // the stack moves on every commit and every Look apply, and hanging a // refresh off each of those is more places to forget. LLUICtrl* mUndoButton = nullptr; LLUICtrl* mRedoButton = nullptr; + LLUICtrl* mHistoryButton = nullptr; + /// The list in the history popover while it is up, and the stack revision + /// it shows. + LLHandle mHistoryList; + U32 mHistoryListRevision = 0; /// Bit 0 can-undo, bit 1 can-redo; -1 until the first refresh. S32 mHistoryButtonState = -1; diff --git a/indra/newview/alfloaterxuistudio.cpp b/indra/newview/alfloaterxuistudio.cpp index 3a6a1f10934..2ca8ee7c79b 100644 --- a/indra/newview/alfloaterxuistudio.cpp +++ b/indra/newview/alfloaterxuistudio.cpp @@ -50,13 +50,10 @@ #include "llexternaleditor.h" #include "llfile.h" #include "llfiltereditor.h" +#include "alviewcapture.h" #include "llfloaterreg.h" +#include "llviewertexturelist.h" #include "llfocusmgr.h" -#include "llimagebmp.h" -#include "llimagej2c.h" -#include "llimagejpeg.h" -#include "llimagepng.h" -#include "llimagetga.h" #include "llfolderview.h" #include "llkeyboard.h" #include "lllayoutstack.h" @@ -200,8 +197,6 @@ namespace LLUIColor guide; // what the numbers mean, drawn to the edges LLUIColor guideSibling; // and to the element before it LLUIColor grid; - LLUIColor rulerGround; - LLUIColor rulerInk; Ink() { @@ -219,8 +214,6 @@ namespace guide = table.getColor("XUIStudioGuide", LLColor4::yellow); guideSibling = table.getColor("XUIStudioGuideSibling", LLColor4::cyan); grid = table.getColor("XUIStudioGrid", LLColor4(0.3f, 0.82f, 1.f, 0.12f)); - rulerGround = table.getColor("XUIStudioRulerGround", LLColor4(0.169f, 0.169f, 0.169f, 0.85f)); - rulerInk = table.getColor("XUIStudioRulerInk", LLColor4::white); } }; @@ -230,12 +223,11 @@ namespace return held; } - // How much bigger than life a preview may be drawn, and how far one - // step of the wheel or one press of the spinner's arrow moves it. The - // spinner in the canvas bar says the same three numbers. - constexpr S32 ZOOM_LEAST = 20; - constexpr S32 ZOOM_MOST = 400; - constexpr S32 ZOOM_STEP = 10; + // How much bigger than life a preview may be drawn, as the canvas + // bounds it; the spinner in the canvas bar says the same two numbers. + constexpr S32 ZOOM_LEAST = (S32)(ALCanvasView::MIN_ZOOM * 100.f); + constexpr S32 ZOOM_MOST = (S32)(ALCanvasView::MAX_ZOOM * 100.f); + // The sections the attribute grid is divided into, in the order an // author reads them: what the thing is, where it is, what it looks @@ -473,13 +465,33 @@ class ALXUICanvas final : public ALCanvasView // Rules are the canvas's own furniture: they stay their own size at // the edges of what can be seen, and it is the numbers on them that - // follow the preview. + // follow the preview. They are the canvas's; what they show, whether + // they show and what they step by is the tool's. void drawChrome() override { - if (mTool && root() && mTool->showRulers()) + drawRulers(); + } + + bool rulersShown() const override + { + return mTool && root() && mTool->showRulers(); + } + + S32 rulerStep() const override + { + return mTool ? llmax(mTool->gridSize(), 2) : 2; + } + + // The selection's edges, on both rules. + bool rulerHighlight(LLRect& content) const override + { + LLView* selected = mTool ? ALXUISelection::resolve(root(), mTool->selection().selection()) : nullptr; + if (!selected) { - drawRulers(); + return false; } + content = localRectOf(selected); + return true; } // Everything that lives in the preview's own coordinates: the preview, @@ -601,11 +613,13 @@ class ALXUICanvas final : public ALCanvasView // selected. bool handleMouseDown(S32 x, S32 y, MASK mask) override { + const S32 drawn_x = x; + const S32 drawn_y = y; toContent(x, y); mLetGo = false; - if (!mTool || !root()) + if (!mTool || !root() || panGesture()) { - return LLPanel::handleMouseDown(x, y, mask); + return ALCanvasView::handleMouseDown(drawn_x, drawn_y, mask); } // A handle answers before the widget under it does, with or @@ -671,11 +685,17 @@ class ALXUICanvas final : public ALCanvasView setFocus(true); return true; } - return LLPanel::handleMouseDown(x, y, mask); + return ALCanvasView::handleMouseDown(drawn_x, drawn_y, mask); } bool handleHover(S32 x, S32 y, MASK mask) override { + const S32 drawn_x = x; + const S32 drawn_y = y; + if (panning()) + { + return ALCanvasView::handleHover(drawn_x, drawn_y, mask); + } toContent(x, y); if (grabbed()) { @@ -698,31 +718,15 @@ class ALXUICanvas final : public ALCanvasView } } } - return LLPanel::handleHover(x, y, mask); + return ALCanvasView::handleHover(drawn_x, drawn_y, mask); } // A panel that names a file of its own is one element here and a // document elsewhere; opening it is what a double click on it means. - // Control and the wheel zooms, which is what it does in every other - // canvas anybody has used. Without control the wheel belongs to whatever - // is under it -- a list in the preview scrolls -- and to the container - // when nothing wants it. - bool handleScrollWheel(S32 x, S32 y, LLScrollDelta delta) override - { - // A wheel event carries no modifiers of its own, so the keyboard is - // asked what is held. - const MASK held = gKeyboard ? gKeyboard->currentMask(false) : MASK_NONE; - if ((held & MASK_CONTROL) && mTool && delta.mClicks != 0) - { - mTool->zoomBy(delta.mClicks > 0 ? -1 : 1); - return true; - } - toContent(x, y); - return LLPanel::handleScrollWheel(x, y, delta); - } - bool handleDoubleClick(S32 x, S32 y, MASK mask) override { + const S32 drawn_x = x; + const S32 drawn_y = y; toContent(x, y); if (mTool && root() && (mask & MASK_CONTROL)) { @@ -735,11 +739,17 @@ class ALXUICanvas final : public ALCanvasView } return true; } - return LLPanel::handleDoubleClick(x, y, mask); + return ALCanvasView::handleDoubleClick(drawn_x, drawn_y, mask); } bool handleMouseUp(S32 x, S32 y, MASK mask) override { + const S32 drawn_x = x; + const S32 drawn_y = y; + if (panning()) + { + return ALCanvasView::handleMouseUp(drawn_x, drawn_y, mask); + } toContent(x, y); if (grabbed()) { @@ -778,40 +788,7 @@ class ALXUICanvas final : public ALCanvasView } return true; } - return LLPanel::handleMouseUp(x, y, mask); - } - - // The rest of what a pointer does, converted at the same door: a right - // click, a middle click and a tool tip all have to find the element - // under the pointer, and where that is depends on the zoom. - bool handleRightMouseDown(S32 x, S32 y, MASK mask) override - { - toContent(x, y); - return LLPanel::handleRightMouseDown(x, y, mask); - } - - bool handleRightMouseUp(S32 x, S32 y, MASK mask) override - { - toContent(x, y); - return LLPanel::handleRightMouseUp(x, y, mask); - } - - bool handleMiddleMouseDown(S32 x, S32 y, MASK mask) override - { - toContent(x, y); - return LLPanel::handleMiddleMouseDown(x, y, mask); - } - - bool handleMiddleMouseUp(S32 x, S32 y, MASK mask) override - { - toContent(x, y); - return LLPanel::handleMiddleMouseUp(x, y, mask); - } - - bool handleToolTip(S32 x, S32 y, MASK mask) override - { - toContent(x, y); - return LLPanel::handleToolTip(x, y, mask); + return ALCanvasView::handleMouseUp(drawn_x, drawn_y, mask); } // Whoever took the pointer is why this is being called, so by now it is @@ -1242,90 +1219,6 @@ class ALXUICanvas final : public ALCanvasView } } - // Two strips along the top and the left edges of what can be seen, - // numbered from the previewed root's top left corner, which is where the - // file counts from: the rules belong to the canvas and stay put, and - // moving a preview renumbers them rather than carrying them off. Ticks - // are the grid, whatever the grid is; the numbers are every fifty, which - // is round whether or not fifty is a multiple of the grid. The - // selection's edges are marked on both. - void drawRulers() - { - static constexpr S32 RULER = 14; - static constexpr S32 LABEL_EVERY = 50; - const LLRect view = viewportRect(); - if (view.getWidth() <= RULER * 2 || view.getHeight() <= RULER * 2) - { - return; - } - const LLRect origin = (root() && root() != this) ? localRectOf(root()) : getLocalRect(); - - const LLColor4 ground = ink().rulerGround.get(); - const LLColor4 marks = ink().rulerInk.get(); - - const S32 rule_bottom = view.mTop - RULER; - const S32 rule_right = view.mLeft + RULER; - const LLRect top(view.mLeft, view.mTop, view.mRight, rule_bottom); - const LLRect left(view.mLeft, rule_bottom, rule_right, view.mBottom); - gl_rect_2d(top, ground, true); - gl_rect_2d(left, ground, true); - gl_rect_2d(top, marks, false); - gl_rect_2d(left, marks, false); - - // The first mark at or after a coordinate. Rounding towards zero is - // not rounding down, and a rule that reaches left of the preview has - // negative numbers on it. - const auto from = [](S32 value, S32 step) - { - const S32 n = value >= 0 ? (value + step - 1) / step : -((-value) / step); - return n * step; - }; - - const S32 grid = llmax(mTool->gridSize(), 2); - const LLFontGL* font = LLFontGL::getFontSansSerifSmall(); - - // The rule is drawn on the canvas and numbered in the file: a mark - // for content coordinate n goes at n times the zoom. - const auto onCanvas = [this](S32 content) { return ll_round((F32)content * mZoom); }; - - for (S32 fx = from(ll_round((F32)rule_right / mZoom) - origin.mLeft, grid); - onCanvas(origin.mLeft + fx) <= view.mRight; fx += grid) - { - const S32 x = onCanvas(origin.mLeft + fx); - const bool named = fx % LABEL_EVERY == 0; - gl_line_2d(x, rule_bottom, x, rule_bottom + (named ? 5 : 3), marks); - if (named) - { - font->renderUTF8(std::to_string(fx), 0, x + 2, rule_bottom + 3, - marks, LLFontGL::LEFT, LLFontGL::BOTTOM); - } - } - for (S32 fy = from(origin.mTop - ll_round((F32)rule_bottom / mZoom), grid); - onCanvas(origin.mTop - fy) >= view.mBottom; fy += grid) - { - const S32 y = onCanvas(origin.mTop - fy); - const bool named = fy % LABEL_EVERY == 0; - gl_line_2d(view.mLeft, y, view.mLeft + (named ? 5 : 3), y, marks); - if (named) - { - font->renderUTF8(std::to_string(fy), 0, view.mLeft + 2, y - 10, - marks, LLFontGL::LEFT, LLFontGL::BOTTOM); - } - } - - // Where the selection sits, on both rules. - if (LLView* selected = ALXUISelection::resolve(root(), mTool->selection().selection())) - { - const LLRect content = localRectOf(selected); - const LLRect box(onCanvas(content.mLeft), onCanvas(content.mTop), - onCanvas(content.mRight), onCanvas(content.mBottom)); - gl_rect_2d(LLRect(llmax(box.mLeft, rule_right), view.mTop, llmin(box.mRight, view.mRight), rule_bottom), - ink().selection.get(), false); - gl_rect_2d(LLRect(view.mLeft, llmin(box.mTop, rule_bottom), rule_right, llmax(box.mBottom, view.mBottom)), - ink().selection.get(), false); - } - } - void drawBox(const LLView* view, const LLColor4& color, bool label) { const LLRect r = localRectOf(view); @@ -1430,6 +1323,18 @@ class ALXUIPreviewHost final : public LLFloater mCanvas = new ALXUICanvas(tool, which, cp); mCanvas->initFromParams(cp); mCanvas->setSizable(true); + // By handle: a host that has been detached -- the gallery, a + // secondary preview -- is a window of its own and can outlive the + // studio. Let go of on detach, so a detached canvas's wheel zooms + // itself and not the studio. + mZoomConnection = mCanvas->onZoomChange( + [held = tool->getDerivedHandle(), canvas = mCanvas](F32 zoom) + { + if (ALFloaterXUIStudio* studio = held.get()) + { + studio->zoomChanged(zoom, canvas); + } + }); addChild(mCanvas); } @@ -1446,6 +1351,7 @@ class ALXUIPreviewHost final : public LLFloater void detach() { mTool = nullptr; + mZoomConnection.disconnect(); mCanvas->detach(); } @@ -1466,29 +1372,11 @@ class ALXUIPreviewHost final : public LLFloater ALFloaterXUIStudio* mTool; ALXUICanvas* mCanvas = nullptr; S32 mWhich; + boost::signals2::scoped_connection mZoomConnection; }; namespace { - // The four regions that fold away, and the button in the window bar that - // folds each. The menu has the same four switches; both read the same - // state, so neither is the one that is right. - constexpr std::pair FOLD_BUTTONS[] = { - { "fold_navigator", "navigator_panel" }, - { "fold_bottom", "bottom_panel" }, - { "fold_inspectors", "inspector_panel" }, - }; - - // The regions that can be taken out into a window of their own, and the - // string each window is titled from. The canvas is not among them: it - // holds a built view tree with hit-testing of its own, and one mechanism - // must not serve both. - constexpr std::pair POP_PANES[] = { - { "navigator_panel", "PaneNavigator" }, - { "bottom_panel", "PaneBottom" }, - { "inspector_panel", "PaneInspectors" }, - }; - constexpr S32 MAX_FIND_ROWS = 500; // The follows flags as a file writes them and back, through the one @@ -1681,6 +1569,11 @@ namespace // Where the specimen sits in its row, past the label. constexpr S32 SPECIMEN_LEFT = 130; constexpr S32 SPECIMEN_WIDTH = 150; + constexpr S32 SPECIMEN_HEIGHT = 22; + // The Library's cells, when it is cells: room for a specimen as it + // is built, its name under it, and the cell's own margins. + constexpr S32 PALETTE_CELL_WIDTH = SPECIMEN_WIDTH + 12; + constexpr S32 PALETTE_CELL_HEIGHT = SPECIMEN_HEIGHT + 14 + 12; // Which heading a tag sits under in the Library. Five kinds, read off the // name: a guess over a vocabulary, and a wrong one costs a reader one @@ -1696,9 +1589,14 @@ namespace // =========================================================================== // ALFloaterXUIStudio // =========================================================================== +// How close together two edits to the same field have to be to be put back +// as one: a number stepped with an arrow, an element nudged along. +static constexpr F64 COALESCE_SECONDS = 1.0; + ALFloaterXUIStudio::ALFloaterXUIStudio(const LLSD& key) -: LLFloater(key) +: ALStudioFloater(key, "ALXUIStudioState") { + mDocuments.setCoalesceWindow(COALESCE_SECONDS); mCommitCallbackRegistrar.add("XUIStudio.Tree", boost::bind(&ALFloaterXUIStudio::onTreeAction, this, _2)); mEnableCallbackRegistrar.add("XUIStudio.TreeEnabled", boost::bind(&ALFloaterXUIStudio::onTreeActionEnabled, this, _2)); mCommitCallbackRegistrar.add("XUIStudio.List", boost::bind(&ALFloaterXUIStudio::onListAction, this, _2)); @@ -1804,6 +1702,18 @@ bool ALFloaterXUIStudio::postBuild() mAttributeGrid->setEdgeTips({ getString("FollowsTipLeft"), getString("FollowsTipBottom"), getString("FollowsTipRight"), getString("FollowsTipTop"), getString("FollowsTipAcross"), getString("FollowsTipDown") }); + // A picture row offers every picture the skin names. + mAttributeGrid->setImageChoices([textures = getString("ImageChoicesTextures")]() + { + std::vector choices; + for (const auto& [name, image] : LLUIImageList::instance().getUIImages()) + { + choices.push_back({ name, textures }); + } + std::sort(choices.begin(), choices.end(), + [](const ALImageField::Choice& a, const ALImageField::Choice& b) { return a.name < b.name; }); + return choices; + }); } mAttributeWhat = getChild("attributes_what"); mAttributeFilter = getChild("attributes_filter"); @@ -1823,7 +1733,7 @@ bool ALFloaterXUIStudio::postBuild() mBindings = getChild("bindings"); mState = getChild("state"); mSelectionFindings = getChild("selection_findings"); - mMenuBar = getChild("studio_menu"); + setMenuBar(getChild("studio_menu")); // Two strips, split by what each mode needs rather than by what it is // about. The navigator holds the ones that are a list of names and read // well narrow; the band under the canvas holds the tables, which are @@ -1877,6 +1787,12 @@ bool ALFloaterXUIStudio::postBuild() mChannels = getChild("channels"); mChannelResponse = getChild("channel_response"); mPalette = getChild("palette"); + getChild("palette_cells")->setCommitCallback([this](LLUICtrl* ctrl, const LLSD&) + { + mPaletteCells = ctrl->getValue().asBoolean(); + mPalette->setCellSize(mPaletteCells ? PALETTE_CELL_WIDTH : 0, mPaletteCells ? PALETTE_CELL_HEIGHT : 0); + saveState(); + }); mPaletteAttributes = getChild("palette_attributes"); // The tag chosen above decides what is listed below it. mPalette->onChose([this](const std::string&) { fillPaletteAttributes(); }); @@ -1886,11 +1802,16 @@ bool ALFloaterXUIStudio::postBuild() { LLFloaterReg::showInstance("xui_library"); }); - for (const auto& [button, pane] : FOLD_BUTTONS) - { - getChild(button)->setCommitCallback( - [this, pane = pane](LLUICtrl*, const LLSD&) { togglePane(pane); }); - } + // Each region put into a pane that knows it can be somewhere else, with + // the button that folds it. The canvas is not among them: it holds a + // built view tree with hit-testing of its own, and one mechanism must + // not serve both. Nothing in this window asks for anything inside a + // region by name after this point, which is the whole of why a region + // may leave. + mFolds.bind(this, { { "navigator", "navigator_panel", "fold_navigator", getString("PaneNavigator") }, + { "inspectors", "inspector_panel", "fold_inspectors", getString("PaneInspectors") }, + { "bottom", "bottom_panel", "fold_bottom", getString("PaneBottom") } }); + mFolds.onChanged([this]() { saveState(); }); getChild("tree_up")->setClickedCallback(boost::bind(&ALFloaterXUIStudio::onTreeMove, this, "move_up")); getChild("tree_down")->setClickedCallback(boost::bind(&ALFloaterXUIStudio::onTreeMove, this, "move_down")); getChild("tree_in")->setClickedCallback(boost::bind(&ALFloaterXUIStudio::onTreeMove, this, "move_in")); @@ -1900,7 +1821,7 @@ bool ALFloaterXUIStudio::postBuild() mTranslateValue = getChild("translate_value"); mTranslateCounts = getChild("translate_counts"); mEditTarget = getChild("edit_target"); - mStatus = getChild("status"); + setStatusLine(getChild("status")); // The canvases are built here rather than declared in the XUI because // they are the tool's own view of a preview, not a widget a file can @@ -1929,6 +1850,8 @@ bool ALFloaterXUIStudio::postBuild() mCanvases[i] = new ALXUICanvas(this, i, cp); mCanvases[i]->initFromParams(cp); mCanvases[i]->setSizable(true); + mCanvases[i]->setBackdrop(mBackdrop); + mCanvases[i]->onZoomChange([this, i](F32 zoom) { zoomChanged(zoom, mCanvases[i]); }); // A canvas with nothing on it takes no room on the row. mCanvases[i]->setVisible(false); mCanvasRow->addCanvas(mCanvases[i]); @@ -1943,23 +1866,6 @@ bool ALFloaterXUIStudio::postBuild() mCanvasShown->setCommitCallback(boost::bind(&ALFloaterXUIStudio::onToggleCanvasShown, this)); mCanvasPinned->setCommitCallback(boost::bind(&ALFloaterXUIStudio::onToggleCanvasPinned, this)); - // Each region put into a pane that knows it can be somewhere else. Done - // here rather than in the file so the file keeps naming what it lays - // out; the tree gains a level and every name in it is where it was. - // Nothing in this window asks for anything inside a region by name after - // this point, which is the whole of why a region may leave. - for (const auto& [region, title] : POP_PANES) - { - if (LLLayoutPanel* panel = findChild(region, true)) - { - if (ALDockPanel* pane = ALDockPanel::wrap(panel, getString(title))) - { - mPanes.push_back(pane->getHandle()); - } - } - } - - holdShapePanes(); loadState(); scanCatalog(); @@ -2092,6 +1998,7 @@ bool ALFloaterXUIStudio::postBuild() getChild("canvas_snap")->setToggleState(mSnap); mGridCombo->setValue(mGrid); setZoom(mZoom); + setCanvasBackdrop(mBackdrop); mSelection.onSelectionChanged(boost::bind(&ALFloaterXUIStudio::onSelectionChanged, this)); mSelection.onHoverChanged(boost::bind(&ALFloaterXUIStudio::onHoverChanged, this)); @@ -2119,18 +2026,37 @@ bool ALFloaterXUIStudio::postBuild() return true; } +void ALFloaterXUIStudio::onOpen(const LLSD& key) +{ + if (!key.isMap() || !key.has("find") || !mFindBar || !mModes) + { + return; + } + mModes->selectTabByName("find_mode"); + mFindBar->setValue("query", key["find"].asString()); + mFindBar->setValue("field", "value"); + mFindBar->setValue("how", "matching"); + mFindBar->setValue("scope", ""); + onFind(); + onMode(); + + // Asked to go where it is found: the first place, with the widget + // selected, as a result pressed would. + if (key["go"].asBoolean() && mFindResults && mFindResults->getItemCount() > 0) + { + mFindResults->selectFirstItem(); + onFindResult(); + } +} + void ALFloaterXUIStudio::onClose(bool app_quitting) { - saveState(); - // Before anything else: a pane left in a window this one does not own is - // destroyed along with it. - dockPanes(); + ALStudioFloater::onClose(app_quitting); closePreviews(); } void ALFloaterXUIStudio::draw() { - rememberShape(); if (!mLintQueue.empty()) { stepLintAll(); @@ -2220,15 +2146,12 @@ void ALFloaterXUIStudio::draw() mStateTimer.reset(); refreshState(selectedView()); } - LLFloater::draw(); + ALStudioFloater::draw(); } bool ALFloaterXUIStudio::handleKeyHere(KEY key, MASK mask) { - // The menu bar's own accelerators, which belong to this floater and - // not to the viewer: they answer while it has the keyboard and are - // silent everywhere else, which is what a floater-local menu is for. - if (mMenuBar && mMenuBar->handleAcceleratorKey(key, mask)) + if (handleMenuAccelerator(key, mask) || handleUndoKeys(key, mask)) { return true; } @@ -2304,9 +2227,9 @@ bool ALFloaterXUIStudio::handleKeyHere(KEY key, MASK mask) { return false; } - if (paneCollapsed(pane)) + if (mFolds.collapsed(pane)) { - togglePane(pane); + mFolds.toggle(pane); } return true; } @@ -2805,206 +2728,72 @@ void ALFloaterXUIStudio::closePreviews() } } -// A pane the developer is not using gives its room to the canvas, and the -// canvas is the region that grows because it is the one the stack resizes. -// A collapsed pane keeps everything it holds: what goes is the room, so -// coming back costs nothing and loses no selection. -void ALFloaterXUIStudio::setPaneCollapsed(std::string_view name, bool collapsed) +// How much bigger than life the canvas draws. The spinner, the wheel and +// what was saved from last time all arrive here, so one place knows the +// limits and what moves when the number does. The canvases share one +// scrolled row, so one of them decides where it scrolls to: the primary +// zooms about the middle of what can be seen of it, and the rest only +// take the number. +void ALFloaterXUIStudio::setZoom(S32 percent) { - LLLayoutPanel* panel = findChild(name, true); - if (LLLayoutStack* stack = panel ? panel->getParentAs() : nullptr) - { - stack->collapsePanel(panel, collapsed); - } + applyZoom(percent, nullptr); } -void ALFloaterXUIStudio::togglePane(std::string_view name) +// A canvas zoomed itself about the pointer, and the row is scrolled for +// it already: the rest take the number. +void ALFloaterXUIStudio::zoomChanged(F32 zoom, ALXUICanvas* zoomed) { - setPaneCollapsed(name, !paneCollapsed(name)); - refreshPaneButtons(); + applyZoom(ll_round(zoom * 100.f), zoomed); saveState(); } -// A region in a window of its own. The pane is moved, not copied, so -// everything the tool holds a pointer to goes on working -- and nothing in -// this window asks for anything inside a region by name after it is built, -// which is what makes that safe. -// Held rather than searched for: a search from this window would stop -// finding a region the moment it left, which is the one time anybody asks. -ALDockPanel* ALFloaterXUIStudio::paneOf(std::string_view name) const +void ALFloaterXUIStudio::applyZoom(S32 percent, ALXUICanvas* zoomed) { - const std::string want = std::string(name) + "_pane"; - for (const LLHandle& held : mPanes) - { - ALDockPanel* pane = held.get() ? held.get()->as() : nullptr; - if (pane && pane->getName() == want) - { - return pane; - } - } - return nullptr; -} - -bool ALFloaterXUIStudio::paneOut(std::string_view name) const -{ - const ALDockPanel* pane = paneOf(name); - return pane && pane->poppedOut(); -} - -void ALFloaterXUIStudio::togglePaneOut(std::string_view name) -{ - if (ALDockPanel* pane = paneOf(name)) - { - if (pane->poppedOut()) - { - pane->dock(); - } - else - { - pane->popOut(); - } - refreshPaneButtons(); - saveState(); - } -} - -// Every region that is out, put back. Called before this window closes: -// a pane left in a window the tool does not own is destroyed with it. -void ALFloaterXUIStudio::dockPanes() -{ - for (const LLHandle& held : mPanes) + mZoom = llclamp(percent, ZOOM_LEAST, ZOOM_MOST); + if (mZoomSpin && mZoomSpin->getValue().asInteger() != mZoom) { - ALDockPanel* pane = held.get() ? held.get()->as() : nullptr; - if (pane && pane->poppedOut()) - { - pane->dock(); - } + mZoomSpin->setValue(mZoom); } -} - -// The four buttons that fold the regions, pressed in while their region is -// showing. They are the same switches as the menu's, so they say the same -// thing about the same state and neither is the one that is right. -void ALFloaterXUIStudio::refreshPaneButtons() -{ - for (const auto& [button, pane] : FOLD_BUTTONS) + const F32 zoom = (F32)mZoom / 100.f; + ALXUICanvas* about = zoomed ? zoomed : mCanvases[PRIMARY]; + for (ALXUICanvas* canvas : mCanvases) { - if (LLButton* toggle = findChild(button, true)) + if (!canvas) { - toggle->setToggleState(!paneCollapsed(pane)); + continue; } - } -} - -bool ALFloaterXUIStudio::paneCollapsed(std::string_view name) const -{ - const LLLayoutPanel* panel = findChild(name, true); - return panel && panel->isCollapsed(); -} - -S32 ALFloaterXUIStudio::paneDim(std::string_view name) const -{ - const LLLayoutPanel* panel = findChild(name, true); - return panel ? panel->getTargetDim() : 0; -} - -// The three panes a bar can be dragged on, held rather than searched for: -// this is asked every frame, and a search by name walks the whole window. -void ALFloaterXUIStudio::holdShapePanes() -{ - static const char* const NAMES[3] = { "navigator_panel", "inspector_panel", "bottom_panel" }; - for (S32 i = 0; i < 3; ++i) - { - mShapePanes[i] = findChild(NAMES[i], true); - } -} - -// Asked for the way a drag on the bar asks, so the stack takes it from -// its neighbours the way it would have then. -void ALFloaterXUIStudio::setPaneDim(std::string_view name, S32 dim) -{ - LLLayoutPanel* panel = findChild(name, true); - if (panel && dim > 0) - { - panel->setTargetDim(dim); - } -} - -bool ALFloaterXUIStudio::applyRectControl() -{ - // Told to the per-account control the floater reads its rect from, so - // that the floater's own bookkeeping -- where it sits relative to the - // screen, what it writes back -- runs as it would have. The relative - // position the account kept is let go of, since it would otherwise - // outrank the rect it was derived from. - if (!mRestoredRect.isEmpty() && !mRectControl.empty()) - { - LLControlGroup* group = getControlGroup(); - group->setRect(mRectControl, mRestoredRect); - for (const std::string& name : { mPosXControl, mPosYControl }) + if (canvas == about && !zoomed) { - if (LLControlVariable* control = group->getControl(name)) - { - control->resetToDefault(); - } + const LLRect seen = canvas->viewportRect(); + canvas->zoomAbout(zoom, seen.getCenterX(), seen.getCenterY()); } - } - return LLFloater::applyRectControl(); -} - -void ALFloaterXUIStudio::rememberShape() -{ - // Not in the middle of a drag: the shape worth keeping is the one it - // ends at. And not while minimized, when the rect is a title bar. - if (gFocusMgr.getMouseCapture() || isMinimized()) - { - return; - } - if (getRect() != mShapeRect) - { - saveState(); - return; - } - for (S32 i = 0; i < 3; ++i) - { - if (mShapePanes[i] && mShapePanes[i]->getTargetDim() != mShapeDims[i]) + else { - saveState(); - return; + canvas->setZoom(zoom); } } + layoutCanvases(); } -// How much bigger than life the canvas draws. The spinner, the wheel and -// what was saved from last time all arrive here, so one place knows the -// limits and what moves when the number does. -void ALFloaterXUIStudio::setZoom(S32 percent) +// Every canvas there is, the ones in the row and any in a window of its +// own, and the ones made later take it when they are made. +void ALFloaterXUIStudio::setCanvasBackdrop(ALCanvasView::Backdrop backdrop) { - mZoom = llclamp(percent, ZOOM_LEAST, ZOOM_MOST); - if (mZoomSpin && mZoomSpin->getValue().asInteger() != mZoom) - { - mZoomSpin->setValue(mZoom); - } + mBackdrop = backdrop; for (ALXUICanvas* canvas : mCanvases) { if (canvas) { - canvas->setZoom((F32)mZoom / 100.f); + canvas->setBackdrop(backdrop); } } - layoutCanvases(); -} - -// One step of the zoom, which is the step the spinner's own arrows take: the -// wheel over the canvas and the bar cannot disagree about how far a step is. -void ALFloaterXUIStudio::zoomBy(S32 steps) -{ - if (steps == 0) + for (const Preview& pv : mPreviews) { - return; + if (ALXUICanvas* canvas = canvasOf(pv)) + { + canvas->setBackdrop(backdrop); + } } - setZoom(mZoom + steps * ZOOM_STEP); - saveState(); } void ALFloaterXUIStudio::layoutCanvases() @@ -3773,7 +3562,7 @@ void ALFloaterXUIStudio::fillPalette() { const std::string xml = "<" + child + " name=\"" + child + "\" label=\"" + child + "\" layout=\"topleft\" left=\"0\" top=\"0\" width=\"" + std::to_string(SPECIMEN_WIDTH) - + "\" height=\"22\"/>"; + + "\" height=\"" + std::to_string(SPECIMEN_HEIGHT) + "\"/>"; LLXMLNodePtr node; if (LLXMLNode::parseBuffer(xml.data(), xml.size(), node)) { @@ -5007,7 +4796,8 @@ void ALFloaterXUIStudio::saveAllDocuments() // is not any of the five things it wrote. std::string ALFloaterXUIStudio::describeAction(const ALXUIDocuments::Entry& entry) const { - if (entry.steps == 1 && entry.documents == 1) + // A run of steps to one field is that field, the same as one step to it. + if ((entry.steps == 1 || entry.sameField) && entry.documents == 1) { const std::string said = describeStep(entry.change); if (!said.empty()) @@ -5032,7 +4822,6 @@ void ALFloaterXUIStudio::fillHistory() mDocuments.settle(); const std::vector history = mDocuments.history(); - refreshUndoLabels(history); std::vector steps; for (const ALXUIDocuments::Entry& entry : history) { @@ -5046,49 +4835,10 @@ void ALFloaterXUIStudio::fillHistory() } steps.push_back(std::move(step)); } - mHistory->setSteps(std::move(steps), mDocuments.inForce()); + showHistory(mHistory, std::move(steps), mDocuments.inForce()); refreshModeCounts(); } -// "Undo" on its own is a promise about nothing in particular. The next step -// back and the next step forward are known, and each item says which it -// is -- in the words the history list uses for the same step, so that the -// menu and the list agree about what is about to happen. -void ALFloaterXUIStudio::refreshUndoLabels(const std::vector& history) -{ - if (!mMenuBar) - { - return; - } - const size_t in_force = mDocuments.inForce(); - if (LLMenuItemGL* undo = mMenuBar->findChild("undo", true)) - { - LLStringUtil::format_map_t args; - if (in_force > 0 && in_force <= history.size()) - { - args["[WHAT]"] = describeAction(history[in_force - 1]); - undo->setLabel(getString("MenuUndoWhat", args)); - } - else - { - undo->setLabel(getString("MenuUndo")); - } - } - if (LLMenuItemGL* redo = mMenuBar->findChild("redo", true)) - { - if (in_force < history.size()) - { - LLStringUtil::format_map_t args; - args["[WHAT]"] = describeAction(history[in_force]); - redo->setLabel(getString("MenuRedoWhat", args)); - } - else - { - redo->setLabel(getString("MenuRedo")); - } - } -} - // A step chosen: put the documents back to just after it, however many undos // or redos that is. The set knows what each action touched, so this only has // to say how far. @@ -5127,7 +4877,7 @@ void ALFloaterXUIStudio::onHistoryStepChosen(size_t at) const ALXUIDocuments::Entry& entry = all[at]; // Only where the step named one element of the file being looked at: // selecting in a file nobody has open would be a jump nobody asked for. - if (entry.steps != 1 || entry.documents != 1 || entry.change.path.empty()) + if ((entry.steps != 1 && !entry.sameField) || entry.documents != 1 || entry.change.path.empty()) { return; } @@ -5535,6 +5285,7 @@ void ALFloaterXUIStudio::showPreview(S32 which) p.can_resize = true; ALXUIPreviewHost* preview = new ALXUIPreviewHost(this, which, p); host = preview; + preview->canvas()->setBackdrop(mBackdrop); pv.canvas = preview->canvas()->getHandle(); root = buildRoot(which, *entry, preview->canvas(), node); if (root) @@ -6486,47 +6237,19 @@ void ALFloaterXUIStudio::capturePreview() return; } - const S32 window_width = gViewerWindow->getWindowWidthRaw(); - const S32 window_height = gViewerWindow->getWindowHeightRaw(); - LLPointer shot = new LLImageRaw; - if (!gViewerWindow->rawSnapshot(shot, window_width, window_height, /*keep_window_aspect=*/true, - /*is_texture=*/false, /*show_ui=*/true, /*show_hud=*/false)) + LLPointer capture; + switch (ALViewCapture::captureScreenRect(screen, capture)) { + case ALViewCapture::Outcome::NoSnapshot: setStatus(getString("CaptureNoSnapshot")); return; - } - - // The rect in the window, in the snapshot's own scale: a snapshot may - // come back at a different size than the window. - const F32 scale_x = (F32)shot->getWidth() / (F32)llmax(1, gViewerWindow->getWindowWidthScaled()); - const F32 scale_y = (F32)shot->getHeight() / (F32)llmax(1, gViewerWindow->getWindowHeightScaled()); - const S32 left = llclamp((S32)(screen.mLeft * scale_x), 0, shot->getWidth()); - const S32 right = llclamp((S32)(screen.mRight * scale_x), left, shot->getWidth()); - const S32 bottom = llclamp((S32)(screen.mBottom * scale_y), 0, shot->getHeight()); - const S32 top = llclamp((S32)(screen.mTop * scale_y), bottom, shot->getHeight()); - const S32 width = right - left; - const S32 height = top - bottom; - if (width <= 0 || height <= 0) - { + case ALViewCapture::Outcome::OffScreen: setStatus(getString("CaptureOffScreen")); return; + case ALViewCapture::Outcome::Captured: + break; } - // Row zero of the snapshot is the bottom of the window, which is - // where the rect's bottom is too. - const U8 components = shot->getComponents(); - LLPointer cropped = new LLImageRaw(width, height, components); - for (S32 row = 0; row < height; ++row) - { - memcpy(cropped->getData() + (size_t)row * width * components, - shot->getData() + ((size_t)(bottom + row) * shot->getWidth() + left) * components, - (size_t)width * components); - } - - mCapture = cropped; - - // The file and the format are one question: the extension the author - // types is what the image is written as. std::string name = mFile; for (char& c : name) { @@ -6535,64 +6258,21 @@ void ALFloaterXUIStudio::capturePreview() c = '_'; } } - name += "_" + mPreviews[PRIMARY].skin + "_" + mPreviews[PRIMARY].language + ".png"; - LLFilePickerReplyThread::startPicker(boost::bind(&ALFloaterXUIStudio::writeCapture, this, _1), - LLFilePicker::FFSAVE_ALL, name); -} - -void ALFloaterXUIStudio::writeCapture(const std::vector& filenames) -{ - // Whichever way this goes the picture is not wanted afterwards: a - // picker cancelled is a capture nobody wants written. - const LLPointer capture = mCapture; - mCapture = nullptr; - if (filenames.empty() || capture.isNull()) - { - return; - } - std::string path = filenames.front(); - - std::string extension = gDirUtilp->getExtension(path); - LLStringUtil::toLower(extension); - if (extension.empty()) - { - // A picture of a floater is a PNG unless the author says - // otherwise, and the file says what it is. - extension = "png"; - path += ".png"; - } - LLPointer image; - if (extension == "jpg" || extension == "jpeg") - { - image = new LLImageJPEG(gSavedSettings.getS32("SnapshotQuality")); - } - else if (extension == "bmp") - { - image = new LLImageBMP; - } - else if (extension == "tga") - { - image = new LLImageTGA; - } - else if (extension == "j2c" || extension == "jp2") - { - image = new LLImageJ2C; - } - else - { - image = new LLImagePNG; - } - - LLStringUtil::format_map_t args; - args["[PATH]"] = path; - if (!image->encode(capture, 0.f) || !image->save(path)) - { - setStatus(getString("CaptureWriteFailed", args)); - return; - } - args["[WIDTH]"] = std::to_string(capture->getWidth()); - args["[HEIGHT]"] = std::to_string(capture->getHeight()); - setStatus(getString("CaptureWritten", args)); + name += "_" + pv.skin + "_" + pv.language + ".png"; + ALViewCapture::saveThroughPicker(capture, name, + [handle = getDerivedHandle()](const std::string& path, S32 width, S32 height, bool written) + { + ALFloaterXUIStudio* self = handle.get(); + if (!self) + { + return; + } + LLStringUtil::format_map_t args; + args["[PATH]"] = path; + args["[WIDTH]"] = std::to_string(width); + args["[HEIGHT]"] = std::to_string(height); + self->setStatus(self->getString(written ? "CaptureWritten" : "CaptureWriteFailed", args)); + }); } // Every file in the catalog, checked a few per frame. What each of them said @@ -8789,6 +8469,16 @@ void ALFloaterXUIStudio::alignSelection(const std::string& how) // because nothing went there. A translation written and a repair are // saved as they are made, so undoing one leaves its file dirty against // the disk, with Save the way to write the undo out. +bool ALFloaterXUIStudio::undo() +{ + return undoEdit(); +} + +bool ALFloaterXUIStudio::redo() +{ + return redoEdit(); +} + bool ALFloaterXUIStudio::undoEdit() { // What is about to be put back, asked before it is, since afterwards the @@ -9571,21 +9261,6 @@ void ALFloaterXUIStudio::onFieldGutter(const std::string& name) // it has answered. void ALFloaterXUIStudio::openQuickly() { - if (LLView* up = mQuickPopover.get()) - { - up->die(); - } - mQuickPopover.markDead(); - - constexpr S32 WIDTH = 460; - constexpr S32 HEIGHT = 300; - - ALQuickOpen::Params qp(LLUICtrlFactory::getDefaultParams()); - qp.name = "quick_open"; - qp.rect = LLRect(0, HEIGHT, WIDTH, 0); - qp.placeholder = getString("QuickOpenPlaceholder"); - ALQuickOpen* quick = LLUICtrlFactory::create(qp); - std::vector candidates; for (const ALXUICatalog::Entry& entry : mCatalog.entries()) { @@ -9599,26 +9274,8 @@ void ALFloaterXUIStudio::openQuickly() one.value = entry.name; candidates.push_back(std::move(one)); } - quick->setCandidates(std::move(candidates)); - - // The popover takes the content, and takes it even when it cannot show. - ALPopover* popover = ALPopover::show(this, quick, getString("QuickOpenTitle")); - if (!popover) - { - return; - } - mQuickPopover = popover->getHandle(); - quick->onChose([this](const std::string& file) - { - if (LLView* up = mQuickPopover.get()) - { - up->die(); - } - mQuickPopover.markDead(); - goToFile(file); - }); - popover->onClosed([this](bool) { mQuickPopover.markDead(); }); - quick->takeFocus(); + quickOpen(std::move(candidates), getString("QuickOpenPlaceholder"), getString("QuickOpenTitle"), + [this](const std::string& file) { goToFile(file); }); } // The file, opened, with the canvas going there whether it is pinned or not: @@ -10362,10 +10019,6 @@ void ALFloaterXUIStudio::openInEditor(const std::string& path, S32 line) // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- -void ALFloaterXUIStudio::setStatus(const std::string& text) -{ - mStatus->setText(text); -} // Everything the menu bar does, by the name the item carries. The actions // are the methods the toolbar's own buttons call; the switches are the @@ -10387,13 +10040,14 @@ void ALFloaterXUIStudio::onMenuAction(const LLSD& param) else if (action == "save_repair") { saveAndRepair(); } else if (action == "impact") { reportTranslationImpact(); } else if (action == "revert") { revertDocument(); } - else if (action == "undo") { undoEdit(); } - else if (action == "redo") { redoEdit(); } + else if (action == "undo") { undo(); } + else if (action == "redo") { redo(); } else if (action == "gallery") { showGallery(); } else if (action == "library") { LLFloaterReg::showInstance("xui_library"); } else if (action == "lint_all") { startLintAll(); } else if (action == "census") { startCensus(); } else if (action == "schema") { onExportSchema(); } + else if (action == "colors") { LLFloaterReg::showInstance("settings_color"); } else if (action == "repair_roots") { onRepairRoots(); } else if (action == "repair_all") { startRepairAll(); } else if (action == "remove_orphans") { onRemoveOrphans(); } @@ -10407,12 +10061,16 @@ void ALFloaterXUIStudio::onMenuAction(const LLSD& param) showPreviews(); } } - else if (action == "pane_navigator") { togglePane("navigator_panel"); } - else if (action == "pane_inspectors") { togglePane("inspector_panel"); } - else if (action == "pane_bottom") { togglePane("bottom_panel"); } - else if (action == "out_navigator") { togglePaneOut("navigator_panel"); } - else if (action == "out_inspectors") { togglePaneOut("inspector_panel"); } - else if (action == "out_bottom") { togglePaneOut("bottom_panel"); } + else if (action.starts_with("backdrop_")) + { + setCanvasBackdrop(ALCanvasView::backdropNamed(action.substr(9))); + } + else if (action == "pane_navigator") { mFolds.toggle("navigator"); } + else if (action == "pane_inspectors") { mFolds.toggle("inspectors"); } + else if (action == "pane_bottom") { mFolds.toggle("bottom"); } + else if (action == "out_navigator") { mFolds.toggleOut("navigator"); } + else if (action == "out_inspectors") { mFolds.toggleOut("inspectors"); } + else if (action == "out_bottom") { mFolds.toggleOut("bottom"); } else if (action == "float_preview") { // A preview changes home rather than moving, so what is on the one @@ -10456,14 +10114,15 @@ bool ALFloaterXUIStudio::onMenuCheck(const LLSD& param) if (flag == "code_built") { return mShowCodeBuilt; } if (flag == "real_floater") { return mRealFloater; } if (flag == "float_preview") { return mFloatPreview; } + if (flag.starts_with("backdrop_")) { return flag.substr(9) == ALCanvasView::backdropName(mBackdrop); } // The switch is on when the pane is showing, so the menu reads as a list // of what is on screen rather than a list of what is hidden. - if (flag == "pane_navigator") { return !paneCollapsed("navigator_panel"); } - if (flag == "pane_inspectors") { return !paneCollapsed("inspector_panel"); } - if (flag == "pane_bottom") { return !paneCollapsed("bottom_panel"); } - if (flag == "out_navigator") { return paneOut("navigator_panel"); } - if (flag == "out_inspectors") { return paneOut("inspector_panel"); } - if (flag == "out_bottom") { return paneOut("bottom_panel"); } + if (flag == "pane_navigator") { return !mFolds.collapsed("navigator"); } + if (flag == "pane_inspectors") { return !mFolds.collapsed("inspectors"); } + if (flag == "pane_bottom") { return !mFolds.collapsed("bottom"); } + if (flag == "out_navigator") { return mFolds.out("navigator"); } + if (flag == "out_inspectors") { return mFolds.out("inspectors"); } + if (flag == "out_bottom") { return mFolds.out("bottom"); } return false; } @@ -10486,9 +10145,8 @@ void ALFloaterXUIStudio::onToggleSecondary() refreshCanvasHead(); } -void ALFloaterXUIStudio::saveState() +void ALFloaterXUIStudio::writeState(LLSD& state) const { - LLSD state; state["file"] = mFile; state["skin"] = mSkin; state["language"] = mLanguage; @@ -10501,9 +10159,11 @@ void ALFloaterXUIStudio::saveState() state["code_built"] = mShowCodeBuilt; state["snap"] = mSnap; state["rulers"] = mRulers; + state["palette_cells"] = mPaletteCells; state["real_floater"] = mRealFloater; state["grid"] = mGrid; state["zoom"] = mZoom; + state["backdrop"] = ALCanvasView::backdropName(mBackdrop); if (LLPanel* current = mInspectors ? mInspectors->getCurrentPanel() : nullptr) { state["tab"] = current->getName(); @@ -10516,60 +10176,10 @@ void ALFloaterXUIStudio::saveState() { state["bottom"] = current->getName(); } - state["fold_navigator"] = paneCollapsed("navigator_panel"); - state["fold_inspectors"] = paneCollapsed("inspector_panel"); - state["fold_bottom"] = paneCollapsed("bottom_panel"); - // How wide the side panes are and how tall the band is, as the drag - // left them; a folded pane remembers the size it unfolds to. - state["dim_navigator"] = paneDim("navigator_panel"); - state["dim_inspectors"] = paneDim("inspector_panel"); - state["dim_bottom"] = paneDim("bottom_panel"); - // And the window itself, left, bottom, right, top. - { - const LLRect r = getRect(); - state["rect"] = LLSD::emptyArray(); - state["rect"].append(r.mLeft); - state["rect"].append(r.mBottom); - state["rect"].append(r.mRight); - state["rect"].append(r.mTop); - mShapeRect = r; - mShapeDims[0] = state["dim_navigator"].asInteger(); - mShapeDims[1] = state["dim_inspectors"].asInteger(); - mShapeDims[2] = state["dim_bottom"].asInteger(); - } - // Which regions are in a window of their own, and where those windows - // are: a developer who put the inspectors on the other monitor finds - // them there next time. - LLSD out; - for (const auto& [region, title] : POP_PANES) - { - if (const ALDockPanel* pane = paneOf(region)) - { - LLSD one; - one["out"] = pane->poppedOut(); - const LLRect r = pane->floatingRect(); - if (!r.isEmpty()) - { - one["rect"] = LLSD::emptyArray(); - one["rect"].append(r.mLeft); - one["rect"].append(r.mBottom); - one["rect"].append(r.mRight); - one["rect"].append(r.mTop); - } - out[region] = one; - } - } - state["panes_out"] = out; - gSavedSettings.setLLSD("ALXUIStudioState", state); } -void ALFloaterXUIStudio::loadState() +void ALFloaterXUIStudio::readState(const LLSD& state) { - const LLSD state = gSavedSettings.getLLSD("ALXUIStudioState"); - if (!state.isMap()) - { - return; - } mFile = state["file"].asString(); if (state.has("skin")) { @@ -10593,11 +10203,21 @@ void ALFloaterXUIStudio::loadState() } mSnap = state["snap"].asBoolean(); mRulers = state["rulers"].asBoolean(); + mPaletteCells = state["palette_cells"].asBoolean(); + if (mPalette) + { + getChild("palette_cells")->setToggleState(mPaletteCells); + mPalette->setCellSize(mPaletteCells ? PALETTE_CELL_WIDTH : 0, mPaletteCells ? PALETTE_CELL_HEIGHT : 0); + } mRealFloater = state["real_floater"].asBoolean(); if (state.has("grid")) { mGrid = llmax(1, state["grid"].asInteger()); } + if (state.has("backdrop")) + { + mBackdrop = ALCanvasView::backdropNamed(state["backdrop"].asString()); + } if (state.has("zoom")) { mZoom = llclamp(state["zoom"].asInteger(), ZOOM_LEAST, ZOOM_MOST); @@ -10622,42 +10242,4 @@ void ALFloaterXUIStudio::loadState() { mBottom->selectTabByName(state["bottom"].asString()); } - // The sizes before the folds: a fold keeps the size it will unfold to. - if (state.has("dim_navigator")) - { - setPaneDim("navigator_panel", state["dim_navigator"].asInteger()); - setPaneDim("inspector_panel", state["dim_inspectors"].asInteger()); - setPaneDim("bottom_panel", state["dim_bottom"].asInteger()); - } - setPaneCollapsed("navigator_panel", state["fold_navigator"].asBoolean()); - setPaneCollapsed("inspector_panel", state["fold_inspectors"].asBoolean()); - setPaneCollapsed("bottom_panel", state["fold_bottom"].asBoolean()); - if (state.has("rect") && state["rect"].size() == 4) - { - mRestoredRect = LLRect(state["rect"][0].asInteger(), state["rect"][3].asInteger(), - state["rect"][2].asInteger(), state["rect"][1].asInteger()); - } - if (state.has("panes_out")) - { - const LLSD& out = state["panes_out"]; - for (const auto& [region, title] : POP_PANES) - { - ALDockPanel* pane = paneOf(region); - if (!pane || !out.has(region)) - { - continue; - } - const LLSD& one = out[region]; - if (one.has("rect") && one["rect"].size() == 4) - { - pane->setFloatingRect(LLRect(one["rect"][0].asInteger(), one["rect"][3].asInteger(), - one["rect"][2].asInteger(), one["rect"][1].asInteger())); - } - if (one["out"].asBoolean()) - { - pane->popOut(); - } - } - } - refreshPaneButtons(); } diff --git a/indra/newview/alfloaterxuistudio.h b/indra/newview/alfloaterxuistudio.h index f6d5520c6e7..fb00a9e36aa 100644 --- a/indra/newview/alfloaterxuistudio.h +++ b/indra/newview/alfloaterxuistudio.h @@ -24,6 +24,8 @@ #pragma once +#include "alcanvasview.h" +#include "alstudiofloater.h" #include "alxuicatalog.h" #include "alxuidiagnostics.h" #include "alxuidocuments.h" @@ -85,31 +87,23 @@ class LLTextEditor; // the previewed file. The canvas, where the file is built from its layered // document in the chosen skin and language, once per variant, and drawn // over with the hover and the selection. And the inspectors. -class ALFloaterXUIStudio final : public LLFloater +class ALFloaterXUIStudio final : public ALStudioFloater { friend class LLFloaterReg; public: - AL_VIEW_TYPE(ALFloaterXUIStudio, LLFloater); + AL_VIEW_TYPE(ALFloaterXUIStudio, ALStudioFloater); static constexpr S32 PRIMARY = 0; static constexpr S32 SECONDARY = 1; static constexpr S32 PREVIEWS = 2; bool postBuild() override; + // Opened by another window with something to find: the query goes + // into the find mode and runs over every skin. + void onOpen(const LLSD& key) override; void onClose(bool app_quitting) override; void draw() override; bool handleKeyHere(KEY key, MASK mask) override; - // The window opens where and as big as it was left, out of its own - // state rather than the per-account one: this is opened from the - // login screen as often as from the world, and there is no account - // yet to have remembered anything. - bool applyRectControl() override; - - // This window has a menu bar of its own, so its shortcuts are asked - // before the viewer's. Without saying so the viewer's menu answers - // first and quietly keeps every key it also binds -- Control+0 is Zoom - // In out there, and the pane it folds here was never reached. - bool hasAccelerators() const override { return true; } // What a preview host reports: the view under the mouse, a modifier // click, a handle dragged, and its own closing. @@ -168,16 +162,25 @@ class ALFloaterXUIStudio final : public LLFloater bool nudge(KEY key, MASK mask); bool undoEdit(); bool redoEdit(); + bool undo() override; + bool redo() override; // Which edge of its parent the selected element is tied to, from the // anchor the canvas draws outside that edge. void toggleFollows(S32 edge); - // How much bigger than life the canvas draws, as a percentage, and one - // step of it: the wheel over the canvas takes the step the bar's own - // spinner takes, so the two cannot disagree about how far a step is. + // How much bigger than life the canvas draws, as a percentage: the + // spinner and the saved state arrive here, and so does the wheel, by + // way of the canvas it zoomed about the pointer; the rest follow it. void setZoom(S32 percent); - void zoomBy(S32 steps); + void zoomChanged(F32 zoom, ALXUICanvas* zoomed); + // The number onto every canvas, with the row scrolled about the one + // that decides, unless one has scrolled it already. + void applyZoom(S32 percent, ALXUICanvas* zoomed); + // What every canvas draws under the preview: nothing, or something + // that shows what a window's transparency does. + void setCanvasBackdrop(ALCanvasView::Backdrop backdrop); + ALCanvasView::Backdrop canvasBackdrop() const { return mBackdrop; } // --- the document under edit --------------------------------------------- // Edits go into a document the tool holds, and the preview is built @@ -344,19 +347,6 @@ class ALFloaterXUIStudio final : public LLFloater // The variants side by side, after any of them has been built: a canvas // is as big as what it holds, so where the next one starts is not known // until the one before it has something on it. - // A region the developer is not using, folded away and brought back with - // everything it held. - void togglePane(std::string_view name); - - // A region in a window of its own, and back. The canvas is not one of - // these: it holds a built view tree with hit-testing of its own. - ALDockPanel* paneOf(std::string_view name) const; - bool paneOut(std::string_view name) const; - void togglePaneOut(std::string_view name); - void dockPanes(); - void setPaneCollapsed(std::string_view name, bool collapsed); - bool paneCollapsed(std::string_view name) const; - void refreshPaneButtons(); void layoutCanvases(); // The pill: what is on the canvas, whether it is being shown, and // whether it is being held on this file. @@ -460,7 +450,6 @@ class ALFloaterXUIStudio final : public LLFloater // needs without the viewer. The picker names the file and its // extension chooses the format. void capturePreview(); - void writeCapture(const std::vector& filenames); // --- the translation table ----------------------------------------------- // One row per field a translator writes, with what the language has @@ -618,16 +607,10 @@ class ALFloaterXUIStudio final : public LLFloater void openInEditor(const std::string& path, S32 line); // --- state --------------------------------------------------------------- - void setStatus(const std::string& text); - void saveState(); - void loadState(); - // The shape a drag leaves behind -- the window's rect, the width of - // the two side panes, the height of the band -- noticed once the drag - // is over, rather than written on every pixel of it. - void rememberShape(); - void holdShapePanes(); - S32 paneDim(std::string_view name) const; - void setPaneDim(std::string_view name, S32 dim); + // What this window keeps besides the regions and the rect, which the + // studio base keeps. + void writeState(LLSD& state) const override; + void readState(const LLSD& state) override; // The menu bar: an action by name, and whether a switch is on. void onMenuAction(const LLSD& param); bool onMenuCheck(const LLSD& param); @@ -645,9 +628,6 @@ class ALFloaterXUIStudio final : public LLFloater // save them first, let them go, or keep the document. void closeDocumentAnswered(const std::string& path, S32 option); void letGoOf(const std::string& path); - // The two menu items say what they will take back or put on again, as - // the history says it. - void refreshUndoLabels(const std::vector& history); // Every list in the tool is a table someone will want in a message or // a bug report: shift and control extend the selection, and the right @@ -677,12 +657,15 @@ class ALFloaterXUIStudio final : public LLFloater bool mShowCodeBuilt = true; bool mSnap = false; bool mRulers = false; + // The Library as cells across and down rather than rows. + bool mPaletteCells = false; // Build the registered class rather than a shell of the file. Off, and // remembered off: what it costs is the viewer's own crashes. bool mRealFloater = false; S32 mGrid = 4; // How much bigger than life the canvas draws, as a percentage. S32 mZoom = 100; + ALCanvasView::Backdrop mBackdrop = ALCanvasView::Backdrop::None; bool mSyncingTree = false; bool mReloadPending = false; bool mReloadEntryOnly = false; // one file changed, not the tree @@ -699,13 +682,6 @@ class ALFloaterXUIStudio final : public LLFloater // this one, to the skin this one varies -- and each of those refusals is // a thing the developer came here to do. ALXUIDocuments mDocuments; - // What the state file said the window's rect was, applied when it - // opens; and what was last written, so that a frame can tell whether - // anything moved. - LLRect mRestoredRect; - LLRect mShapeRect; - S32 mShapeDims[3] = { 0, 0, 0 }; - LLLayoutPanel* mShapePanes[3] = { nullptr, nullptr, nullptr }; S32 mLastX = -1; S32 mLastY = -1; @@ -717,7 +693,6 @@ class ALFloaterXUIStudio final : public LLFloater // By handle: a pane in a window of its own goes with that window when // the application quits, and whether it or this window is closed // first is not this window's to say. - std::vector > mPanes; ALXUICanvas* mCanvases[PREVIEWS] = {}; bool mFloatPreview = false; // The pill above the canvas. Hidden, the region is given over to the @@ -730,7 +705,6 @@ class ALFloaterXUIStudio final : public LLFloater std::string mSourcePath; // what the jump button opens S32 mSourceLine = 0; - LLPointer mCapture; // taken before the picker opens LLScrollListCtrl* mMenuList = nullptr; // the list the right button was over std::string mMenuCell; // the cell it was over LLHandle mListMenu; @@ -788,7 +762,6 @@ class ALFloaterXUIStudio final : public LLFloater LLScrollListCtrl* mSourceLayerList = nullptr; // The list inside the gutter popover, which lives only while it is up. LLHandle mGutterPopover; - LLHandle mQuickPopover; LLScrollListCtrl* mGutterList = nullptr; LLTextBox* mOverrideField = nullptr; // The field the layer table is about: whichever gutter was last clicked, @@ -824,7 +797,6 @@ class ALFloaterXUIStudio final : public LLFloater LLScrollListCtrl* mBindings = nullptr; LLScrollListCtrl* mState = nullptr; LLScrollListCtrl* mSelectionFindings = nullptr; - LLMenuBarGL* mMenuBar = nullptr; LLTabContainer* mModes = nullptr; LLTabContainer* mBottom = nullptr; LLScrollListCtrl* mNotifications = nullptr; @@ -843,7 +815,6 @@ class ALFloaterXUIStudio final : public LLFloater LLTextEditor* mTranslateValue = nullptr; LLTextBox* mTranslateCounts = nullptr; LLTextBox* mEditTarget = nullptr; - LLTextBox* mStatus = nullptr; boost::unordered_map mRows; }; diff --git a/indra/newview/algradehistory.cpp b/indra/newview/algradehistory.cpp index 3f6ca3384be..c9c8d3d78e1 100644 --- a/indra/newview/algradehistory.cpp +++ b/indra/newview/algradehistory.cpp @@ -26,6 +26,8 @@ #include "algradehistory.h" +#include "llsdutil.h" + #include bool ALGradeHistory::canCoalesce(const std::string& name, F32 now) const @@ -38,18 +40,23 @@ bool ALGradeHistory::canCoalesce(const std::string& name, F32 now) const && mGroupDepth == 0 && mCursor == mStack.size() && !mStack.empty() - && mStack.back().size() == 1 + && mStack.back().mChanges.size() == 1 && mLastName == name && (now - mLastTime) <= COALESCE_SECONDS; } void ALGradeHistory::record(const std::string& name, const LLSD& before, const LLSD& after, F32 now) { - if (name.empty()) + // Not a change, so not a reason to touch anything -- least of all the + // redo tail, which a write of the value already there would otherwise + // throw away for nothing. + if (name.empty() || llsd_equals(before, after)) { return; } + ++mRevision; + // Anything recorded invalidates the redo tail: the future that was undone // is no longer reachable from here. if (mCursor < mStack.size()) @@ -68,11 +75,11 @@ void ALGradeHistory::record(const std::string& name, const LLSD& before, const L // once the index is dead. if (mGroupIndex >= mStack.size()) { - mStack.emplace_back(); + mStack.push_back(Step{ {}, mGroupLabel }); mGroupIndex = mStack.size() - 1; } - Transaction& group = mStack[mGroupIndex]; + Transaction& group = mStack[mGroupIndex].mChanges; auto existing = std::find_if(group.begin(), group.end(), [&name](const Change& c) { return c.mName == name; }); if (existing != group.end()) @@ -93,11 +100,23 @@ void ALGradeHistory::record(const std::string& name, const LLSD& before, const L { // Same control, still moving: keep the value it started from and let // the destination follow. One drag stays one step. - mStack.back().front().mAfter = after; + Change& moving = mStack.back().mChanges.front(); + moving.mAfter = after; + + // Back where it started, it is not a step any more. The drag may yet + // go on, and if it does it starts a new step from here -- which is + // this same starting value, so nothing is lost by letting go. + if (llsd_equals(moving.mBefore, moving.mAfter)) + { + mStack.pop_back(); + mCursor = mStack.size(); + mHaveLast = false; + return; + } } else { - mStack.push_back(Transaction{ { name, before, after } }); + mStack.push_back(Step{ Transaction{ { name, before, after } }, std::string() }); if (mStack.size() > MAX_DEPTH) { @@ -111,13 +130,14 @@ void ALGradeHistory::record(const std::string& name, const LLSD& before, const L mHaveLast = true; } -void ALGradeHistory::beginGroup() +void ALGradeHistory::beginGroup(const std::string& label) { if (mGroupDepth++ == 0) { // One past the end: the transaction is created by the first write, so // a group that records nothing leaves no empty step behind. mGroupIndex = mStack.size(); + mGroupLabel = label; mHaveLast = false; } } @@ -126,6 +146,23 @@ void ALGradeHistory::endGroup() { if (mGroupDepth > 0 && --mGroupDepth == 0) { + ++mRevision; + + // A control the group moved and then put back is not part of what the + // group did, and a group made of nothing else did nothing at all. + if (mGroupIndex < mStack.size()) + { + Transaction& group = mStack[mGroupIndex].mChanges; + group.erase(std::remove_if(group.begin(), group.end(), + [](const Change& c) { return llsd_equals(c.mBefore, c.mAfter); }), + group.end()); + if (group.empty()) + { + mStack.erase(mStack.begin() + mGroupIndex); + mCursor = std::min(mCursor, mStack.size()); + } + } + // The eviction that record()'s plain path does as it pushes, deferred // to here, where erasing the front can no longer shift mGroupIndex // out from under an open group. A group adds at most one transaction @@ -141,6 +178,8 @@ void ALGradeHistory::endGroup() } } + mGroupLabel.clear(); + // A fresh write after the group starts its own step rather than // coalescing into it. mHaveLast = false; @@ -154,9 +193,11 @@ const ALGradeHistory::Transaction* ALGradeHistory::undo() return nullptr; } + ++mRevision; + // Applying the result must not fold back into the step it came from. mHaveLast = false; - return &mStack[--mCursor]; + return &mStack[--mCursor].mChanges; } const ALGradeHistory::Transaction* ALGradeHistory::redo() @@ -166,15 +207,18 @@ const ALGradeHistory::Transaction* ALGradeHistory::redo() return nullptr; } + ++mRevision; mHaveLast = false; - return &mStack[mCursor++]; + return &mStack[mCursor++].mChanges; } void ALGradeHistory::clear() { + ++mRevision; mStack.clear(); mCursor = 0; mGroupDepth = 0; mGroupIndex = 0; + mGroupLabel.clear(); mHaveLast = false; } diff --git a/indra/newview/algradehistory.h b/indra/newview/algradehistory.h index 99671c1b4b6..efbf5079525 100644 --- a/indra/newview/algradehistory.h +++ b/indra/newview/algradehistory.h @@ -50,6 +50,19 @@ * `record` takes the current time rather than reading one, so the coalescing * window is exercised by the tests directly instead of by sleeping. * + * @par Nothing that changed nothing + * A step whose every control ends where it started is not kept: a drag that + * comes back to its starting value, a group whose writes cancel out, a write + * of the value already there. Each of those would be an undo step that visibly + * does nothing, which reads as Ctrl+Z being broken just as surely as a hundred + * steps for one drag does. + * + * @par Names + * A group can be given a label when it opens -- "Reset Bloom", "Look: Soft + * Film" -- and the step it makes carries it, for a history that is shown + * rather than only stepped through. A step made any other way has none, and + * the caller names it from its changes. + * * @par What it deliberately does not do * It stores values, not dirty state. Undoing restores what the settings were; * it does not try to restore whether the active Look was considered modified. @@ -78,15 +91,18 @@ class ALGradeHistory /// Record one control changing from @a before to @a after at @a now /// (seconds, monotonic; only differences matter). Recording anything - /// discards the redo tail, as every undo stack does. + /// discards the redo tail, as every undo stack does -- except a write of + /// the value already there, which is not a change and leaves the stack + /// exactly as it was. void record(const std::string& name, const LLSD& before, const LLSD& after, F32 now); /// @name Grouping /// Everything recorded between these becomes a single step, whatever the /// controls or the timing -- for a section reset, or applying a Look. - /// Nesting is counted, so a group inside a group is still one step. + /// Nesting is counted, so a group inside a group is still one step, and + /// the outermost group's @a label is the one the step carries. /// @{ - void beginGroup(); + void beginGroup(const std::string& label = std::string()); void endGroup(); /// @} @@ -105,12 +121,32 @@ class ALGradeHistory size_t depth() const { return mStack.size(); } size_t cursor() const { return mCursor; } + /// @name Reading the stack, oldest first + /// For showing the history: @a index runs 0 .. depth() - 1, and the first + /// cursor() of them are the ones in force. + /// @{ + const Transaction& at(size_t index) const { return mStack[index].mChanges; } + /// The label of the group that made the step, or empty. + const std::string& labelOf(size_t index) const { return mStack[index].mLabel; } + /// @} + + /// Changes whenever the stack or the cursor does, and at no other time, + /// so a view of the history can tell from one number whether it is + /// showing the stack as it is now. + U32 revision() const { return mRevision; } + private: + struct Step + { + Transaction mChanges; + std::string mLabel; + }; + /// True when the top transaction is a lone write to @a name that is recent /// enough to absorb another. bool canCoalesce(const std::string& name, F32 now) const; - std::vector mStack; + std::vector mStack; /// How many transactions are currently applied. Everything at or past this /// index is redoable; everything before it is undoable. @@ -119,9 +155,12 @@ class ALGradeHistory S32 mGroupDepth = 0; /// Index of the transaction the current group is accumulating into. size_t mGroupIndex = 0; + /// What the outermost open group asked its step to be called. + std::string mGroupLabel; std::string mLastName; F32 mLastTime = 0.f; bool mHaveLast = false; + U32 mRevision = 0; }; #endif // AL_GRADEHISTORY_H diff --git a/indra/newview/allightboxdirectory.cpp b/indra/newview/allightboxdirectory.cpp new file mode 100644 index 00000000000..0c2713a7d2b --- /dev/null +++ b/indra/newview/allightboxdirectory.cpp @@ -0,0 +1,472 @@ +/** + * @file allightboxdirectory.cpp + * @brief Where the Lightbox's sections and settings are, found once + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" +#include "allightboxdirectory.h" + +#include "alcurveeditorctrl.h" +#include "alsettingrow.h" +#include "llaccordionctrl.h" +#include "llaccordionctrltab.h" +#include "llcheckboxctrl.h" +#include "llpanel.h" +#include "lltextbox.h" +#include "lluictrl.h" +#include "llviewercontrol.h" + +#include +#include +#include + +namespace +{ +const std::string SECTION_PREFIX = "sec_"; + +/// A view's children in the order its XUI declares them. A view list is +/// filled from the front, so it is read backwards. +template +void forEachChildInOrder(LLView* view, Fn&& fn) +{ + const LLView::child_list_t* children = view->getChildList(); + for (auto it = children->rbegin(); it != children->rend(); ++it) + { + fn(*it); + } +} + +/// A caption as it should read on its own: no padding, and no colon, which +/// is there to lead the eye to the control beside it. +std::string tidyCaption(std::string caption) +{ + LLStringUtil::trim(caption); + while (!caption.empty() && caption.back() == ':') + { + caption.pop_back(); + LLStringUtil::trim(caption); + } + return caption; +} + +/// A setting's key as words, for a row that captions itself in none of the +/// ways a person would read: the prefix every render setting shares goes, +/// and the words come apart. "RenderReferenceWipeMode" reads "Reference wipe +/// mode"; an acronym stays whole, so "RenderCASSharpness" reads "CAS +/// sharpness". +std::string readableKey(const std::string& key) +{ + std::string rest = key; + for (const char* prefix : { "Alchemy", "Render" }) + { + const size_t len = strlen(prefix); + if (rest.size() > len && rest.compare(0, len, prefix) == 0 && isupper((unsigned char)rest[len])) + { + rest.erase(0, len); + } + } + + std::vector words; + for (size_t i = 0; i < rest.size(); ++i) + { + const bool upper = isupper((unsigned char)rest[i]) != 0; + // A word starts at a capital after a lower-case letter, or at the last + // capital of a run that a lower-case letter follows ("CASSharpness"). + const bool starts = i == 0 || + (upper && (islower((unsigned char)rest[i - 1]) || + (i + 1 < rest.size() && isupper((unsigned char)rest[i - 1]) && islower((unsigned char)rest[i + 1])))); + if (starts) + { + words.emplace_back(); + } + words.back() += rest[i]; + } + + std::string readable; + for (size_t w = 0; w < words.size(); ++w) + { + std::string word = words[w]; + const bool acronym = word.size() > 1 && + std::all_of(word.begin(), word.end(), [](char c) { return !islower((unsigned char)c); }); + if (w > 0 && !acronym) + { + LLStringUtil::toLower(word); + } + readable += (w > 0 ? " " : "") + word; + } + return readable.empty() ? key : readable; +} + +/// The control's own label: a text box it holds and names as one ("slider +/// label", "checkbox label", "wheel_label"). Its value text is never named +/// so, which is the point of going by the name. +LLTextBox* ownLabelOf(LLUICtrl* ctrl) +{ + for (LLView* child : *ctrl->getChildList()) + { + LLTextBox* box = ALViewType::as(child); + if (!box) + { + continue; + } + std::string name = box->getName(); + LLStringUtil::toLower(name); + if (name.size() >= 5 && name.compare(name.size() - 5, 5, "label") == 0 && + !tidyCaption(box->getText()).empty()) + { + return box; + } + } + return nullptr; +} + +/// The text to the control's left on the same line, which is how a row +/// captions a dropdown, a colour or a vector: the one nearest the control +/// whose height takes in the control's middle. +LLTextBox* labelToLeftOf(LLUICtrl* ctrl) +{ + LLView* parent = ctrl->getParent(); + if (!parent) + { + return nullptr; + } + + // A little slack on the right edge: a caption sized to the column can + // touch the control it names. + constexpr S32 TOUCHING = 4; + const LLRect& rect = ctrl->getRect(); + const S32 middle = (rect.mTop + rect.mBottom) / 2; + + LLTextBox* nearest = nullptr; + for (LLView* sibling : *parent->getChildList()) + { + LLTextBox* box = ALViewType::as(sibling); + if (!box || box == ctrl) + { + continue; + } + const LLRect& text = box->getRect(); + if (text.mBottom <= middle && middle <= text.mTop && text.mRight <= rect.mLeft + TOUCHING && + !tidyCaption(box->getText()).empty() && + (!nearest || text.mRight > nearest->getRect().mRight)) + { + nearest = box; + } + } + return nearest; +} +} // namespace + +// static +bool ALLightboxDirectory::parseVec3Name(const std::string& name, std::string& setting, S32& component) +{ + static const std::string prefix = "vec3_"; + if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) + { + return false; + } + size_t sep = name.rfind('_'); + if (sep <= prefix.size() || sep + 2 != name.size()) + { + return false; + } + S32 comp = name[sep + 1] - '0'; + if (comp < 0 || comp > 2) + { + return false; + } + setting = name.substr(prefix.size(), sep - prefix.size()); + component = comp; + return true; +} + +// Descending into composites is safe because their internal children are +// named for their role ("Slider", "value") and cannot parse as +// vec3__. +// static +void ALLightboxDirectory::collectVec3Controls(LLView* view, std::map>& rows) +{ + for (LLView* child : *view->getChildList()) + { + if (LLUICtrl* ctrl = ALViewType::as(child)) + { + std::string setting; + S32 component = 0; + if (parseVec3Name(ctrl->getName(), setting, component)) + { + rows[setting][component] = ctrl; + } + } + collectVec3Controls(child, rows); + } +} + +// static +void ALLightboxDirectory::collectBoundKeys(LLView* view, std::set& keys) +{ + for (LLView* child : *view->getChildList()) + { + if (LLUICtrl* ctrl = ALViewType::as(child)) + { + if (LLControlVariable* control = ctrl->getControlVariable()) + { + // Only controls owned by gSavedSettings; enabled/visibility + // bindings live in separate slots and are not touched here. + if (gSavedSettings.getControl(control->getName()) == control) + { + keys.insert(control->getName()); + } + } + std::string setting; + S32 component = 0; + if (parseVec3Name(ctrl->getName(), setting, component)) + { + keys.insert(setting); + } + // A graph that edits settings not bound through control_name (an + // LLSD point list cannot be) names them in XUI; enrol those too. + if (ALCurveEditorCtrl* graph = ALViewType::as(ctrl)) + { + for (const std::string& name : graph->getEditedSettingNames()) + { + keys.insert(name); + } + } + } + collectBoundKeys(child, keys); + } +} + +void ALLightboxDirectory::build(const std::vector& pages) +{ + mSections.clear(); + mSettings.clear(); + mSectionIndex.clear(); + mSettingIndex.clear(); + + // Sections do not nest, so a tab ends the search down its branch. + std::function find_sections = [&](LLView* view, size_t page) + { + forEachChildInOrder(view, [&](LLView* child) + { + if (LLAccordionCtrlTab* tab = ALViewType::as(child)) + { + addSection(tab, page); + } + else + { + find_sections(child, page); + } + }); + }; + for (size_t page = 0; page < pages.size(); ++page) + { + if (pages[page]) + { + find_sections(pages[page], page); + } + } +} + +void ALLightboxDirectory::addSection(LLAccordionCtrlTab* tab, size_t page) +{ + // The section panel by name rather than getAccordionView(): the tab only + // learns its container from a *visible* child, so a section that starts + // collapsed can report none. + LLPanel* panel = nullptr; + forEachChildInOrder(tab, [&](LLView* child) + { + LLPanel* candidate = ALViewType::as(child); + if (!panel && candidate && candidate->getName().compare(0, SECTION_PREFIX.size(), SECTION_PREFIX) == 0) + { + panel = candidate; + } + }); + if (!panel || mSectionIndex.count(panel->getName())) + { + return; + } + + Section section; + section.mName = panel->getName(); + section.mTitle = tab->getTitle(); + section.mPage = page; + section.mTab = tab; + section.mPanel = panel; + for (LLView* up = tab->getParent(); up && !section.mAccordion; up = up->getParent()) + { + section.mAccordion = ALViewType::as(up); + } + + const size_t index = mSections.size(); + mSections.push_back(section); + mSectionIndex.emplace(section.mName, index); + + addSettings(panel, index); + // A switch on the header is part of the section however it is drawn -- + // Color Grading's master is one -- and it has no caption but the title. + if (LLCheckBoxCtrl* header = tab->getHeaderCheckBox()) + { + addSettings(header, index); + } + // Last, so a row that shows a graph's setting as a number is the one that + // names it and the one a jump goes to: the split-tone graph's three are + // all rows as well. The tone curve's four have no row but the graph. + addGraphSettings(panel, index); +} + +void ALLightboxDirectory::addGraphSettings(LLView* view, size_t section) +{ + forEachChildInOrder(view, [&](LLView* child) + { + if (ALCurveEditorCtrl* graph = ALViewType::as(child)) + { + for (const std::string& key : graph->getEditedSettingNames()) + { + if (gSavedSettings.getControl(key).notNull()) + { + // A graph has no caption for any one of the settings it + // draws, and the text beside it is not about them. + addSetting(key, graph, section, false, readableKey(key)); + } + } + } + addGraphSettings(child, section); + }); +} + +void ALLightboxDirectory::addSettings(LLView* view, size_t section) +{ + // Parent first, so a composite that binds itself as well as its parts -- + // a slider and the bar inside it -- is found as the whole. + forEachChildInOrder(view, [&](LLView* child) + { + if (LLUICtrl* ctrl = ALViewType::as(child)) + { + if (LLControlVariable* control = ctrl->getControlVariable()) + { + if (gSavedSettings.getControl(control->getName()) == control) + { + // A checkbox hands control_name down to its button; the + // checkbox is the thing with the caption and the focus. + LLUICtrl* whole = ctrl; + if (LLCheckBoxCtrl* check = ALViewType::as(ctrl->getParent())) + { + whole = check; + } + addSetting(control->getName(), whole, section, false); + } + } + std::string setting; + S32 component = 0; + if (parseVec3Name(ctrl->getName(), setting, component)) + { + addSetting(setting, ctrl, section, true); + } + } + addSettings(child, section); + }); +} + +void ALLightboxDirectory::addSetting(const std::string& key, LLUICtrl* ctrl, size_t section, bool vector_row, + const std::string& caption) +{ + if (mSettingIndex.count(key)) + { + return; + } + + Setting setting; + setting.mKey = key; + setting.mSection = section; + setting.mCtrl = ctrl; + if (!caption.empty()) + { + // Named by whoever added it: there is no label to go looking for. + setting.mCaption = caption; + mSettingIndex.emplace(key, mSettings.size()); + mSettings.push_back(setting); + return; + } + + // A vector row's spinners carry a channel letter each, which is no name + // for the setting; the row's caption is the text to their left. + LLTextBox* box = nullptr; + if (ALSettingRow* row = ALViewType::as(ctrl)) + { + // A setting row says its own name; its label is inside the slider it + // holds, a level below where a control's own label is looked for. + // (This is also the viewer's reference to ALSettingRow, which is what + // links the widget -- and its registrar -- in from llui: a static + // library leaves out an object file nothing refers to, and the tag + // would then build nothing. Keep a reference if this goes.) + box = row->getLabelBox(); + } + else if (!vector_row) + { + box = ownLabelOf(ctrl); + } + if (!box) + { + box = labelToLeftOf(ctrl); + } + if (box) + { + setting.mCaption = tidyCaption(box->getText()); + setting.mCaptionBox = box; + } + else if (ctrl == mSections[section].mTab->getHeaderCheckBox()) + { + setting.mCaption = mSections[section].mTitle; + } + else + { + setting.mCaption = readableKey(key); + } + + mSettingIndex.emplace(key, mSettings.size()); + mSettings.push_back(setting); +} + +const ALLightboxDirectory::Section* ALLightboxDirectory::section(std::string_view name) const +{ + const auto found = mSectionIndex.find(name); + return found != mSectionIndex.end() ? &mSections[found->second] : nullptr; +} + +const ALLightboxDirectory::Setting* ALLightboxDirectory::setting(std::string_view key) const +{ + const auto found = mSettingIndex.find(key); + return found != mSettingIndex.end() ? &mSettings[found->second] : nullptr; +} + +std::string ALLightboxDirectory::captionFor(const std::string& key) const +{ + const Setting* found = setting(key); + return found ? found->mCaption : readableKey(key); +} + +const ALLightboxDirectory::Section* ALLightboxDirectory::sectionOf(std::string_view key) const +{ + const Setting* found = setting(key); + return found ? &mSections[found->mSection] : nullptr; +} diff --git a/indra/newview/allightboxdirectory.h b/indra/newview/allightboxdirectory.h new file mode 100644 index 00000000000..50ec3b3516b --- /dev/null +++ b/indra/newview/allightboxdirectory.h @@ -0,0 +1,149 @@ +/** + * @file allightboxdirectory.h + * @brief Where the Lightbox's sections and settings are, found once + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#ifndef AL_LIGHTBOXDIRECTORY_H +#define AL_LIGHTBOXDIRECTORY_H + +#include "stdtypes.h" + +#include +#include +#include +#include +#include +#include +#include + +class LLAccordionCtrl; +class LLAccordionCtrlTab; +class LLPanel; +class LLTextBox; +class LLUICtrl; +class LLView; + +/** + * Every section and every bound setting on the Lightbox, found by walking the + * pages once and then held by pointer. + * + * @par Why once, and why by pointer + * A tab page can be taken out of the floater into a window of its own, and + * that *moves* its widgets: from then on nothing below the floater's root can + * find them by name. Pointers keep working wherever the widget goes. So + * everything the floater needs later is looked up here, in postBuild, before + * anything has had a chance to move -- and after postBuild nothing in the + * floater looks a widget up by name at all. + * + * @par Captions + * A setting's caption is what its row calls it, read the way a person reads + * the row: the control's own label if it has one ("Strength" on a slider, the + * text beside a checkbox), else the text to its left on the same line (a + * dropdown, a colour, a vector row), else -- for a switch on a section header + * -- the section's title, else the key made into words. Finding a setting by + * name and naming an undo step both read it from here. + */ +class ALLightboxDirectory +{ +public: + struct Section + { + /// The section panel's name: "sec_tone", or "sec_tone_adv" for the + /// Advanced sibling, which is a section of its own here. + std::string mName; + /// What its accordion header says. + std::string mTitle; + /// Which tab page it is on, in tab order. + size_t mPage = 0; + LLAccordionCtrl* mAccordion = nullptr; + LLAccordionCtrlTab* mTab = nullptr; + LLPanel* mPanel = nullptr; + }; + + struct Setting + { + /// The gSavedSettings name. + std::string mKey; + /// What its row calls it. + std::string mCaption; + /// Index into sections(). + size_t mSection = 0; + /// The control to put the keyboard in when going to the setting. + LLUICtrl* mCtrl = nullptr; + /// The text the caption was read from, which is what to highlight; + /// null when the caption came from a section title or the key. + LLTextBox* mCaptionBox = nullptr; + }; + + /// Walk @a pages, in tab order, and remember every section and setting on + /// them. Rebuilding replaces what was there. + void build(const std::vector& pages); + + /// In the order the XUI declares them, page by page. + const std::vector
& sections() const { return mSections; } + /// In the order the XUI declares them, section by section; one entry per + /// key, the first control found for it. + const std::vector& settings() const { return mSettings; } + + /// By panel name ("sec_tone"), or null. + const Section* section(std::string_view name) const; + /// By setting name, or null. + const Setting* setting(std::string_view key) const; + + /// What a setting is called: its row's caption, or, for a setting no row + /// here shows, its key made into words. + std::string captionFor(const std::string& key) const; + /// The section a setting's row is in, or null for one no row shows. + const Section* sectionOf(std::string_view key) const; + + /// The widget naming contract of the vector rows, "vec3__<0|1|2>". + /// Setting names never contain '_', so the parse is unambiguous. + static bool parseVec3Name(const std::string& name, std::string& setting, S32& component); + + /// Every widget under @a view that follows the vector naming contract, + /// by setting and component. Any LLUICtrl will do; the name is the whole + /// contract. + static void collectVec3Controls(LLView* view, std::map>& rows); + + /// The gSavedSettings keys bound anywhere under @a view, plus the settings + /// its vector rows name: what a section's Reset All resets. + static void collectBoundKeys(LLView* view, std::set& keys); + +private: + void addSection(LLAccordionCtrlTab* tab, size_t page); + void addSettings(LLView* view, size_t section); + /// The settings a graph under @a view edits without being bound to them + /// (an LLSD point list cannot be), for any that no row of the section has + /// already claimed. + void addGraphSettings(LLView* view, size_t section); + /// @a caption, where one is given, is the setting's name as it stands; + /// left empty, the name is read off the control's label or the text beside it. + void addSetting(const std::string& key, LLUICtrl* ctrl, size_t section, bool vector_row, + const std::string& caption = std::string()); + + std::vector
mSections; + std::vector mSettings; + std::map> mSectionIndex; + std::map> mSettingIndex; +}; + +#endif // AL_LIGHTBOXDIRECTORY_H diff --git a/indra/newview/alviewcapture.cpp b/indra/newview/alviewcapture.cpp new file mode 100644 index 00000000000..578dd588ba7 --- /dev/null +++ b/indra/newview/alviewcapture.cpp @@ -0,0 +1,134 @@ +/** + * @file alviewcapture.cpp + * @brief A rect of the screen as a picture, and that picture written to a file + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alviewcapture.h" + +#include "lldir.h" +#include "llimagebmp.h" +#include "llimagej2c.h" +#include "llimagejpeg.h" +#include "llimagepng.h" +#include "llimagetga.h" +#include "llviewercontrol.h" +#include "llviewermenufile.h" +#include "llviewerwindow.h" + +ALViewCapture::Outcome ALViewCapture::captureScreenRect(const LLRect& screen, LLPointer& out) +{ + const S32 window_width = gViewerWindow->getWindowWidthRaw(); + const S32 window_height = gViewerWindow->getWindowHeightRaw(); + LLPointer shot = new LLImageRaw; + + if (!gViewerWindow->rawSnapshot(shot, window_width, window_height, /*keep_window_aspect=*/true, + /*is_texture=*/false, /*show_ui=*/true, /*show_hud=*/false)) + { + return Outcome::NoSnapshot; + } + + // The rect in the window, in the snapshot's own scale: a snapshot may + // come back at a different size than the window. + const F32 scale_x = (F32)shot->getWidth() / (F32)llmax(1, gViewerWindow->getWindowWidthScaled()); + const F32 scale_y = (F32)shot->getHeight() / (F32)llmax(1, gViewerWindow->getWindowHeightScaled()); + const S32 left = llclamp((S32)(screen.mLeft * scale_x), 0, shot->getWidth()); + const S32 right = llclamp((S32)(screen.mRight * scale_x), left, shot->getWidth()); + const S32 bottom = llclamp((S32)(screen.mBottom * scale_y), 0, shot->getHeight()); + const S32 top = llclamp((S32)(screen.mTop * scale_y), bottom, shot->getHeight()); + const S32 width = right - left; + const S32 height = top - bottom; + + if (width <= 0 || height <= 0) + { + return Outcome::OffScreen; + } + + // Row zero of the snapshot is the bottom of the window, which is + // where the rect's bottom is too. + const U8 components = shot->getComponents(); + LLPointer cropped = new LLImageRaw(width, height, components); + + for (S32 row = 0; row < height; ++row) + { + memcpy(cropped->getData() + (size_t)row * width * components, + shot->getData() + ((size_t)(bottom + row) * shot->getWidth() + left) * components, + (size_t)width * components); + } + + out = cropped; + + return Outcome::Captured; +} + +// The file and the format are one question: the extension the person +// types is what the picture is written as. +void ALViewCapture::saveThroughPicker(LLPointer image, const std::string& suggested_name, said_t said) +{ + LLFilePickerReplyThread::startPicker([image, said = std::move(said)](const std::vector& filenames, + LLFilePicker::ELoadFilter, LLFilePicker::ESaveFilter) + { + if (filenames.empty() || image.isNull()) + { + return; + } + + std::string path = filenames.front(); + std::string extension = gDirUtilp->getExtension(path); + + LLStringUtil::toLower(extension); + + if (extension.empty()) + { + extension = "png"; + path += ".png"; + } + + LLPointer formatted; + + if (extension == "jpg" || extension == "jpeg") + { + formatted = new LLImageJPEG(gSavedSettings.getS32("SnapshotQuality")); + } + else if (extension == "bmp") + { + formatted = new LLImageBMP; + } + else if (extension == "tga") + { + formatted = new LLImageTGA; + } + else if (extension == "j2c" || extension == "jp2") + { + formatted = new LLImageJ2C; + } + else + { + formatted = new LLImagePNG; + } + + const bool written = formatted->encode(image, 0.f) && formatted->save(path); + + said(path, image->getWidth(), image->getHeight(), written); + }, LLFilePicker::FFSAVE_ALL, suggested_name); +} diff --git a/indra/newview/alviewcapture.h b/indra/newview/alviewcapture.h new file mode 100644 index 00000000000..ec901b368b5 --- /dev/null +++ b/indra/newview/alviewcapture.h @@ -0,0 +1,61 @@ +/** + * @file alviewcapture.h + * @brief A rect of the screen as a picture, and that picture written to a file + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llimage.h" +#include "llpointer.h" +#include "llrect.h" + +#include +#include + +// What a tool showing a picture of the UI does when asked for the picture +// as a file: the window as drawn, cropped to a rect of the screen, then +// written to whatever file the picker names, in the format the file's +// extension says. XUI Studio captures a preview this way. +namespace ALViewCapture +{ + enum class Outcome + { + Captured, + // The window would not give a snapshot. + NoSnapshot, + // The rect is off the screen. + OffScreen + }; + + // The screen rect as a picture: row zero is the bottom, as a snapshot + // has it. + Outcome captureScreenRect(const LLRect& screen, LLPointer& out); + + // What became of the picture once the picker has spoken: the file it + // went to and its size, or the file it would not go to. Nothing is + // said for a picker cancelled, since nothing happened. + typedef std::function said_t; + + // The picker, opened on a suggested name, and the picture written to + // the file it names. A name without an extension is written as a PNG. + void saveThroughPicker(LLPointer image, const std::string& suggested_name, said_t said); +} diff --git a/indra/newview/app_settings/looks/Golden%20Hour.xml b/indra/newview/app_settings/looks/Golden%20Hour.xml index bd19ada4a7a..e9b948c5c32 100644 --- a/indra/newview/app_settings/looks/Golden%20Hour.xml +++ b/indra/newview/app_settings/looks/Golden%20Hour.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -247,49 +409,95 @@ Value 1.0 - RenderColorGradeCurveShoulder + RenderColorGradeCurveAmount Comment - Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + How much of the tone curves is applied. 0 leaves the image untouched, 1 applies the curves fully. Range 0 to 1, default 1. Persist 1 Type - Color3 + F32 + Value + 1.0 + + RenderColorGradeCurveBlue + + Comment + Tone curve for the blue channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD Value - 1.0 - 1.0 - 1.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveStrength + RenderColorGradeCurveGreen Comment - Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Tone curve for the green channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveToe + RenderColorGradeCurveMaster Comment - Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Tone curve applied to every channel, after the per-channel curves. An array of [input, output] control points, both 0 to 1, sorted by input with the first at input 0 and the last at input 1, up to 16 from the graph and 64 from a file; a monotone cubic spline passes through them. [[0,0],[1,1]] is a straight line (no change). Edited in the Lightbox Tone Curve graph. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + + + + RenderColorGradeCurveRed + + Comment + Tone curve for the red channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD + Value + + + 0.0 + 0.0 + + + 1.0 + 1.0 + RenderColorGradeGain @@ -447,6 +655,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +975,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -811,27 +1280,27 @@ Value 0.15 - RenderLensFlareOcclusionRadius + RenderLensFlareOcclusionScale Comment - How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Size of the sun-occlusion probe as a multiple of the sun disc; it follows zoom and the sky's sun scale. Below 1 only the core of the disc counts, above 1 objects near the sun start to dim the flare. Range 0.25 to 2. Persist 1 Type F32 Value - 0.02 + 1.0 - RenderLensFlareOcclusionTaps + RenderLensFlareFadeTime Comment - Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Seconds for the lens flare to fade in when the sun is revealed; it fades out in 0.6 of this, and never faster whatever passes in front of the sun. Longer is calmer through foliage and fences. Range 0.1 to 1. Persist 1 Type - S32 + F32 Value - 9 + 0.35 RenderLensFlareStarburst @@ -869,7 +1338,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type @@ -984,6 +1453,17 @@ 0.44 + RenderSplitToneHighlightWidth + + Comment + How far above the split point, in luma from 0 to 1, the highlight tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderSplitToneMidtoneAmount Comment @@ -1025,6 +1505,17 @@ 0.55 + RenderSplitToneShadowWidth + + Comment + How far below the split point, in luma from 0 to 1, the shadow tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderTonemapACESWhite Comment diff --git a/indra/newview/app_settings/looks/Neutral.xml b/indra/newview/app_settings/looks/Neutral.xml index 220f7f62846..a97ff1746d9 100644 --- a/indra/newview/app_settings/looks/Neutral.xml +++ b/indra/newview/app_settings/looks/Neutral.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -247,49 +409,95 @@ Value 1.0 - RenderColorGradeCurveShoulder + RenderColorGradeCurveAmount Comment - Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + How much of the tone curves is applied. 0 leaves the image untouched, 1 applies the curves fully. Range 0 to 1, default 1. Persist 1 Type - Color3 + F32 + Value + 1.0 + + RenderColorGradeCurveBlue + + Comment + Tone curve for the blue channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD Value - 1.0 - 1.0 - 1.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveStrength + RenderColorGradeCurveGreen Comment - Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Tone curve for the green channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveToe + RenderColorGradeCurveMaster Comment - Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Tone curve applied to every channel, after the per-channel curves. An array of [input, output] control points, both 0 to 1, sorted by input with the first at input 0 and the last at input 1, up to 16 from the graph and 64 from a file; a monotone cubic spline passes through them. [[0,0],[1,1]] is a straight line (no change). Edited in the Lightbox Tone Curve graph. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + + + + RenderColorGradeCurveRed + + Comment + Tone curve for the red channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD + Value + + + 0.0 + 0.0 + + + 1.0 + 1.0 + RenderColorGradeGain @@ -447,6 +655,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +975,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -811,27 +1280,27 @@ Value 0.15 - RenderLensFlareOcclusionRadius + RenderLensFlareOcclusionScale Comment - How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Size of the sun-occlusion probe as a multiple of the sun disc; it follows zoom and the sky's sun scale. Below 1 only the core of the disc counts, above 1 objects near the sun start to dim the flare. Range 0.25 to 2. Persist 1 Type F32 Value - 0.02 + 1.0 - RenderLensFlareOcclusionTaps + RenderLensFlareFadeTime Comment - Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Seconds for the lens flare to fade in when the sun is revealed; it fades out in 0.6 of this, and never faster whatever passes in front of the sun. Longer is calmer through foliage and fences. Range 0.1 to 1. Persist 1 Type - S32 + F32 Value - 9 + 0.35 RenderLensFlareStarburst @@ -869,7 +1338,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type @@ -984,6 +1453,17 @@ 0.5 + RenderSplitToneHighlightWidth + + Comment + How far above the split point, in luma from 0 to 1, the highlight tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderSplitToneMidtoneAmount Comment @@ -1025,6 +1505,17 @@ 0.5 + RenderSplitToneShadowWidth + + Comment + How far below the split point, in luma from 0 to 1, the shadow tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderTonemapACESWhite Comment diff --git a/indra/newview/app_settings/looks/Soft%20Film.xml b/indra/newview/app_settings/looks/Soft%20Film.xml index 58df533f4fb..0bfedc5cf2d 100644 --- a/indra/newview/app_settings/looks/Soft%20Film.xml +++ b/indra/newview/app_settings/looks/Soft%20Film.xml @@ -74,7 +74,7 @@ RenderBloomScatter Comment - Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. + Bloom upsample tent-filter radius multiplier. 1.0 = adjacent-texel taps. Values >1 widen the bloom per octave; >~2.5 starts to alias because the tent skips source texels. Persist 1 Type @@ -104,6 +104,168 @@ Value 2.5 + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + RenderCASSharpness Comment @@ -228,7 +390,7 @@ RenderColorGradeBrightness Comment - Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. + Shifts the whole image lighter or darker. Range -0.5 to 0.5; small values (±0.05) are already noticeable. Persist 1 Type @@ -247,49 +409,95 @@ Value 1.06 - RenderColorGradeCurveShoulder + RenderColorGradeCurveAmount Comment - Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + How much of the tone curves is applied. 0 leaves the image untouched, 1 applies the curves fully. Range 0 to 1, default 1. Persist 1 Type - Color3 + F32 + Value + 1.0 + + RenderColorGradeCurveBlue + + Comment + Tone curve for the blue channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD Value - 1.0 - 1.0 - 1.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveStrength + RenderColorGradeCurveGreen Comment - Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Tone curve for the green channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveToe + RenderColorGradeCurveMaster Comment - Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Tone curve applied to every channel, after the per-channel curves. An array of [input, output] control points, both 0 to 1, sorted by input with the first at input 0 and the last at input 1, up to 16 from the graph and 64 from a file; a monotone cubic spline passes through them. [[0,0],[1,1]] is a straight line (no change). Edited in the Lightbox Tone Curve graph. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + + + + RenderColorGradeCurveRed + + Comment + Tone curve for the red channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD + Value + + + 0.0 + 0.0 + + + 1.0 + 1.0 + RenderColorGradeGain @@ -447,6 +655,72 @@ Value 1.0 + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + RenderDynamicExposureCoefficient Comment @@ -701,6 +975,201 @@ Value 1.3 + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderLensFlareChromaticSpread Comment @@ -811,27 +1280,27 @@ Value 0.15 - RenderLensFlareOcclusionRadius + RenderLensFlareOcclusionScale Comment - How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Size of the sun-occlusion probe as a multiple of the sun disc; it follows zoom and the sky's sun scale. Below 1 only the core of the disc counts, above 1 objects near the sun start to dim the flare. Range 0.25 to 2. Persist 1 Type F32 Value - 0.02 + 1.0 - RenderLensFlareOcclusionTaps + RenderLensFlareFadeTime Comment - Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Seconds for the lens flare to fade in when the sun is revealed; it fades out in 0.6 of this, and never faster whatever passes in front of the sun. Longer is calmer through foliage and fences. Range 0.1 to 1. Persist 1 Type - S32 + F32 Value - 9 + 0.35 RenderLensFlareStarburst @@ -869,7 +1338,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type @@ -984,6 +1453,17 @@ 0.5 + RenderSplitToneHighlightWidth + + Comment + How far above the split point, in luma from 0 to 1, the highlight tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderSplitToneMidtoneAmount Comment @@ -1025,6 +1505,17 @@ 0.5 + RenderSplitToneShadowWidth + + Comment + How far below the split point, in luma from 0 to 1, the shadow tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderTonemapACESWhite Comment diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index 0b64e1063b3..4766caf0d0b 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -1517,6 +1517,28 @@ Value https://accounts.secondlife.com/packs/floater?lang=[LANGUAGE]&shortfall=[SHORTFALL] + PaymentMethodURL + + Comment + URL for adding a payment method + Persist + 1 + Type + String + Value + https://secondlife.com/my/lindex/buy.php?associate_for_viewer=1 + + PaymentMethodStagingURL + + Comment + URL for adding a payment method on the Damballah + Persist + 1 + Type + String + Value + https://secondlife-staging.com/my/lindex/buy.php?associate_for_viewer=1 + EnableButtonFlashing Comment @@ -13834,7 +13856,7 @@ Type Boolean Value - 1 + 0 PreferredBrowserBehavior @@ -15030,17 +15052,6 @@ Value 180 - OSHibernationMode - - Comment - Whether to prevent OS from hibernating. 0 - can hibernate; 1 - can't hibernate, can turn screen off; 2 - can't hibernate, can't turn screen off - Persist - 1 - Type - S32 - Value - 0 - HeightUnits Comment @@ -16925,17 +16936,6 @@ Value 0 - ColorSettingsHideDefault - - Comment - Show non-default settings only in Color Settings list - Persist - 0 - Type - Boolean - Value - 0 - DebugSettingsHideDefault Comment diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index e2e32e14577..eca619ad578 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -310,6 +310,17 @@ Value 3 + ALLightboxState + + Comment + Lightbox: the window's shape, which tabs were out in windows of their own and where those windows were, restored when it opens. + Persist + 1 + Type + LLSD + Value + + ALXUIStudioState Comment @@ -2053,6 +2064,267 @@ Value 0.0 + RenderCrossFilterStrength + + Comment + Strength of the cross-screen (star) filter - the etched glass filter that diffracts every bright point in frame into a star. 0 disables. The arms carry a highlight's light spread along their length, so they are much dimmer than the core and this generally wants a large value - try 8 and work outward. Unlike the starburst under Lens Flare, which is locked to the sun, this streaks every highlight. Streaks ride on the bloom pyramid, so bloom strength 0 silences them. HDR only. Range 0 to 32. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterPoints + + Comment + Number of arms on each star. 4 is the classic cross filter, 6 and 8 match the denser gratings. Odd counts give an asymmetric star, since the arms are spread evenly rather than in opposed pairs. Range 2 to 12. + Persist + 1 + Type + S32 + Value + 4 + + RenderCrossFilterAngle + + Comment + Rotation of the whole star in degrees, as if turning the filter in its thread. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderCrossFilterLength + + Comment + Spacing of the samples that build each arm, which sets how far the star reaches. 1.0 is the tuned value and gives a clean continuous streak; higher spreads the arms further, and lower draws them in tight. The range stops at 2 because beyond that the samples building each arm stop overlapping and the streak breaks up into a repeating lattice rather than getting longer. Length and falloff work together - a long streak with a fast falloff still fades out early, which is usually what reads best. Range 0.25 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderCrossFilterFalloff + + Comment + How quickly each arm fades along its length, measured as how much brightness is lost between the core of a highlight and the tip of its arm. Low values give long even spokes; high values keep the star tight around the highlight. This changes only the shape of the arms - brightness belongs to Strength. Range 0.1 to 3. + Persist + 1 + Type + F32 + Value + 1.5 + + RenderCrossFilterChromatic + + Comment + Rainbow dispersion along the arms. A real star filter is a diffraction grating, and a grating separates wavelengths by angle, so the tips of the streaks go coloured while the core stays white. 0 gives clean white spokes. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtStrength + + Comment + Strength of the lens dirt overlay - grime on the front element catching bloom and flare, the way a real lens lights up when something bright is in frame. 0 disables. 0.3 is a subtle used-lens feel; 1.0 is a filthy one. Only lights up where there is already bloom or flare to catch, so a flat scene stays clean no matter how high this goes. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDirtBloomResponse + + Comment + How strongly bloom lights up the dirt. 0 means bloom passes through a clean lens; 1 is the full response. Raise it for hazy sunlit shots where the grime should catch everything. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtFlareResponse + + Comment + How strongly the lens flare lights up the dirt. 0 means the flare passes through a clean lens; 1 is the full response. This is the response that reads most like a real lens, since flare and grime share the same front element. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGain + + Comment + Overall brightness of the generated dirt plate, applied after the tone curve. Mostly a trim for Toe: raise it when a high Toe has crushed the plate too far. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtGrime + + Comment + How much muck is on the glass. Scales every layer of dust and grit at once, so this is the master density. Starting points, with Smudge and Toe to match: a lens that has been outdoors 0.45 / 0.5 / 1.8, one nobody has wiped in a while 1.0 / 1.0 / 1.6, a damaged one 0.95 / 0.9 / 1.2 with scratches, and filthy 1.9 / 1.5 / 1.2. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtMoteScale + + Comment + Size of the dust motes. Above 1 the specks grow and thin out, which reads as a lens that has picked up a few big flecks rather than an even film; below 1 they shrink and multiply into finer grime. Range 0.5 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtScratches + + Comment + How many scratches cross the glass. 0 is undamaged; around 16 reads as a well-used lens with coating chips. Scratches are straight and bright where dust is soft and scattered, so a few go a long way. Range 0 to 32. + Persist + 1 + Type + S32 + Value + 0 + + RenderLensDirtSeed + + Comment + Reshuffles the grime - every value lays the dirt out differently at the same settings. It also moves the overall density by twenty or thirty percent, because dirt pools rather than spreading evenly and where it pools changes with the seed. If a plate comes out dirtier than the last, that is this and not Grime. Range 0 to 999. + Persist + 1 + Type + S32 + Value + 7 + + RenderLensDirtSmudge + + Comment + Strength of the wipe marks - the broad, soft smears a cloth leaves behind, as opposed to the discrete specks. Raise it for a lens cleaned in a hurry. Range 0 to 2. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDirtToe + + Comment + Shapes the low end of the plate - how much of the faint grime survives into the result. Lower values keep the midtones dirt actually lives in; higher values crush them and leave only the brightest specks, which reads clean no matter how high Strength goes. Range 0.6 to 4. + Persist + 1 + Type + F32 + Value + 1.6 + + RenderLensDistortionAmount + + Comment + Overall strength of the geometric lens distortion. 0 disables the effect entirely. 0.25 is a subtle wide-angle bend; 1.0 applies the shape coefficients in full. Only the world view is warped - nametags, selection outlines and UI are drawn afterwards and stay straight. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionK1 + + Comment + Primary radial distortion coefficient. Negative values give barrel distortion (straight lines bow outward, the wide-angle look); positive values give pincushion (lines bow inward, common on telephoto). -0.2 is a mild wide angle, -0.4 approaches a fisheye look. Has no effect until Amount is raised. Range -0.5 to 0.5. + Persist + 1 + Type + F32 + Value + -0.2 + + RenderLensDistortionK2 + + Comment + Secondary radial distortion coefficient, shaping the falloff toward the corners. Leave at 0 for a simple bend; small non-zero values reproduce the moustache distortion of real wide-angle lenses, where the bend reverses direction near the frame edge. Range -0.25 to 0.25. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderLensDistortionSqueeze + + Comment + Anamorphic desqueeze factor. 1.0 is a spherical lens and does nothing. 1.33 and 2.0 match the common anamorphic formats, stretching the image horizontally the way a desqueezed cinema frame looks. Range 0.5 to 2.5. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderLensDistortionFit + + Comment + How the warped image is rescaled - 0 = none (pincushion can show black corners), 1 = fit (scaled so nothing ever goes black; the usual choice), 2 = fill (scaled so the whole undistorted frame stays visible, which can letterbox the corners) + Persist + 1 + Type + S32 + Value + 1 + + RenderLensDistortionCenter + + Comment + [-0.5, 0.5] Offset of the optical axis from the frame center, x and y, z component unused. Non-zero values decenter the distortion the way a shifted or tilted lens does. Leave at zero for a centered lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + + RenderLensDistortionTangential + + Comment + [-0.05, 0.05] Brown-Conrady tangential (decentering) coefficients p1 and p2 in x and y, z component unused. These model a lens element mounted slightly off-axis and shear the image rather than bending it radially. Realistic values are tiny - below 0.01. Leave at zero unless deliberately faking a misaligned lens. + Persist + 1 + Type + Vector3 + Value + + 0.0 + 0.0 + 0.0 + + RenderColorGrade Comment @@ -2164,6 +2436,28 @@ Value 0.0 + RenderSplitToneShadowWidth + + Comment + How far below the split point, in luma from 0 to 1, the shadow tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + + RenderSplitToneHighlightWidth + + Comment + How far above the split point, in luma from 0 to 1, the highlight tint ramps from nothing to full strength. Range 0.02 (a hard edge) to 0.5 (very soft). Default 0.35, the value that was fixed in the shader before it became a setting. + Persist + 1 + Type + F32 + Value + 0.35 + RenderColorGradeWhiteBalanceCCT Comment @@ -2330,51 +2624,97 @@ Value 0.0 - RenderColorGradeCurveToe + RenderColorGradeCurveMaster Comment - Per-channel filmic curve toe point (RGB). Pixels at or below this input level flatten toward black. Try (0.05, 0.05, 0.1) to lift shadow blues. + Tone curve applied to every channel, after the per-channel curves. An array of [input, output] control points, both 0 to 1, sorted by input with the first at input 0 and the last at input 1, up to 16 from the graph and 64 from a file; a monotone cubic spline passes through them. [[0,0],[1,1]] is a straight line (no change). Edited in the Lightbox Tone Curve graph. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveShoulder + RenderColorGradeCurveRed Comment - Per-channel filmic curve shoulder point (RGB). Pixels at or above this input level flatten toward white. Lower values (around 0.85) give a tighter roll-off. + Tone curve for the red channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. Persist 1 Type - Color3 + LLSD Value - 1.0 - 1.0 - 1.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + - RenderColorGradeCurveStrength + RenderColorGradeCurveGreen Comment - Per-channel curve blend strength (RGB). 0 disables; 0.4 is typical film, differing values per channel create cross-process looks. + Tone curve for the green channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. Persist 1 Type - Color3 + LLSD Value - 0.0 - 0.0 - 0.0 + + 0.0 + 0.0 + + + 1.0 + 1.0 + + + + RenderColorGradeCurveBlue + + Comment + Tone curve for the blue channel only, applied before the Master curve. Same format as RenderColorGradeCurveMaster: [input, output] points in 0..1, up to 16 from the graph and 64 from a file; [[0,0],[1,1]] is no change. + Persist + 1 + Type + LLSD + Value + + + 0.0 + 0.0 + + + 1.0 + 1.0 + + RenderColorGradeCurveAmount + + Comment + How much of the tone curves is applied. 0 leaves the image untouched, 1 applies the curves fully. Range 0 to 1, default 1. + Persist + 1 + Type + F32 + Value + 1.0 + RenderCVDMode Comment @@ -2749,7 +3089,7 @@ RenderLensFlareStarburstSpikes Comment - Number of aperture-like spikes in the starburst. Real cameras typically show 2x the blade count (6-blade = 12 spikes, etc). Range 1 to 32. + Angular frequency of the starburst rather than a literal spike count - it draws two opposed spikes per cycle, so the star carries twice this many primary spikes (the default 4 gives 8, matching an 8-blade iris) with fainter secondary rays between them. Real lenses show one spike per aperture blade when the blade count is even and two per blade when it is odd, so use half the blade count for an even iris (6 blades = 3) and the blade count itself for an odd one (7 blades = 7). Range 1 to 32. Persist 1 Type @@ -2779,27 +3119,27 @@ Value 0.25 - RenderLensFlareOcclusionRadius + RenderLensFlareOcclusionScale Comment - How wide the sun-occlusion test samples the scene depth. Larger values let thin geometry (foliage, wires) dim the flare more gracefully. Range 0.005 to 0.1. + Size of the sun-occlusion probe as a multiple of the sun disc; it follows zoom and the sky's sun scale. Below 1 only the core of the disc counts, above 1 objects near the sun start to dim the flare. Range 0.25 to 2. Persist 1 Type F32 Value - 0.02 + 1.0 - RenderLensFlareOcclusionTaps + RenderLensFlareFadeTime Comment - Number of depth samples for sun-occlusion testing. Higher values give smoother partial occlusion transitions at a small performance cost. Range 1 to 32. + Seconds for the lens flare to fade in when the sun is revealed; it fades out in 0.6 of this, and never faster whatever passes in front of the sun. Longer is calmer through foliage and fences. Range 0.1 to 1. Persist 1 Type - S32 + F32 Value - 9 + 0.35 RenderParcelSelectionToMaxHeight @@ -3190,6 +3530,179 @@ Value 0 + RenderBokehHighlightGain + + Comment + Extra weight given to bright samples inside the defocus blur, which makes out-of-focus highlights read as distinct bokeh discs rather than a smooth blur. 0 is a plain average and is also the cheapest. 0.5 is a gentle lift; 2.0 is a strong cinematic pop. Range 0 to 4. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightThreshold + + Comment + Brightness above which the highlight boost starts to apply. 0 boosts the whole range; raising it restricts the effect to genuine highlights and leaves midtones averaging normally. Only has an effect when Highlight Gain is above 0. Range 0 to 8. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehHighlightClamp + + Comment + Ceiling on how much radiance a single sample may contribute to a neighbouring bokeh disc. A specular glint can carry thousands of times the brightness of its surroundings; uncapped it spreads into a large blob that flickers as the camera moves. The in-focus pixel keeps its full intensity either way - only its contribution to the blur is capped. 0 disables the cap. Range 0 to 1024. + Persist + 1 + Type + F32 + Value + 64.0 + + RenderBokehApertureBlades + + Comment + Number of aperture blades shaping the out-of-focus highlights. 0 gives the perfectly round bokeh of an idealised lens. 5 to 9 reproduce real diaphragms, where the discs become visible polygons - 6 is the most common on stills lenses. Values below 3 are treated as circular. Range 0 to 11. + Persist + 1 + Type + S32 + Value + 0 + + RenderBokehApertureRotation + + Comment + Rotation of the aperture polygon in degrees. Only has an effect when Aperture Blades is 3 or more. Range 0 to 360. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehApertureCurvature + + Comment + Rounds the aperture blades back toward a circle. 0 gives hard straight-edged polygons; 1 is fully round, matching a rounded diaphragm held wide open. Only has an effect when Aperture Blades is 3 or more. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehAnamorphicSqueeze + + Comment + Stretches out-of-focus highlights into ovals, the way the cylindrical element of an anamorphic lens does. 1.0 is a round spherical-lens disc. Above 1 gives taller-than-wide ovals, the classic anamorphic signature; below 1 gives wider-than-tall. The stretch preserves area, so this changes the shape of the blur without changing how strong it is. Range 0.25 to 4. + Persist + 1 + Type + F32 + Value + 1.0 + + RenderBokehCatEyeAmount + + Comment + Optical vignetting, also called cat's eye. Toward the frame edges the lens barrel clips the aperture, squeezing round bokeh into lens-shaped slivers that lean away from the centre while the middle of the frame stays round. Strength follows distance from the centre of the image circle, so on a wide display the sides clip well before the top and bottom do. 0 disables. 0.45 is a natural fast-prime look; 1.0 is heavy. The range runs past the old maximum because measuring from the image circle lowered the offset everywhere -- a value tuned before that change wants roughly half again as much. Range 0 to 1.5. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeAmount + + Comment + Longitudinal chromatic aberration - the colour cast real glass leaves on the rim of an out-of-focus highlight, which flips hue either side of the focal plane. 0 disables. 0.2 is a subtle uncorrected-lens feel; 0.6 is strong. This is separate from the frame-wide fringe under Chromatic Aberration, which appears whether or not anything is defocused. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFringeNearTint + + Comment + Colour cast on highlights in front of the focal plane. The default light magenta matches the usual signature of uncorrected glass; swap with the far tint to reverse the effect. + Persist + 1 + Type + Color3 + Value + + 1.0 + 0.85 + 1.0 + + + RenderBokehFringeFarTint + + Comment + Colour cast on highlights behind the focal plane. The default light green is the counterpart to the near tint; a real lens shifts hue in opposite directions either side of focus. + Persist + 1 + Type + Color3 + Value + + 0.85 + 1.0 + 0.9 + + + RenderBokehSphericalAberration + + Comment + Spherical aberration - how the light inside an out-of-focus highlight is spread across it. 0 is the evenly lit disc of a perfectly corrected lens. Positive brightens the rim and hollows out the middle, the soap-bubble look; negative fills the middle and softens the edge, the creamy look. The sign flips either side of the focal plane exactly as it does in real glass, so a lens with bright-rimmed background bokeh has bright-centred foreground bokeh. Needs a few pixels of blur before there is any shape to work with, and fades out below that. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldStretch + + Comment + Field aberration - stretches out-of-focus highlights toward the frame edges while the centre stays round. 0 disables. Positive stretches them across the radius so they line up along circles and the frame appears to swirl, the look of old fast portrait glass; negative stretches them along the radius into comet shapes. Area-preserving, so highlights change shape without changing brightness. Range -1 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + + RenderBokehFieldFalloff + + Comment + How quickly field aberration builds from the centre of the frame toward the corners. 1 spreads it fairly evenly across the frame; higher values keep the middle clean and concentrate the deformation at the edges. Shape control only; use RenderBokehFieldStretch to toggle the effect. Range 1 to 4. + Persist + 1 + Type + F32 + Value + 2.0 + + RenderBokehComaAsymmetry + + Comment + Comatic asymmetry - draws each out-of-focus highlight into a comet whose tail points away from the centre of frame, the way a real lens flares off-axis. Zero on the optical axis and strongest in the corners, and it follows the highlight's actual shape, so it stays aligned with an anamorphic squeeze or a swirl rather than drifting off them. 0 disables. Pairs with RenderBokehFieldStretch, which supplies the elongation the tail runs along. Range 0 to 1. + Persist + 1 + Type + F32 + Value + 0.0 + AllowNoCopyRezRestoreToWorld Comment diff --git a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl index d3f7c630f2b..6848c7be1de 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/blitWithEffectsF.glsl @@ -61,6 +61,8 @@ uniform float uRefWipePos; // Seam position, 0..1 across the frame. vec3 clampHDRRange(vec3 color); // From postEffectUtilsF.glsl (auto-linked via mFeatures.hasPostEffects). +vec2 applyLensDistortion(vec2 uv); +float lensDistortMask(vec2 duv); vec3 applyVignette(vec3 color, vec2 uv); vec3 applyCVDCompensation(vec3 color); vec3 applyFilmGrain(vec3 color, vec2 fragCoord); @@ -79,25 +81,43 @@ vec3 applyPreview(vec3 color); // treatment, which is what a still is for. // // uRefWipeMode is zero whenever there is no still, so the sampler is never -// read unless one has been grabbed. +// read unless one has been grabbed -- GLSL's ?: evaluates only the selected +// branch, so the ternary below preserves that. +// +// Lens distortion is threaded through here rather than applied to +// vary_fragcoord in main(), and the order within this function matters: +// remap first (which pane, which side of the seam), distort second. That way +// the still and the live frame carry an identical warp, so a wipe compares +// two grades of the same lens rather than a lens against a flat plate. The +// seam test itself keeps the *raw* uv, which is what holds the divider +// straight while the image bends around it. vec4 sampleWithReference(vec2 uv) { + vec2 pane_uv = uv; + bool from_still = false; + if (uRefWipeMode == 1) { // Wipe: the still to the left of the seam, live to the right. - return (uv.x < uRefWipePos) ? texture(uReferenceStill, uv) - : texture(diffuseRect, uv); + from_still = (uv.x < uRefWipePos); } - - if (uRefWipeMode == 2) + else if (uRefWipeMode == 2) { // Side by side: both squeezed two to one, so the same region of the // image appears twice rather than two different halves of it. - return (uv.x < 0.5) ? texture(uReferenceStill, vec2(uv.x * 2.0, uv.y)) - : texture(diffuseRect, vec2((uv.x - 0.5) * 2.0, uv.y)); + from_still = (uv.x < 0.5); + pane_uv.x = from_still ? uv.x * 2.0 : (uv.x - 0.5) * 2.0; } - return texture(diffuseRect, uv); + vec2 duv = applyLensDistortion(pane_uv); + vec4 s = from_still ? texture(uReferenceStill, duv) + : texture(diffuseRect, duv); + + // Resolve out-of-frame samples to black rather than smearing the edge + // texel into the corners. rgb only: alpha carries no meaning at the + // backbuffer, and masking it would be a silent behaviour change. + s.rgb *= lensDistortMask(duv); + return s; } // A hairline at the seam, so the eye knows which side it is looking at. Drawn diff --git a/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl index 2711b767e58..d27c022b526 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/colorCorrectF.glsl @@ -33,7 +33,6 @@ out vec4 frag_color; // ---- Input ------------------------------------------------------------------ uniform sampler2D diffuseRect; -uniform sampler2D depthMap; // Shared per-frame sky/water constants, spliced from class1/deferred/environmentBlock.glsl // and bound at UB_ENVIRONMENT. Members are read by bare name. Declared unconditionally -- @@ -47,6 +46,14 @@ uniform sampler2D depthMap; // halation signal rides in the alpha channel; otherwise the pyramid is RGB-only. uniform sampler2D bloomMap; uniform float bloom_strength; +uniform sampler2D crossFilterMap; // streak accumulator; uCrossStrength gates it +// The accumulator is a quadrant of a borrowed target, not a texture of its own: +// uCrossRegion maps a screen coordinate into it (xy origin, zw scale) after +// uCrossClamp pins the coordinate to the quadrant's texel-centre range, as a +// CLAMP_TO_EDGE sampler would have at the edge of a dedicated target. +uniform vec4 uCrossRegion; +uniform vec2 uCrossClamp; +uniform float uCrossStrength; // 0 when the filter is off or unbound #ifdef BLOOM_HALATION uniform float halation_strength; uniform vec3 halation_tint; @@ -80,10 +87,20 @@ vec3 applyChannelCurves(vec3 diff); #endif #ifdef HAS_POST_EFFECTS -vec3 computeLensFlare(sampler2D diffuse, sampler2D depth, vec2 uv); +vec3 computeLensFlare(vec2 uv); vec4 applyChromaticAberration(sampler2D tex, vec2 uv); +vec3 applyLensDirt(vec2 uv, vec3 lens_light); #endif +// How much each source lights the grime. Only this shader composites the +// terms they scale, but the declarations deliberately sit OUTSIDE the +// HAS_POST_EFFECTS guard: uLensDirtBloomResponse is consumed in the +// independently-guarded BLOOM_COMPOSITE block, and tying the declaration to +// the other define made any future BLOOM_COMPOSITE-without-HAS_POST_EFFECTS +// program a hard compile error. An unused uniform costs nothing. +uniform float uLensDirtBloomResponse; +uniform float uLensDirtFlareResponse; + #ifdef DITHER vec3 applyDither(vec3 color, vec2 fragCoord); #endif @@ -108,9 +125,19 @@ void main() { // === LINEAR SPACE ======================================================== + // Light falling on the front element, accumulated as it is composited. + // Lens dirt is only visible where something is already glowing, so it needs + // the flare and bloom terms themselves rather than the finished image -- + // hence capturing them here instead of adding them anonymously. + vec3 lens_light = vec3(0.0); + #ifdef HAS_POST_EFFECTS vec4 diff = applyChromaticAberration(diffuseRect, vary_fragcoord); - diff.rgb += computeLensFlare(diffuseRect, depthMap, vary_fragcoord); + { + vec3 flare = computeLensFlare(vary_fragcoord); + diff.rgb += flare; + lens_light += flare * uLensDirtFlareResponse; + } #else vec4 diff = texture(diffuseRect, vary_fragcoord); #endif @@ -121,14 +148,35 @@ void main() { vec4 bloom_sample = texture(bloomMap, vary_fragcoord); #ifdef BLOOM_HALATION - diff.rgb += bloom_sample.rgb * bloom_strength - + bloom_sample.a * halation_strength * halation_tint; + vec3 bloom_term = bloom_sample.rgb * bloom_strength + + bloom_sample.a * halation_strength * halation_tint; #else - diff.rgb += bloom_sample.rgb * bloom_strength; + vec3 bloom_term = bloom_sample.rgb * bloom_strength; #endif + // Cross-filter streaks, composited here instead of in a pass of their + // own. Added to bloom_term rather than to diff so they inherit both of + // the couplings they had while they lived inside the pyramid: bloom + // strength scales them, and they light the lens dirt below. + if (uCrossStrength > 0.0) + { + vec2 cross_tc = clamp(vary_fragcoord, uCrossClamp, vec2(1.0) - uCrossClamp); + bloom_term += texture(crossFilterMap, uCrossRegion.xy + cross_tc * uCrossRegion.zw).rgb + * uCrossStrength * bloom_strength; + } + + diff.rgb += bloom_term; + lens_light += bloom_term * uLensDirtBloomResponse; } #endif +#ifdef HAS_POST_EFFECTS + // Still in linear light, so the dirt is exposed and tonemapped along with + // the light that lit it. In the non-HDR path lens_light carries the flare + // alone -- legacy glow composites in a separate pass much later, which is + // out of reach from here. + diff.rgb += applyLensDirt(vary_fragcoord, lens_light); +#endif + #ifdef TONEMAP diff.rgb = applyExposure(diff.rgb); #endif diff --git a/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl index b1b4751c8eb..c2a35f3993a 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/colorGradeUtilF.glsl @@ -25,7 +25,7 @@ * vec3 applyVibrance (vec3 col) // step 9 * vec3 applyHueShift (vec3 col) // step 10 * vec3 applyLUTGrading (vec3 col) // step 11 - * vec3 applyChannelCurves (vec3 col) // step 12 + * vec3 applyChannelCurves (vec3 col) // step 12 (baked 1D LUT) * * Optimisation notes: * - Every uniform here is fed a PRECOMPUTED value from pipeline.cpp. The @@ -35,7 +35,11 @@ * - Fast-path identity checks still live shader-side because this file is * attached to multiple programs with different defaults; doing them on * the CPU would require shader-variant plumbing we don't have. - * - Shared helpers (`CG_LUMA`, `cg_rgb2hsv`/`cg_hsv2rgb`, `cg_sCurve`) are + * - The two samplers (`uColorGradeLut`, `uToneCurveLut`) are only read + * behind a uniform branch on their strength/amount, so the CPU can leave + * them unbound whenever it uploads 0 and nothing is ever sampled from a + * unit that happens to hold something else. + * - Shared helpers (`CG_LUMA`, `cg_rgb2hsv`/`cg_hsv2rgb`, `cg_ramp`) are * prefixed `cg_`/`CG_` to avoid colliding with `LUMA` in * postEffectUtilsF, which can be linked into the same program. * @@ -138,16 +142,27 @@ vec3 applyLiftGammaGain(vec3 col) // // Lightroom-style, luminance-preserving. The CPU computes // `tint / max(dot(tint, LUMA), 1e-4)` for each of the three tints and uploads -// them as `*Ratio` uniforms, and also precomputes the luma split point -// `uSplitToneMid = 0.5 + balance * 0.4`. The shader just multiplies ratios by -// the pixel's luma and blends through the luma masks. - -uniform vec3 uShadowRatio; // default vec3(1) — shadow tint / dot(tint, LUMA). -uniform vec3 uMidtoneRatio; // default vec3(1) — midtone tint / dot(tint, LUMA). -uniform vec3 uHighlightRatio; // default vec3(1) — highlight tint / dot(tint, LUMA). -uniform float uMidtoneAmount; // [0, 1] default 0 — midtone strength. -uniform float uSplitToneMid; // default 0.5 — pre-slid luma split point. -uniform float uToneAmount; // [0, 1] default 0 — shadow/highlight strength. +// them as `*Ratio` uniforms, and folds each luma ramp into a {scale, bias} +// pair (ALCurveModel::splitToneRamp): the shadow ramp spans +// [mid - shadow_width, mid] and the highlight ramp [mid, mid + highlight_width], +// with mid = 0.5 + balance * 0.4. Per ramp the shader is one FMA, a clamp and +// the Hermite cubic -- no divides -- and because the two ramps meet at `mid` +// without overlapping, the three weights always sum to 1. + +uniform vec3 uShadowRatio; // default vec3(1) — shadow tint / dot(tint, LUMA). +uniform vec3 uMidtoneRatio; // default vec3(1) — midtone tint / dot(tint, LUMA). +uniform vec3 uHighlightRatio; // default vec3(1) — highlight tint / dot(tint, LUMA). +uniform float uMidtoneAmount; // [0, 1] default 0 — midtone strength. +uniform vec2 uSplitToneShadowRamp; // (1/ws, -(mid - ws)/ws) — the shadow weight is 1 - ramp. +uniform vec2 uSplitToneHighlightRamp; // (1/wh, -mid/wh). +uniform float uToneAmount; // [0, 1] default 0 — shadow/highlight strength. + +// smoothstep with its edges pre-folded into a scale/bias pair by the CPU. +float cg_ramp(float l, vec2 ramp) +{ + float t = clamp(l * ramp.x + ramp.y, 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} vec3 applySplitToning(vec3 col) { @@ -158,10 +173,10 @@ vec3 applySplitToning(vec3 col) if (uToneAmount <= 0.0 && uMidtoneAmount <= 0.0) return col; - float l = dot(col, CG_LUMA); + float l = dot(col, CG_LUMA); - float hi = smoothstep(uSplitToneMid, uSplitToneMid + 0.35, l); - float lo = 1.0 - smoothstep(uSplitToneMid - 0.35, uSplitToneMid, l); + float hi = cg_ramp(l, uSplitToneHighlightRamp); + float lo = 1.0 - cg_ramp(l, uSplitToneShadowRamp); float md = max(1.0 - hi - lo, 0.0); vec3 result = col; @@ -317,30 +332,38 @@ vec3 applyLUTGrading(vec3 col) // ============================================================================= -// Step 12 — Per-channel filmic curves (DISPLAY space) +// Step 12 — Tone curve (DISPLAY space) // ============================================================================= // -// `uCurveInvRange` holds `1 / max(shoulder - toe, 1e-4)` from the CPU, so the -// per-pixel inner loop is: subtract, multiply, clamp, cubic, mix — no divs. - -uniform vec3 uCurveToe; // default vec3(0). -uniform vec3 uCurveInvRange; // default vec3(1) — 1 / (shoulder - toe). -uniform vec3 uCurveStrength; // default vec3(0) — per-channel blend. +// Master + per-channel monotone splines, composited as master(channel(x)) and +// baked by LLPipeline::bakeToneCurveLut into a 512x1 RGBA16 row whose R, G and +// B texels are the three composite curves. `uToneCurveLutScale` is the +// half-texel mapping (1 - 1/N, 0.5/N) -- the same convention applyLUTGrading +// uses above -- so x = 0 and x = 1 read texel 0 and texel N-1 exactly and the +// hardware's linear filter interpolates between the baked samples. That +// reconstruction is good to well under 1/1024 for any curve the editor can +// make; a segment narrower than a few texels is the accepted limit of a LUT +// of any size, and the editor's minimum point spacing sits well inside it. +// +// `uToneCurveAmount` is 0 whenever the section is bypassed, every curve is +// identity, or the texture does not exist. That is the fast path, and it +// leaves the sampler unread, so nothing needs to be bound there. -float cg_sCurve(float x, float toe, float invRange, float strength) -{ - float t = clamp((x - toe) * invRange, 0.0, 1.0); - float s = t * t * (3.0 - 2.0 * t); - return mix(x, s, strength); -} +uniform sampler2D uToneCurveLut; +uniform vec2 uToneCurveLutScale; // (1 - 1/N, 0.5/N); unused on the fast path. +uniform float uToneCurveAmount; // [0, 1] default 0 — blend toward the curved value. vec3 applyChannelCurves(vec3 col) { - if (all(equal(uCurveStrength, vec3(0.0)))) + if (uToneCurveAmount <= 0.0) return col; - return vec3( - cg_sCurve(col.r, uCurveToe.r, uCurveInvRange.r, uCurveStrength.r), - cg_sCurve(col.g, uCurveToe.g, uCurveInvRange.g, uCurveStrength.g), - cg_sCurve(col.b, uCurveToe.b, uCurveInvRange.b, uCurveStrength.b)); + // No input clamp: the sampler is clamp-to-edge, so an over-range channel + // saturates to the end texel, and the caller clamps the result anyway. + vec3 u = col * uToneCurveLutScale.x + uToneCurveLutScale.y; + vec3 curved = vec3( + textureLod(uToneCurveLut, vec2(u.r, 0.5), 0.0).r, + textureLod(uToneCurveLut, vec2(u.g, 0.5), 0.0).g, + textureLod(uToneCurveLut, vec2(u.b, 0.5), 0.0).b); + return mix(col, curved, uToneCurveAmount); } diff --git a/indra/newview/app_settings/shaders/class1/alchemy/lensFlareStateF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/lensFlareStateF.glsl new file mode 100644 index 00000000000..a97bbbcac43 --- /dev/null +++ b/indra/newview/app_settings/shaders/class1/alchemy/lensFlareStateF.glsl @@ -0,0 +1,229 @@ +/** + * @file lensFlareStateF.glsl + * @brief Per-frame lens flare state: how much of the sun is unoccluded, what + * colour it is, and a temporally filtered "drive" the colour-correct + * pass multiplies its flare by. Written to a 2x1 target; read back next + * frame as its own history, exactly like the exposure map. + * + * Why a separate pass: the occlusion test's answer is the same for every + * pixel on screen, so it is measured once rather than per fragment of + * computeLensFlare; and measured raw it is binary in time -- the sun's centre + * pixel gets covered, the whole flare (a streak across the frame, halo, + * ghosts) is gone in one frame and back the next, and a moving camera behind + * fence posts or foliage strobes. Here the visibility can only change at a + * bounded rate, whatever the geometry does. This header is the one place that + * is explained; the other sites point here. + * + * Layout (texelFetch, no filtering): + * texel 0: rgb = drive, linear HDR: the sun's overbright colour integrated + * over the unoccluded part of the disc, times the screen-edge + * fade, filtered. Premultiplied on purpose -- fading out to + * black holds the last sun colour for free, and exact black + * means no flare whatever the reason. + * a = instability score, sign-packed with the direction of the + * last registered step of the raw target. + * texel 1: r = raw target luminance, g = reference luminance (a decaying + * running maximum the slew limit is relative to), b = anchor: the + * raw target luminance at the last registered step -- the step + * detector measures displacement from it, so its verdict does not + * depend on how many frames a crossing takes -- a = the dt this + * frame used, for inspection only. + * + * This file is the source of truth for the filter. Its mirror, + * scripts/content_tools/check_lens_flare_state.py, is the tool that chose the + * constants and the only regression test they have: change a constant here + * first, then make the script agree and re-run it. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * $/LicenseInfo$ + */ + +/*[EXTRA_CODE_HERE]*/ + +out vec4 frag_color; + +uniform sampler2D diffuseRect; // linear HDR scene +uniform sampler2D depthMap; // final scene depth (after alpha) +uniform sampler2D uLensFlareStateMap; // this target, last frame + +uniform float dt; // frame interval, seconds +uniform vec2 uResolution; // framebuffer size in pixels +uniform vec2 uLensFlareSunPos; // sun (or moon) centre in UV +uniform float uLensFlareSunVisibility; // CPU screen-edge fade, 0..1 +uniform float uLensFlareOcclusionRadius;// probe radius as a fraction of screen height +uniform float uLensFlareFadeTime; // RenderLensFlareFadeTime: seconds for a full fade-in + +const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); +const float GOLDEN_ANGLE = 2.39996322972865332; + +// ---- Spatial kernel --------------------------------------------------------- +// A fixed Fermat spiral of 256 taps, weighted exp(-K_TAP r^2): tight enough that +// the unoccluded estimate is 98% of the disc's centre texel, dense enough that +// a straight edge through the centre reads within 0.04 of half at every angle +// (std 0.015) and no single tap carries more than 3.1% of the weight. +// +// The pattern is deliberately NOT rotated per frame. Against a fine occluder +// such as alpha-masked foliage every tap is a coin toss, and a pattern that +// moves re-tosses them all every frame: the flare then flickered with the +// camera and the trees perfectly still. A fixed pattern is exact for a still +// scene and changes continuously as the camera moves. 48 taps could only +// afford that with rotation (worst edge bias 0.14, 15% per tap); 256 cannot +// be told apart from the rotated pattern and cost two fragments a frame. +// +// Each tap is gated on its own: an occluded tap contributes nothing, an +// unoccluded one its overbright colour (only an HDR-bright sun flares, never +// a bright wall). Gating the mean colour instead switched the whole flare on +// and off at the threshold whenever the disc was mostly hidden. +const int TAP_COUNT = 256; +const float K_TAP = 8.0; +const float GATE = 2.0; // luminance below which a texel is not sun + +// ---- FadeTime to the filter's rates ----------------------------------------- +// A first-order fade of tau = FadeTime/3 under a slew of 1/FadeTime rises +// 10-90% in FadeTime; the fall is 0.6x that. The slew is the guarantee: a full +// off-on-off cycle cannot complete in less than 1.6 FadeTime whatever passes +// in front of the sun. +const float TAU_IN_MUL = 1.0 / 3.0; // x FadeTime, seconds +const float TAU_OUT_MUL = 0.2; // x FadeTime, seconds +const float SLEW_IN_MUL = 1.0; // / FadeTime, per second, relative to the reference +const float SLEW_OUT_MUL = 1.0 / 0.6; // / FadeTime, per second + +// ---- Temporal filter -------------------------------------------------------- +const float DT_MAX = 0.25; // a stall converges, it does not teleport +const float TAU_REF = 2.0; // reference luminance decay, seconds +const float STEP_THRESH = 0.1; // displacement from the anchor that registers a step, + // relative to the unoccluded sun's brightness +const float GAIN = 0.35; // instability added per direction reversal +const float TAU_I = 1.0; // instability decay, seconds +const float I_DEADZONE = 0.30; // one reversal (an ordinary reveal) slows nothing +const float K_DAMP = 20.0; // tau multiplier slope above the dead zone +const float SNAP_FLOOR = 1e-4; // drive luminance below which a black target snaps to exact zero + +float skyOf(float d) +{ + // Only the far layers count as unoccluded: the sun disc is pinned at + // 0.999999 and the sky dome at 0.99999 (forward); mirrored under reverse-Z. +#ifdef REVERSE_Z + return smoothstep(0.9999, 1.0, 1.0 - d); +#else + return smoothstep(0.9999, 1.0, d); +#endif +} + +void main() +{ + vec2 sun_uv = uLensFlareSunPos; + vec2 radius_uv = uLensFlareOcclusionRadius * vec2(uResolution.y / max(uResolution.x, 1.0), 1.0); + + float w_sum = 0.0; + vec3 energy = vec3(0.0); + float peak = 0.0; + for (int i = 0; i < TAP_COUNT; i++) + { + float r = sqrt((float(i) + 0.5) / float(TAP_COUNT)); + float a = float(i) * GOLDEN_ANGLE; + vec2 t = vec2(cos(a), sin(a)) * r; + float w = exp(-K_TAP * dot(t, t)); + w_sum += w; + // A tap past the frame reads the nearest edge texel for depth and + // colour: an occluder reaching the edge still occludes, and a sun just + // out of view keeps its glow, so the streak survives across the edge + // margin. + vec2 uv = clamp(sun_uv + t * radius_uv, vec2(0.0), vec2(1.0)); + float sky = skyOf(texture(depthMap, uv).r); + if (sky > 0.0) + { + // Colour fetched only for unoccluded taps, so an occluder texel + // never enters the estimate, not even times zero. This fetch sits + // in non-uniform control flow, where implicit derivatives are + // undefined; an explicit level keeps it defined on every driver. + vec3 c = textureLod(diffuseRect, uv, 0.0).rgb; + float lum = dot(c, LUMA); + vec3 over = c * (max(lum - GATE, 0.0) / max(lum, 1e-4)); + energy += w * sky * over; + peak = max(peak, dot(over, LUMA)); + } + } + vec3 sun_drive = energy / w_sum; // the visible sun's overbright colour + vec3 target = sun_drive * uLensFlareSunVisibility; + + // ---- Temporal filter, in luminance, RGB following ------------------------ + vec4 prev0 = texelFetch(uLensFlareStateMap, ivec2(0, 0), 0); + vec4 prev1 = texelFetch(uLensFlareStateMap, ivec2(1, 0), 0); + float dtc = clamp(dt, 0.0, DT_MAX); + float fade = max(uLensFlareFadeTime, 0.05); + float Lp = dot(prev0.rgb, LUMA); + float Lt = dot(target, LUMA); + float L_full = peak; // the sun as if fully unoccluded + float L_ref = max(max(prev1.g * exp(-dtc / TAU_REF), Lt), Lp); + + // Instability: direction reversals of the raw target within about a second. + // A step is a move of more than STEP_THRESH away from the anchor (the value + // at the last step), measured against the unoccluded sun's brightness + // rather than the current level, so neither a crossing spread over many + // frames nor the tap jitter behind a thin post can hide or fake one. A + // fence post train reverses every crossing; a single reveal does not. + float inst_packed = prev0.a; + float I = abs(inst_packed); + float s_prev = sign(inst_packed); + float anchor = prev1.b; + float delta = (Lt - anchor) / max(max(L_full, L_ref), 1e-6); + float s_now = (abs(delta) > STEP_THRESH) ? sign(delta) : 0.0; + if (s_now != 0.0) + { + anchor = Lt; + } + bool reversal = (s_now != 0.0) && (s_prev != 0.0) && (s_now != s_prev); + I = min(I * exp(-dtc / TAU_I) + (reversal ? GAIN : 0.0), 1.0); + float s_store = (s_now != 0.0) ? s_now : s_prev; + inst_packed = (s_store != 0.0) ? s_store * max(I, 1e-3) : 0.0; + + // Fade: asymmetric time constants from FadeTime, slowed while unstable, + // and a slew cap relative to the reference so no frame moves the drive by + // more than a bounded fraction of the sun's recent brightness. + bool rising = Lt > Lp; + float tau = fade * (rising ? TAU_IN_MUL : TAU_OUT_MUL); + tau *= 1.0 + K_DAMP * max(I - I_DEADZONE, 0.0); + float alpha = 1.0 - exp(-dtc / tau); + float slew = (rising ? SLEW_IN_MUL : SLEW_OUT_MUL) / fade; + float cap = slew * dtc * L_ref; + float diff = abs(Lt - Lp); + if (diff > 1e-9) + { + alpha = min(alpha, cap / diff); + } + vec3 drive = max(mix(prev0.rgb, target, alpha), vec3(0.0)); + // A geometric decay never reaches zero in a half-float target (it pins at + // a denormal) and the reader's early-out needs exact black: snap once the + // target is black and the drive is far below anything visible. + if (Lt <= 0.0 && dot(drive, LUMA) < SNAP_FLOOR) + { + drive = vec3(0.0); + } + + if (gl_FragCoord.x < 1.0) + { + frag_color = vec4(drive, inst_packed); + } + else + { + frag_color = vec4(Lt, L_ref, anchor, dtc); + } +} diff --git a/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl b/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl index b3ddda674a0..57bc6d3563d 100644 --- a/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl +++ b/indra/newview/app_settings/shaders/class1/alchemy/postEffectUtilsF.glsl @@ -15,15 +15,22 @@ * * LINEAR SPACE (colorCorrectF) * vec4 applyChromaticAberration(sampler2D tex, vec2 uv) - * vec3 computeLensFlare (sampler2D diff, sampler2D depth, vec2 uv) + * vec3 computeLensFlare (vec2 uv) * * DISPLAY SPACE (blitWithEffectsF) + * vec2 applyLensDistortion (vec2 uv) -- UV in, UV out * vec3 applyVignette (vec3 color, vec2 uv) * vec3 applyCVDCompensation (vec3 color) * vec3 applyFilmGrain (vec3 color, vec2 fragCoord) * vec3 applyDither (vec3 color, vec2 fragCoord) * vec3 applyPreview (vec3 color) * + * applyLensDistortion is the odd one out: it transforms a sample coordinate + * rather than a colour, so it runs *before* the scene is sampled and the + * colour effects above run on the result. It is a lens effect; everything + * else in the DISPLAY SPACE group is a sensor or print effect and stays in + * unwarped screen space. + * * Conventions used throughout: * - Every effect has an `amount <= 0` fast-path that returns the input * unchanged, so the call site can unconditionally chain them. @@ -198,10 +205,10 @@ vec4 applyChromaticAberration(sampler2D tex, vec2 uv) // Lens flare — anamorphic streak with optional glow, ghosts, halo, starburst // ============================================================================= // -// Screen-space approximation of an anamorphic lens flare. Driven by a -// CPU-computed sun UV position and visibility (on-screen fade), plus a -// multi-tap depth occlusion check done here so geometry in front of the sun -// attenuates the flare smoothly. +// Screen-space approximation of an anamorphic lens flare, driven by a +// CPU-computed sun UV position and by the sun state texture that +// lensFlareStateF.glsl measures and filters once per frame (its header says +// why the measurement lives there and not here). // // Each sub-effect (glow / ghosts / halo / starburst) is gated by its own // intensity uniform (0 = disabled, else acts as a brightness multiplier), so @@ -211,12 +218,11 @@ vec4 applyChromaticAberration(sampler2D tex, vec2 uv) // ---- Driver inputs (set by the viewer each frame) -------------------------- uniform float uLensFlareStrength; // master on/off + intensity uniform vec2 uLensFlareSunPos; // sun position in UV space -uniform float uLensFlareSunVisibility; // CPU-side on-screen fade uniform vec3 uLensFlareLightColor; // artist tint applied to whole flare -// ---- Depth occlusion ------------------------------------------------------- -uniform float uLensFlareOcclusionRadius; // Poisson disk radius in UV space -uniform int uLensFlareOcclusionTaps; // 1..32 — more taps = smoother partial occlusion +// ---- Sun state (2x1, written by lensFlareStateF.glsl each frame) ----------- +// texel 0 rgb = the filtered, premultiplied flare drive; black means no flare. +uniform sampler2D uLensFlareStateMap; // ---- Anamorphic streak ----------------------------------------------------- uniform float uLensFlareStreakLength; // horizontal extent in UV space @@ -248,92 +254,19 @@ uniform int uLensFlareStarburstSpikes; // primary angular frequency uniform float uLensFlareStarburstSharpness; // pow() exponent — higher = tighter spikes uniform float uLensFlareStarburstFalloff; // radial decay rate from the sun -vec3 computeLensFlare(sampler2D diffuse, sampler2D depth, vec2 uv) +vec3 computeLensFlare(vec2 uv) { // Master gate: cheapest possible early-out. - float vis = uLensFlareSunVisibility * uLensFlareStrength; - if (vis <= 0.0) + if (uLensFlareStrength <= 0.0) return vec3(0.0); - vec2 sun_uv = uLensFlareSunPos; - - // ------------------------------------------------------------------- - // Depth-based occlusion. - // - // CPU-side visibility only tracks whether the sun is on-screen; it - // doesn't know about intervening geometry. Probe a Poisson disk of - // depth taps around the sun's UV and count how many are at the far - // plane (i.e. sky). This gives smooth partial occlusion when the sun - // is half-behind an object. - // ------------------------------------------------------------------- - if (all(greaterThanEqual(sun_uv, vec2(0.0))) && all(lessThanEqual(sun_uv, vec2(1.0)))) - { - // Pre-baked Poisson disk samples, good spatial distribution. - const vec2 taps[32] = vec2[32]( - vec2( 0.0, 0.0), - vec2(-0.326, -0.406), - vec2(-0.840, -0.074), - vec2(-0.196, 0.457), - vec2( 0.498, 0.336), - vec2( 0.106, -0.747), - vec2( 0.736, -0.290), - vec2( 0.423, 0.767), - vec2(-0.621, 0.572), - vec2( 0.890, 0.156), - vec2(-0.453, -0.780), - vec2( 0.215, -0.945), - vec2(-0.928, 0.326), - vec2( 0.673, -0.685), - vec2(-0.158, 0.892), - vec2( 0.952, 0.548), - vec2(-0.756, -0.518), - vec2( 0.347, -0.412), - vec2(-0.089, -0.290), - vec2( 0.612, 0.710), - vec2(-0.544, 0.815), - vec2( 0.818, -0.543), - vec2(-0.987, -0.321), - vec2( 0.145, 0.623), - vec2(-0.412, 0.178), - vec2( 0.567, -0.098), - vec2(-0.278, -0.654), - vec2( 0.934, 0.389), - vec2(-0.703, 0.112), - vec2( 0.056, -0.512), - vec2( 0.289, 0.934), - vec2(-0.867, 0.745) - ); - int num_taps = clamp(uLensFlareOcclusionTaps, 1, 32); - float occluded = 0.0; - for (int i = 0; i < num_taps; i++) - { - vec2 tap_uv = sun_uv + taps[i] * uLensFlareOcclusionRadius; - float d = texture(depth, tap_uv).r; - // smoothstep against near-far plane — only sky counts as visible. Mirror the far - // end under reverse-Z (far=0): smoothstep(0.9999,1,1-d) == 1-smoothstep(0,1e-4,d). -#ifdef REVERSE_Z - occluded += smoothstep(0.9999, 1.0, 1.0 - d); -#else - occluded += smoothstep(0.9999, 1.0, d); -#endif - } - vis *= occluded / float(num_taps); - } - if (vis <= 0.0) + // The filtered drive from the state pass: colour and visibility in one. + vec3 sun_color = texelFetch(uLensFlareStateMap, ivec2(0, 0), 0).rgb; + if (max(sun_color.r, max(sun_color.g, sun_color.b)) <= 0.0) return vec3(0.0); - // ------------------------------------------------------------------- - // Sample sun brightness once, convert to a normalized "overbright" - // factor. We subtract 2.0 from luminance so only HDR-bright suns - // drive the flare — prevents diffuse bright surfaces from flaring. - // ------------------------------------------------------------------- - vec3 sun_color = texture(diffuse, clamp(sun_uv, vec2(0.0), vec2(1.0))).rgb; - float sun_lum = dot(sun_color, LUMA); - float sun_bright = max(sun_lum - 2.0, 0.0) / max(sun_lum, 1e-4); - sun_color *= sun_bright; - - if (sun_bright <= 0.0) - return vec3(0.0); + vec2 sun_uv = uLensFlareSunPos; + float vis = uLensFlareStrength; float aspect = uResolution.x / max(uResolution.y, 1.0); vec2 delta = uv - sun_uv; @@ -457,7 +390,7 @@ vec3 computeLensFlare(sampler2D diffuse, sampler2D depth, vec2 uv) } // Final scale: 0.15 tames peak intensity to a plausible lens-response - // range; tint and visibility factor apply equally to all sub-effects. + // range; tint and the master strength apply equally to all sub-effects. return max(flare * vis * uLensFlareLightColor * 0.15, vec3(0.0)); } @@ -813,3 +746,129 @@ vec3 applyDither(vec3 color, vec2 fragCoord) float levels = (uDitherBits >= 10) ? 1023.0 : 255.0; return color + tpdf * (uDitherAmount / levels); } + + +// ============================================================================= +// Geometric lens distortion — Brown-Conrady radial + tangential +// ============================================================================= +// +// The only entry point here that transforms a coordinate instead of a colour. +// Runs in the final blit, warping the coordinate the scene is sampled at, so +// the vignette/grain/dither/CVD chain that follows stays in unwarped sensor +// space. That split is deliberate: distortion happens in the lens, the print +// effects happen at the sensor and on the print. +// +// This is the *inverse* map — for each output pixel it answers "where in the +// source does this come from", which is the direction a gather-based post +// process needs. Negative k1 therefore reads as barrel and positive as +// pincushion, matching how the coefficients are named on a real lens profile. +// +// Radial distance is measured in aspect-corrected units using the same +// branchless form the rest of this file uses: one component of `scale` stays +// 1.0 and the other carries the ratio, so the wide axis is stretched to its +// true physical extent and r lands near 1.0 at the frame corners. That is not +// cosmetic -- on a 16:9 frame the corners really are further from the optical +// axis than the edge midpoints, and a lens distorts by physical radius. +// +// Note: uLensDistortAmount, uLensDistortK, uLensDistortScale, uLensDistortSqueeze +// and uLensDistortTangential arrive pre-baked from the CPU (pipeline.cpp). +// The master amount is folded into the coefficients there, so the shader does +// one gate and then pure polynomial evaluation. The slider ranges quoted below +// are the *user-facing* values before baking. + +uniform float uLensDistortAmount; // artist range [0, 1]: master gate. 0 disables. +uniform vec2 uLensDistortK; // (k1, k2) already multiplied by amount on the CPU. + // k1 artist range [-0.5, 0.5]: negative barrel, + // positive pincushion. k2 [-0.25, 0.25] shapes the tail. +uniform float uLensDistortScale; // auto-fit rescale, applied as a direct multiplier. Solved + // exactly on the CPU over a dense frame probe (boundary + // walk, plus an interior grid in Fit mode). + // Uploaded as 1.0 when fit is off -- never 0. +uniform vec2 uLensDistortSqueeze; // (1 / squeeze, 1), pre-reciprocated. Anamorphic desqueeze. +uniform vec2 uLensDistortCenter; // [-0.5, 0.5] optical axis offset from frame centre. +uniform vec2 uLensDistortTangential; // (p1, p2) already multiplied by amount on the CPU. + // Decentering terms; tiny values (< 0.01) are realistic. + +vec2 applyLensDistortion(vec2 uv) +{ + // Fast path when the effect is disabled — the uniform branch is coherent + // across the whole draw, and zero-initialized uniforms land here (see the + // GL 4.1 no-default-initializers note in llshadermgr). + if (uLensDistortAmount <= 0.0) + return uv; + + // Offset from the optical axis, then aspect-corrected so the radial term + // is isotropic in physical units rather than in UV units. + vec2 p = uv - 0.5 - uLensDistortCenter; + float aspect = uResolution.x / max(uResolution.y, 1.0); + vec2 scale = max(vec2(aspect, 1.0 / max(aspect, 1e-4)), 1.0); + vec2 q = p * scale; + + float r2 = dot(q, q); + + // Radial: 1 + k1*r^2 + k2*r^4. Horner keeps it to two FMAs. + float radial = 1.0 + r2 * (uLensDistortK.x + r2 * uLensDistortK.y); + + // Tangential: the classic Brown-Conrady decentering pair. Zero by default, + // and the two terms vanish independently, so leaving them at 0 costs only + // the multiplies. + float p1 = uLensDistortTangential.x; + float p2 = uLensDistortTangential.y; + vec2 tangential = vec2(2.0 * p1 * q.x * q.y + p2 * (r2 + 2.0 * q.x * q.x), + p1 * (r2 + 2.0 * q.y * q.y) + 2.0 * p2 * q.x * q.y); + + // Warp, rescale to keep the frame filled, undo the aspect correction, then + // apply the anamorphic squeeze in UV space. + vec2 warped = (q * radial + tangential) * uLensDistortScale; + warped /= scale; + warped *= uLensDistortSqueeze; + + return warped + 0.5 + uLensDistortCenter; +} + +// ============================================================================= +// Lens dirt — grime on the front element, lit by whatever is already glowing +// ============================================================================= +// +// Contributes nothing on its own. Dirt is only visible where light is already +// falling on it, so this takes the bloom and flare terms as its input rather +// than the scene: point the camera at a flat wall and the lens looks clean no +// matter how high the strength goes, exactly as a real one does. It is also +// what makes the effect cheap -- the cost rides on effects that are already +// running. +// +// Tier 3 (uniform branch) rather than a compile-time permutation, because this +// file is a shared object attached to all nine post programs and no +// per-program define can reach it. The strength is forced to 0 by the CPU +// whenever no plate exists, so the sampler is never read unbound. +// +// The plate is a single channel: dirt is a scalar mask, and the generator +// (lensDirtGenF.glsl) writes one into an R8 target. + +uniform sampler2D uLensDirtMap; +uniform float uLensDirtStrength; // 0 disables + +vec3 applyLensDirt(vec2 uv, vec3 lens_light) +{ + if (uLensDirtStrength <= 0.0) + return vec3(0.0); + + // Sampled with raw screen UV, and there is no fitting to do: the plate is + // generated at the frame's own aspect, so a mote is already round on the + // display it was made for. + return lens_light * texture(uLensDirtMap, uv).r * uLensDirtStrength; +} + + +// Companion to the above: 1.0 inside the frame, 0.0 outside, so the call site +// can resolve out-of-frame samples to black instead of smearing the edge texel +// across the corners. Branchless, and identically 1.0 when distortion is off +// (the early-out above returns an in-range uv, and the gate makes that exact). +float lensDistortMask(vec2 duv) +{ + if (uLensDistortAmount <= 0.0) + return 1.0; + + vec2 inside = step(vec2(0.0), duv) * step(duv, vec2(1.0)); + return inside.x * inside.y; +} diff --git a/indra/newview/app_settings/shaders/class1/deferred/dofCombineF.glsl b/indra/newview/app_settings/shaders/class1/deferred/dofCombineF.glsl index 57085e25ff4..da2d0e3ea31 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/dofCombineF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/dofCombineF.glsl @@ -36,7 +36,14 @@ uniform sampler2D lightMap; uniform vec2 screen_res; uniform float max_cof; +// Fraction of the screen the blur was rendered at: CameraDoFResScale, or less +// if the borrowed target could not hold that. Drives the CoF arithmetic. uniform float res_scale; +// Where the blur sits inside the texture it was rendered into. The blur is +// drawn into a corner of a borrowed target that need not be screen-sized, so a +// screen coordinate is scaled by dof_uv_scale to land on it, and clamped to +// dof_width/dof_height, the last texel it wrote. +uniform vec2 dof_uv_scale; uniform float dof_width; uniform float dof_height; @@ -54,7 +61,7 @@ void main() { vec2 tc = vary_fragcoord.xy; - vec4 dof = dofSample(diffuseRect, vary_fragcoord.xy*res_scale); + vec4 dof = dofSample(diffuseRect, vary_fragcoord.xy*dof_uv_scale); vec4 diff = texture(lightMap, vary_fragcoord.xy); diff --git a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl index 4d5615af0e6..40da0f3dc52 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/postDeferredF.glsl @@ -34,43 +34,238 @@ uniform sampler2D diffuseRect; //[ENGINE_BLOCK Matrices] uniform vec2 screen_res; uniform float max_cof; -uniform float res_scale; in vec2 vary_fragcoord; -void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc) +// ============================================================================= +// Bokeh gather weighting +// ============================================================================= +// +// This pass moved ahead of the tonemapper, which changes what the sample +// weighting has to do. The old form was `wg = 0.25 + s.r+s.g+s.b`, applied to +// display-space values already compressed into [0, 1]: there the largest a +// sample could weigh was 3.25x the smallest, a mild nudge that made highlights +// read against a tonemapper that had already crushed them. +// +// On linear HDR the same expression is an accidental max filter. A punctual +// specular peak has no solid angle, so one pixel can carry five figures of +// radiance -- weighted by `r+g+b` it outweighs every other sample in its disc +// combined, and the disc becomes a flat plate of that one colour. So the pop +// that expression was faking now has to be asked for explicitly, and the +// default is a plain energy-conserving average. + +uniform float uBokehHighlightThreshold; // luma where the boost starts; 0 boosts the whole range +uniform float uBokehHighlightGain; // 0 = plain average (fast path); higher = more highlight pop +uniform float uBokehHighlightClamp; // per-sample radiance ceiling; <= 0 disables + +// Per-sample radiance ceiling -- the DoF-side analogue of the firefly clamp +// bloomExtractF applies for the same reason. Bounding the artifact, not the +// image: the in-focus pixel keeps its full intensity, only what a sample is +// allowed to contribute to a *neighbour's* disc is capped. Scaled by the +// largest channel so a clamped highlight keeps its colour instead of sliding +// toward whichever primary saturated first. +vec3 bokehClamp(vec3 c) { - vec4 s = texture(diffuseRect, tc); + if (uBokehHighlightClamp <= 0.0) + return c; - float sc = abs(s.a*2.0-1.0)*max_cof; + float peak = max(max(c.r, c.g), c.b); + return (peak > uBokehHighlightClamp) ? c * (uBokehHighlightClamp / peak) : c; +} - if (sc > min_sc) //sampled pixel is more "out of focus" than current sample radius +// Energy-conserving by default: every accepted sample counts once, so the +// gather is an average and a bright sample contributes its brightness rather +// than extra influence. +float bokehWeight(vec3 c) +{ + if (uBokehHighlightGain <= 0.0) + return 1.0; + + float peak = max(max(c.r, c.g), c.b); + return 1.0 + uBokehHighlightGain * max(peak - uBokehHighlightThreshold, 0.0); +} + +#if DOF_SHAPED +// ============================================================================= +// Shaped aperture, optical vignetting, and defocus fringing +// ============================================================================= +// +// Everything in this block is compiled out entirely unless at least one of +// these effects is switched on, because it lives in the innermost sample loop +// where a uniform branch still costs registers. The CPU picks between the +// shaped and unshaped programs; within a shaped one each effect gates on its +// own uniform, the way the lens flare's sub-effects do. The CPU-side `shaped` +// predicate in pipeline.cpp must list every effect that lives here -- one it +// misses becomes a dead control whenever it is the only one active. + +uniform int uBokehBlades; // 0 = circular; 3..11 = polygon +uniform float uBokehApertureRotation; // radians, converted from degrees on the CPU +uniform float uBokehApertureCurvature; // 0 straight blades -> 1 fully round +uniform vec3 uBokehApertureConst; // (pi/N, 2pi/N, cos(pi/N)) baked on the CPU +uniform vec2 uBokehAnamorphic; // per-axis sample stretch, area-preserving; (1,1) = spherical +uniform float uBokehCatEye; // 0 disables; higher clips harder toward the edges +uniform float uBokehFringeAmount; // 0 disables +uniform vec3 uBokehFringeNearTint; // applied in front of the focal plane +uniform vec3 uBokehFringeFarTint; // applied behind it +uniform float uBokehSpherical; // -1 creamy .. 0 flat disc .. +1 soap bubble +uniform float uBokehFieldStretch; // 0 disables; + tangential (swirl), - radial (coma) +uniform float uBokehFieldFalloff; // how fast the stretch grows toward the corners +uniform float uBokehComaAsymmetry; // 0 disables; ramps with field radius + +// The floor keeps the shape weight strictly positive. Two reasons, both +// measured rather than defensive. A rim-bright profile drives the inner disc +// toward zero, and near a depth edge the outer rings are all rejected by the +// `sc > min_sc` test -- so the surviving samples would carry almost no weight +// and the pixel would fall back to its own colour while its neighbour blurred +// normally, which reads as speckle along every defocus transition. And a +// creamy profile lands exactly 0.0 on the outermost ring, the largest one, so +// its samples were fetched, tinted and multiplied away: 40% of all taps at 4px +// of blur. At 0.15 the centre-tap share tracks the unaberrated baseline to +// within a percent at every blur size, and both profiles still read correctly. +const float BOKEH_SHAPE_FLOOR = 0.15; + +// How much this sample counts, before its radiance is weighed. Both aberrations +// are per-sample scalars on the same accumulation, so they combine into one +// branchless expression -- cheaper than gating each, and it avoids a divergent +// branch on `apod`, which varies per fragment through the blur-size fade. +// +// Spherical aberration redistributes weight across the disc. The gather is +// close to area-uniform -- ring sample counts grow with radius while rings stay +// one pixel apart -- so a per-sample weight is very nearly the bokeh's radial +// profile. Not exactly: int(sc*3.7) truncates a fraction of a sample from every +// ring, which biases density by up to 5% at small radii, and the outermost ring +// sits at radius_norm 1.0 where a continuous integral would half-weight it. The +// continuous form of (2r^2 - 1) has an area-weighted mean of zero, so the mean +// weight is 1 in the limit; the discrete walk deviates by up to a quarter at +// 3-4px of blur. It does not matter while the gather normalises by `w`, and it +// would matter a great deal if that normalisation were ever removed. +// +// `apod` arrives multiplied by -cof_sign and by the blur-size fade. `coma_vec` +// carries the comatic bias as one vector: its direction is the axis and its +// length is the strength, both baked per fragment. +float bokehShapeWeight(float radius_norm, vec2 samp_dir, float apod, vec2 coma_vec) +{ + float sw = 1.0 + + apod * (2.0 * radius_norm * radius_norm - 1.0) + + dot(samp_dir, coma_vec) * radius_norm; + + return max(sw, BOKEH_SHAPE_FLOOR); +} + +// 1.0 if this sample falls inside the aperture, 0.0 if a blade or the cat's-eye +// clip excludes it. `radius_norm` is the sample's position across the disc, +// 0 at the centre and 1 at the rim. +float apertureMask(float ang, float radius_norm, vec2 cat_offset) +{ + float edge = 1.0; + + if (uBokehBlades >= 3) { - float wg = 0.25; + // Inscribed radius of a regular N-gon at this angle: + // cos(pi/N) / cos(mod(theta + rot, 2pi/N) - pi/N) + // The three constants come pre-baked so the loop does no trig setup, + // only the one cosine it genuinely needs per sample. + float half_sector = uBokehApertureConst.x; + float sector = uBokehApertureConst.y; + float apothem = uBokehApertureConst.z; + + float t = mod(ang + uBokehApertureRotation, sector) - half_sector; + float poly = apothem / max(cos(t), 1e-3); - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; + // Curvature relaxes the straight blades back toward a circle, which is + // what a rounded diaphragm actually produces. + edge = mix(poly, 1.0, clamp(uBokehApertureCurvature, 0.0, 1.0)); + } - diff += wg*s; + if (radius_norm > edge) + { + return 0.0; + } - w += wg; + if (uBokehCatEye > 0.0) + { + // Optical (mechanical) vignetting. The aperture an off-axis ray sees is + // the intersection of the diaphragm with the lens barrel, and that + // second opening slides further off-centre the further the ray is from + // the axis -- so discs near the frame edge are clipped into lens-shaped + // slivers that lean away from centre, while the middle stays round. + vec2 p = vec2(sin(ang), cos(ang)) * radius_norm; + vec2 d = p - cat_offset; + if (dot(d, d) > 1.0) + { + return 0.0; + } } + + return 1.0; } -void dofSampleNear(inout vec4 diff, inout float w, float min_sc, vec2 tc) +// Longitudinal chromatic aberration. Real glass brings different wavelengths to +// focus at slightly different distances, so a defocused edge picks up a colour +// cast whose hue flips either side of the focal plane -- the familiar magenta +// in front, green behind. Approximated by tinting each sample by how far out in +// the disc it sits, which puts the cast on the disc's rim where it belongs, and +// choosing the tint by the sign of the circle of confusion. +// +// Distinct from the lateral chromatic aberration in colorCorrect: that one is a +// whole-frame radial fringe that grows toward the corners and is present +// whether or not anything is defocused. +vec3 bokehFringe(vec3 c, float radius_norm, float cof_sign) { - vec4 s = texture(diffuseRect, tc); + if (uBokehFringeAmount <= 0.0) + { + return c; + } - float wg = 0.25; + vec3 tint = (cof_sign < 0.0) ? uBokehFringeFarTint : uBokehFringeNearTint; + return c * mix(vec3(1.0), tint, clamp(radius_norm, 0.0, 1.0) * uBokehFringeAmount); +} +#endif - // de-weight dull areas to make highlights 'pop' - wg += s.r+s.g+s.b; +// Note the centre sample in main() is deliberately neither clamped nor +// radiance-weighted. It is this pixel's own value, and when the pixel is in +// focus the gather loops never run at all -- clamping it there would clip +// in-focus highlights, which is the opposite of what the clamp is for. It IS +// shape-weighted, so a rim-bright profile does not leave the sharp image +// bleeding through the middle of the disc; see the centre tap in main(). +// +// radius_norm and cof_sign are only read in the shaped build; the unshaped one +// discards them along with the fringe call. +// +// One accumulate body shared by both gathers, so the near and far fields can +// never weight samples differently -- a one-sided edit to the clamp or fringe +// would otherwise show up as a subtle front/back blur mismatch. +void dofAccumulate(inout vec4 diff, inout float w, vec4 s, float radius_norm, float cof_sign, float shape_w) +{ + vec3 c = bokehClamp(s.rgb); +#if DOF_SHAPED + c = bokehFringe(c, radius_norm, cof_sign); +#endif + vec4 cs = vec4(c, s.a); + float wg = bokehWeight(cs.rgb) * shape_w; - diff += wg*s; + diff += wg*cs; w += wg; } +void dofSample(inout vec4 diff, inout float w, float min_sc, vec2 tc, float radius_norm, float cof_sign, float shape_w) +{ + vec4 s = texture(diffuseRect, tc); + + float sc = abs(s.a*2.0-1.0)*max_cof; + + if (sc > min_sc) //sampled pixel is more "out of focus" than current sample radius + { + dofAccumulate(diff, w, s, radius_norm, cof_sign, shape_w); + } +} + +void dofSampleNear(inout vec4 diff, inout float w, vec2 tc, float radius_norm, float cof_sign, float shape_w) +{ + dofAccumulate(diff, w, texture(diffuseRect, tc), radius_norm, cof_sign, shape_w); +} + vec3 clampHDRRange(vec3 color); void main() @@ -80,26 +275,204 @@ void main() vec4 diff = texture(diffuseRect, vary_fragcoord.xy); { - float w = 1.0; - float sc = (diff.a*2.0-1.0)*max_cof; float PI = 3.14159265358979323846264; + // Outermost ring radius and which side of focus we are on. The rings + // walk inward from here, so sc/max_radius is the sample's position + // across the disc: 1.0 at the rim, approaching 0 at the centre. Both + // the aperture shape and the fringe are defined in those terms. + float max_radius = max(abs(sc), 1e-4); + float cof_sign = (sc < 0.0) ? -1.0 : 1.0; + +#if DOF_SHAPED + vec2 cat_offset = vec2(0.0); + vec2 ax = vec2(1.0, 0.0); + vec2 ay = vec2(0.0, 1.0); + vec2 coma_vec = vec2(0.0); + float apod_signed = 0.0; + float ring_density = 1.0; + + // Everything below is only read by the gather loops, so fragments that + // are in focus -- most of the frame at a mild setting -- skip the lot. + // It matters more than it looks: the block carries several divides, two + // square roots and a smoothstep, where before these effects existed it + // was two scalar assignments. + if (abs(sc) > 0.5) + { + // Where this fragment sits in the frame, measured the way every + // other radial effect in the stack measures it: aspect-corrected, + // then normalised over the half-diagonal so the corner reads 1.0 on + // any viewport shape. Measured in raw UV instead, "distance from + // the optical axis" reaches 1.0 at the left edge of a 21:9 frame + // and 1.0 at its top edge, which are nowhere near the same distance + // -- and the effects keyed off it then follow the viewport + // rectangle rather than the lens's image circle. + float dof_aspect = screen_res.x / max(screen_res.y, 1.0); + vec2 dof_ascale = max(vec2(dof_aspect, 1.0 / max(dof_aspect, 1e-4)), 1.0); + vec2 field_vec = (vary_fragcoord.xy - 0.5) * dof_ascale; + float field_len = length(field_vec); + vec2 field_dir = (field_len > 1e-5) ? (field_vec / field_len) : vec2(0.0); + float field_r = clamp(field_len / (0.5 * length(dof_ascale)), 0.0, 1.0); + + // Aberrations fade in with blur size. One ring cannot carry a + // radial profile: below about 1.5px the disc is a single ring + // sitting at radius_norm 1.0, so a shaped weight there is applied + // to every surviving sample at once and the blur either collapses + // or goes one-sided. Fading to zero leaves those pixels behaving + // exactly as they do without the effect. + float shape_fade = smoothstep(1.5, 4.0, max_radius); + + // Offset of the barrel opening for optical vignetting, growing with + // distance from the optical axis. + cat_offset = field_dir * (field_r * uBokehCatEye); + + // Anamorphic deformation. A cylindrical element squeezes the image on + // one axis, and out-of-focus highlights inherit that squeeze as ovals + // -- the format's most recognisable signature. Applied to the sample + // offsets *after* the aperture test, not to the test itself: the + // diaphragm is whatever shape it is, and the cylinder stretches the + // disc that results, so blades and cat's-eye slivers stretch with it. + // + // The CPU sends the anamorphic squeeze area-preserving (the two + // axes multiply to 1), so the control changes the shape of the blur + // without also changing how much of it there is. + // + // Anamorphic alone is a diagonal matrix. Field stretch adds a + // second squeeze on the radial axis, so the pair becomes a general + // 2x2 carried as its two column vectors; with field stretch off it + // reduces to (anam.x, 0) and (0, anam.y). + if (uBokehFieldStretch != 0.0 && field_len > 1e-5) + { + // Stretch across the radius for swirl, along it for coma. The + // sqrt makes the axes s and 1/s, so the deformation is + // area-preserving and a highlight keeps its brightness as it + // deforms. max() on the falloff because a zero exponent would + // make pow() return 1.0 everywhere and stretch the on-axis disc + // as hard as the corners. + vec2 u = (uBokehFieldStretch > 0.0) + ? vec2(-field_dir.y, field_dir.x) // across the radius + : field_dir; // along it + float s = sqrt(1.0 + abs(uBokehFieldStretch) + * pow(field_r, max(uBokehFieldFalloff, 1.0))); + + // A symmetric stretch by s along u is (1/s)I + (s - 1/s)uu^T. + // field_dir is already the unit vector the rotation would have + // rebuilt, so there is no angle to recover and no trig here. + float k = s - 1.0 / s; + ax = vec2(1.0 / s + k * u.x * u.x, k * u.x * u.y); + ay = vec2(k * u.x * u.y, 1.0 / s + k * u.y * u.y); + } + + // Anamorphic applied outermost: the cylindrical element squeezes + // the whole image, including whatever shape the field aberration + // has already produced. + ax *= uBokehAnamorphic; + ay *= uBokehAnamorphic; + + // Largest singular value of the basis -- how far its widest axis + // has been stretched. Ring sample counts scale by it so a deformed + // disc does not thin out into visible rings. This reduces exactly + // to max(anam.x, anam.y) when there is no field stretch, and is + // not max(anam, field) with it: the two stretch axes can oppose, + // and the true maximum then sits *below* max(anam). + // Capped because it multiplies the tap count directly, and the only + // other bound on it is a pair of CPU clamps two files away. + float bF = dot(ax, ax) + dot(ay, ay); + float bD = ax.x * ay.y - ax.y * ay.x; + ring_density = min(sqrt(max(0.5 * (bF + sqrt(max(bF * bF - 4.0 * bD * bD, 0.0))), 1e-4)), 3.0); + + // Spherical aberration. Multiplied by -cof_sign, not cof_sign: + // cof_sign is +1 in front of the focal plane, so anchoring the + // control to the foreground would invert it for the background -- + // and the background is the only field the default build renders, + // since RenderDepthOfFieldNearBlur defaults off and compiles the + // near gather out. Positive now means a bright rim behind focus, + // which is what the setting says it means. + apod_signed = uBokehSpherical * shape_fade * -cof_sign; + + // Comatic asymmetry, as one vector: direction is the bias axis, + // length is the strength. + // + // Two things here are easy to get backwards, and both were. + // + // This pass is a *gather*: a fragment reads its neighbours, so a + // point source renders as the weight function mirrored through the + // origin. Favouring outward samples therefore deposits light on the + // inward side and the comet points at the frame centre. The axis is + // negated so the rendered flare runs outward, the way real coma and + // this setting's own description both say it should. + // + // And the axis has to be measured in the disc's *parameter* space, + // because that is where the samples are chosen. The screen-space + // centroid is M times the parameter-space centroid, so biasing + // along M^T(field) lands the comet along M M^T(field) -- 41 degrees + // off with a strong anamorphic squeeze. Biasing along the inverse + // instead puts it back exactly on the field direction. det(M) is + // the anamorphic product, which the CPU sends as 1 and which is + // positive regardless, so the adjugate serves and the 1/det drops + // out in the normalise. + if (uBokehComaAsymmetry != 0.0 && field_len > 1e-5) + { + vec2 g = vec2(ay.y * field_dir.x - ay.x * field_dir.y, + ax.x * field_dir.y - ax.y * field_dir.x); + float gl = length(g); + if (gl > 1e-5) + { + coma_vec = -(g / gl) + * (uBokehComaAsymmetry * field_r * shape_fade); + } + } + } +#else + const float ring_density = 1.0; +#endif + + // The centre tap is the sample at radius_norm 0, so it carries that + // position's weight rather than a bare 1.0. Leaving it unweighted is + // what let a rim-bright profile bleed the sharp image through wherever + // the surrounding ring samples were rejected. +#if DOF_SHAPED + float w = bokehShapeWeight(0.0, vec2(0.0), apod_signed, coma_vec); +#else + float w = 1.0; +#endif + diff *= w; + // sample quite uniformly spaced points within a circle, for a circular 'bokeh' #if FRONT_BLUR if (sc > 0.5) { while (sc > 0.5) { - int its = int(max(1.0,(sc*3.7))); + int its = int(max(1.0,(sc*3.7*ring_density))); for (int i=0; i 0.5) { - int its = int(max(1.0,(sc*3.7))); + int its = int(max(1.0,(sc*3.7*ring_density))); for (int i=0; i 1 decays faster +uniform float uCrossChromatic; // 0 = white streaks, 1 = full dispersion +uniform float uCrossPassScale; // 1, TAPS, TAPS^2 across the three iterations + +// The source need not be a texture of its own. From the second pass on it is +// a quadrant of a larger borrowed target (see generateBloomHDR), so a +// region-relative coordinate is mapped into the texture through uCrossRegion +// (xy origin, zw scale) -- and clamped first to the region's texel-centre +// range, which is exactly what CLAMP_TO_EDGE did at the edge of a dedicated +// target and what stops a tap near the edge reading the neighbouring +// quadrant. A whole texture is region (0,0,1,1) with a clamp of 0, where the +// sampler's own edge clamp still applies. +uniform vec4 uCrossRegion; +uniform vec2 uCrossClamp; + +in vec2 vary_texcoord0; + +// Four taps per pass with the stride quadrupling to match is not a tuning +// choice -- it is the whole trick. +// +// Composing the three passes puts a sample at every offset i + 4j + 16k for +// i, j, k in 0..3. That is base-4 positional notation, so the chain reaches +// every integer offset from 0 to 63 exactly once, weighted falloff^-offset: +// an exact exponential line filter from twelve taps instead of sixty-four. +// +// Two things break that: +// +// - TAPS not matching the stride. Six taps against a stride of four covers +// the same span with multiplicity running 1,1,1,1,2,2,1,1,2,2,... -- a +// modulation repeating at 4 and again at 16, which reads as self-similar +// spikes along every arm. +// +// - Sampling more than one direction per pass. The tiling argument assumes +// offsets accumulate one-sided. Let a pass also step backwards and the net +// offset becomes +/-i +/-4j +/-16k with independent signs: 127 offsets +// instead of 64, reached by paths whose weight is set by how far the path +// travelled rather than where it ended. Measured against a clean +// exponential that is a 73% error with ten places where the arm gets +// *brighter* further out. +// +// So this shader streaks exactly one direction, one-sided, and the caller runs +// a separate three-pass chain per arm. uCrossLength must also stay near one +// texel: it multiplies every offset, so at 2 the chain lands on even texels +// only and real gaps open between them. +// +// CROSS_TAPS is injected at compile time from CROSS_FILTER_TAPS +// (llviewershadermgr.h) -- the same constant pipeline.cpp derives the pass +// strides and the falloff remap from, so the three moving parts of the +// tiling can no longer disagree. +const int TAPS = CROSS_TAPS; +const float CHAIN_REACH = float(TAPS * TAPS * TAPS - 1); + +// Cheap spectrum for the dispersion. A real star filter is a diffraction +// grating, and a grating separates wavelengths by angle -- which is why the +// tips of the streaks go rainbow while the core stays white. Three overlapping +// triangular lobes are enough to read as that without a LUT. +vec3 spectrum(float t) +{ + float s = clamp(t, 0.0, 1.0) * 3.0; + return clamp(vec3(1.5 - abs(s - 0.5), + 1.5 - abs(s - 1.5), + 1.5 - abs(s - 2.5)), 0.0, 1.0); +} + +void main() +{ + vec3 accum = vec3(0.0); + float total_w = 0.0; + + for (int i = 0; i < TAPS; ++i) + { + float step_index = float(i) * uCrossPassScale; + vec2 offset = uCrossDir * uCrossTexel * uCrossLength * step_index; + + // Attenuation is exponential in the step index, so the three passes + // compose into one continuous exponential rather than three banded + // ones: weight(a) * weight(b) == weight(a + b). + float weight = pow(uCrossFalloff, -step_index); + + vec3 tint = vec3(1.0); + if (uCrossChromatic > 0.0) + { + // Normalised against the chain's full reach so the hue sweep spans + // the whole arm. + float t = clamp(step_index / CHAIN_REACH, 0.0, 1.0); + + // Normalised so the three lobes always average to white. + // Dispersion redistributes a tap's energy across the channels; it + // must not add or remove any, or the dispersion slider doubles as a + // brightness slider. + vec3 sp = spectrum(t); + sp *= 3.0 / max(sp.r + sp.g + sp.b, 1e-4); + + // The ramp by t keeps the core white, and it is not cosmetic. A + // grating deviates by wavelength, so at zero deviation every + // wavelength lands in the same place: the centre of a streak is + // white by construction and only the tips separate into colour. + tint = mix(vec3(1.0), sp, uCrossChromatic * t); + } + + vec2 tc = clamp(vary_texcoord0 + offset, uCrossClamp, vec2(1.0) - uCrossClamp); + accum += texture(diffuseMap, uCrossRegion.xy + tc * uCrossRegion.zw).rgb * weight * tint; + total_w += weight; + } + + // Normalise by the total tap weight, making this a weighted average along + // the arm rather than a sum, so a uniform region passes through unchanged + // and falloff shapes the arm without also setting its brightness. + accum /= max(total_w, 1e-4); + + frag_color = vec4(accum, 0.0); +} diff --git a/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl b/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl new file mode 100644 index 00000000000..c12b0b28fc8 --- /dev/null +++ b/indra/newview/app_settings/shaders/class1/effects/lensDirtGenF.glsl @@ -0,0 +1,267 @@ +/** + * @file lensDirtGenF.glsl + * @brief Generates the lens dirt plate into a texture. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Alchemy Viewer Project + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +/*[EXTRA_CODE_HERE]*/ + +// Muck on the front element: defocused dust, wipe smudges, stray fibres, fine +// grit, and optionally scratches and coating chips. The result is a scalar mask +// that colorCorrect multiplies bloom and flare by, so only relative brightness +// matters and the plate is heavily biased to black. +// +// This runs ONCE into a texture whenever a parameter changes or the window +// resizes -- not per frame. That budget is the whole reason the effect can be +// procedural at all: a few hundred ALU per pixel is unthinkable in the +// per-frame path and unremarkable in a one-off, which is what lets this draw +// discrete features rather than settling for whatever a couple of octaves of +// noise happen to look like. +// +// Generating at the target's own resolution also retires the cover-fit the +// baked plates needed. A square image sampled with screen UV stretches every +// round mote into an ellipse on a wide display; there is no fitting to do when +// the plate is made at the shape it will be read at. + +out vec4 frag_color; + +in vec2 vary_texcoord0; + +uniform vec2 uDirtResolution; // plate size in pixels; only the ratio is read +uniform float uDirtSeed; // reshuffles every layer +uniform float uDirtGrime; // master density +uniform float uDirtMoteScale; // >1 enlarges the motes and thins them out +uniform float uDirtSmudge; // wipe-mark strength +uniform int uDirtScratches; // 0 for undamaged glass +uniform float uDirtToe; // tone curve exponent +uniform float uDirtGain; // tone curve gain + +// Upper bound on the segment loops, injected at compile time from +// LENS_DIRT_MAX_LINES so the CPU clamp and the loop bound cannot drift apart. +// The loop is bounded rather than run to a uniform so the compiler can unroll +// it instead of branching per iteration on a value it cannot see. +const int MAX_LINES = DIRT_MAX_LINES; + +// ---------------------------------------------------------------- hashes ---- + +float hash21(vec2 p, float seed) +{ + return fract(sin(p.x * 127.1 + p.y * 311.7 + seed * 74.7) * 43758.5453); +} + +vec2 hash22(vec2 p, float seed) +{ + return fract(vec2(sin(p.x * 127.1 + p.y * 311.7 + seed * 74.7), + sin(p.x * 269.5 + p.y * 183.3 + seed * 51.3)) * 43758.5453); +} + +float screenBlend(float a, float b) +{ + return 1.0 - (1.0 - a) * (1.0 - b); +} + +// ------------------------------------------------------------ mote layer ---- + +// One jittered disc per grid cell, scanned over the 3x3 neighbourhood so a disc +// crossing a cell boundary still registers. +// +// Three details separate this from looking like plain cellular noise, and all +// three were found by comparing bakes against the plates this replaces. +// +// Cells are dropped when their hash exceeds `density`: one feature per cell is +// perfectly even, and dirt is not -- it pools and leaves clean glass between. +// Radii are raised to `bias` so most motes come out small with a few large, +// rather than filling the range uniformly. And the profile is a flat interior +// with a quick rim rather than a Gaussian, because the reference motes were +// drawn as solid shapes and lightly blurred; a Gaussian has no edge anywhere +// and reads as fog the moment neighbouring blobs overlap. `softness` is the +// fraction of the radius the rim occupies. +float dirtMotes(vec2 uv, float cells, float r_min, float r_max, float softness, + float seed, float aspect, float density, float bias) +{ + float acc = 0.0; + vec2 cell = floor(uv * cells); + + for (int ox = -1; ox <= 1; ++ox) + { + for (int oy = -1; oy <= 1; ++oy) + { + vec2 g = cell + vec2(float(ox), float(oy)); + float keep = step(hash21(g, seed + 57.0), density); + vec2 f = (g + hash22(g, seed)) / cells; + float rad = r_min + (r_max - r_min) * pow(hash21(g, seed + 19.0), bias); + + vec2 d2 = vec2((uv.x - f.x) * aspect, uv.y - f.y); + float d = length(d2); + + float x = clamp((rad - d) / (rad * softness + 1e-9), 0.0, 1.0); + acc = screenBlend(acc, x * x * (3.0 - 2.0 * x) * keep); + } + } + return acc; +} + +// ----------------------------------------------------------- value noise ---- + +float vnoise(vec2 uv, float freq, float seed) +{ + vec2 p = uv * freq; + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + + float a = hash21(i, seed); + float b = hash21(i + vec2(1.0, 0.0), seed); + float c = hash21(i + vec2(0.0, 1.0), seed); + float d = hash21(i + vec2(1.0, 1.0), seed); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm3(vec2 uv, float freq, float seed) +{ + float total = 0.0; + float amp = 1.0; + float norm = 0.0; + for (int i = 0; i < 3; ++i) + { + total += amp * vnoise(uv, freq * exp2(float(i)), seed + float(i) * 13.0); + norm += amp; + amp *= 0.5; + } + return total / norm; +} + +// ------------------------------------------------------------- segments ----- + +float segDist(vec2 uv, vec2 a, vec2 b, float aspect) +{ + vec2 pa = vec2((uv.x - a.x) * aspect, uv.y - a.y); + vec2 ba = vec2((b.x - a.x) * aspect, b.y - a.y); + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-9), 0.0, 1.0); + return length(pa - ba * h); +} + +// Fibres and scratches. A segment distance field, optionally domain-warped by +// low-frequency noise so a fibre wanders while a scratch stays straight -- the +// only difference between a thread lying on the glass and a wipe with grit in +// the cloth. +float dirtLines(vec2 uv, int count, float length_, float width, float seed, + float aspect, float wander) +{ + vec2 w = uv; + if (wander > 0.0) + { + w += (vec2(fbm3(uv, 3.0, seed + 7.0), fbm3(uv, 3.0, seed + 31.0)) - 0.5) * wander; + } + + float acc = 0.0; + for (int i = 0; i < MAX_LINES; ++i) + { + if (i >= count) + { + break; + } + float fi = float(i); + vec2 a = vec2(hash21(vec2(fi, 1.0), seed), hash21(vec2(fi, 2.0), seed)); + float ang = hash21(vec2(fi, 3.0), seed) * 6.2831853; + float len = length_ * (0.4 + 0.6 * hash21(vec2(fi, 4.0), seed)); + vec2 b = a + vec2(cos(ang), sin(ang)) * len; + float wid = width * (0.5 + 0.5 * hash21(vec2(fi, 5.0), seed)); + + float d = segDist(w, a, b, aspect); + float x = clamp((wid - d) / max(wid, 1e-9), 0.0, 1.0); + acc = max(acc, x * x * (3.0 - 2.0 * x) * (0.55 + 0.45 * hash21(vec2(fi, 6.0), seed))); + } + return acc; +} + +// ---------------------------------------------------------------- build ----- + +void main() +{ + vec2 uv = vary_texcoord0; + float aspect = uDirtResolution.x / max(uDirtResolution.y, 1.0); + float seed = uDirtSeed; + float grime = uDirtGrime; + float mscale = max(uDirtMoteScale, 0.05); + + // Where the muck gathers. Dirt does not spread evenly over a lens: it pools + // and leaves other areas nearly clean, and modulating the mote layers by a + // low-frequency field is what turns an even scatter into something that + // looks like it settled there. + // + // The frequency is load-bearing for a reason that is not obvious. A clump + // field with only a handful of features across the plate has a spatial mean + // that swings from seed to seed, and since it multiplies every mote layer + // that swing becomes the plate's overall density -- at a low frequency the + // same settings covered anywhere from 19% to 76% of the frame on nothing + // but the seed. More features average the mean back toward the middle, and + // the narrow output range keeps what wobble remains away from the result. + float clump = fbm3(uv, 5.0, seed + 101.0); + clump = 0.62 + 0.76 * clamp((clump - 0.34) / 0.36, 0.0, 1.0); + + float img = 0.0; + + // Motes, large relative to their spacing so they overlap into a mottled + // field rather than reading as separate blobs. + img = screenBlend(img, dirtMotes(uv, 4.5 / mscale, 0.050, 0.140, 0.45, + seed, aspect, 0.80 * grime, 1.7) * 0.46 * clump); + img = screenBlend(img, dirtMotes(uv, 9.0 / mscale, 0.022, 0.064, 0.42, + seed + 3.0, aspect, 0.75 * grime, 1.9) * 0.50 * clump); + img = screenBlend(img, dirtMotes(uv, 20.0 / mscale, 0.009, 0.028, 0.40, + seed + 5.0, aspect, 0.60 * grime, 2.1) * 0.44 * clump); + + // Fine grit. Small and sharp, and the layer most easily overdone: too much + // and the plate reads as a starfield rather than as dirt. + img = screenBlend(img, dirtMotes(uv, 90.0, 0.0011, 0.0030, 0.55, + seed + 9.0, aspect, 0.34 * grime, 1.5) * 0.75); + + // Wipe smudges: broad and low contrast, a cloth pushed across the glass. + float sm = fbm3(uv, 1.6, seed + 21.0); + img = screenBlend(img, pow(clamp((sm - 0.50) / 0.34, 0.0, 1.0), 1.4) * 0.34 * uDirtSmudge); + + // Stray fibres. + img = screenBlend(img, dirtLines(uv, 12, 0.20, 0.0014, seed + 41.0, aspect, 0.06) * 0.62); + + if (uDirtScratches > 0) + { + img = screenBlend(img, dirtLines(uv, uDirtScratches, 0.55, 0.0022, + seed + 57.0, aspect, 0.0)); + } + + // Keep the middle of the frame clearer. The plate multiplies bloom, and the + // subject usually sits in the centre, so grime there veils exactly what the + // viewer is looking at. The range is wider than it looks like it needs to + // be because the clump field above swings harder than this does, and a + // gentler curve simply disappeared underneath it. + vec2 c = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); + float r = clamp(length(c) / (0.5 * length(vec2(aspect, 1.0))), 0.0, 1.0); + img *= 0.22 + 0.78 * pow(r, 0.75); + + // Shape the low end. The layers above are deliberately generous with area, + // and this is what decides whether the plate reads at all: crushing it too + // hard removes the midtones dirt actually lives in and leaves a few bright + // dots that stay invisible however high the strength goes. + float v = clamp(uDirtGain * pow(clamp(img, 0.0, 1.0), uDirtToe), 0.0, 1.0); + + frag_color = vec4(v, v, v, 1.0); +} diff --git a/indra/newview/llagent.cpp b/indra/newview/llagent.cpp index af701857276..a921f6ccb7e 100644 --- a/indra/newview/llagent.cpp +++ b/indra/newview/llagent.cpp @@ -607,6 +607,9 @@ void LLAgent::setFirstLogin(bool b) { setFeatureVersion(UI_FEATURE_VERSION, UI_FEATURE_FLAGS); } + + // Shared environment on teleport should only be enabled by default for new users; + gSavedSettings.setBOOL("SwitchToSharedEnvAfterTeleport", true); } } @@ -1665,8 +1668,6 @@ void LLAgent::setAFK() setControlFlags(AGENT_CONTROL_AWAY | AGENT_CONTROL_STOP); gAwayTimer.start(); } - - LLAppViewer::instance()->setPermitOSHibernation(true); } //----------------------------------------------------------------------------- @@ -1685,13 +1686,6 @@ void LLAgent::clearAFK() sendAnimationRequest(ANIM_AGENT_AWAY, ANIM_REQUEST_STOP); clearControlFlags(AGENT_CONTROL_AWAY); } - - if (isAgentAvatarValid()) - { - // Only set this if agent is inworld, login screen - // shouldn't prevent hibernation. - LLAppViewer::instance()->setPermitOSHibernation(false); - } } //----------------------------------------------------------------------------- diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index af0ab84b435..5caf6e4842a 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -636,12 +636,6 @@ bool LLAppViewer::sendURLToOtherInstance(const std::string& url) return false; } -//virtual -void LLAppViewer::setOSHibernationMode(eHibernationMode mode) -{ - // See OS specific files -} - //---------------------------------------------------------------------------- // LLAppViewer definition @@ -6241,29 +6235,6 @@ void LLAppViewer::outOfMemorySoftQuit() } } -void LLAppViewer::setPermitOSHibernation(bool permit) -{ - if (permit) - { - if (mCurrentHibernationMode != LL_HIBERNATE_MODE_DEFAULT) - { - // Will call OS specific code to let OS hibernate when idle - setOSHibernationMode(LL_HIBERNATE_MODE_DEFAULT); - mCurrentHibernationMode = LL_HIBERNATE_MODE_DEFAULT; - } - } - else - { - static LLCachedControl os_hibernation_mode(gSavedSettings, "OSHibernationMode", 0); - eHibernationMode mode = static_cast(os_hibernation_mode()); - if (mode != LL_HIBERNATE_MODE_DEFAULT && mCurrentHibernationMode != mode) - { - setOSHibernationMode(mode); - mCurrentHibernationMode = mode; - } - } -} - void LLAppViewer::idleNameCache() { // Neither old nor new name cache can function before agent has a region @@ -6504,9 +6475,6 @@ void LLAppViewer::disconnectViewer() // Pass the connection state to LLUrlEntryParcel not to attempt // parcel info requests while disconnected. LLUrlEntryParcel::setDisconnected(gDisconnected); - - // Restore default OS hibernation mode - setPermitOSHibernation(true); } void LLAppViewer::forceErrorLLError() @@ -6856,15 +6824,6 @@ void LLAppViewer::handleLoginComplete() // we logged in successfully, so save settings on logout LL_INFOS() << "Login successful, per account settings will be saved on log out." << LL_ENDL; mSavePerAccountSettings=true; - - // Don't allow hibernation while we're running - setPermitOSHibernation(false); - // Track 'hibernation' mode changes - mOSHibernationModeChangeConnection = gSavedSettings.getControl("OSHibernationMode")->getSignal()->connect([](LLControlVariable* control, const LLSD& new_val, const LLSD& old_val) - { - // setPermitOSHibernation will sort itself out based on new mode. - LLAppViewer::instance()->setPermitOSHibernation(false); - }); } //virtual diff --git a/indra/newview/llappviewer.h b/indra/newview/llappviewer.h index 2b21dc814d9..eccca2082d1 100644 --- a/indra/newview/llappviewer.h +++ b/indra/newview/llappviewer.h @@ -98,9 +98,9 @@ class LLAppViewer : public LLApp // // Main application logic // - virtual bool init(); // Override to do application initialization - virtual bool cleanup(); // Override to do application cleanup - virtual bool frame(); // Override for application body logic + bool init() override; // Override to do application initialization + bool cleanup() override; // Override to do application cleanup + bool frame() override; // Override for application body logic // Application control void flushLFSIO(); // waits for lfs transfers to complete @@ -286,8 +286,6 @@ class LLAppViewer : public LLApp // Note: mQuitRequested can be aborted by user. void outOfMemorySoftQuit(); - virtual void setPermitOSHibernation(bool permit); - #ifdef LL_DISCORD static void initDiscordSocial(); static void updateDiscordActivity(); @@ -304,14 +302,6 @@ class LLAppViewer : public LLApp virtual bool initSLURLHandler(); virtual bool sendURLToOtherInstance(const std::string& url); - typedef enum - { - LL_HIBERNATE_MODE_DEFAULT = 0, // Use the platform's default behavior. - LL_HIBERNATE_MODE_PREVENT = 1, - LL_HIBERNATE_MODE_PREVENT_SCREEN = 2, - } eHibernationMode; - virtual void setOSHibernationMode(eHibernationMode mode); - virtual bool initParseCommandLine(LLCommandLineParser& clp) { return true; } // Allow platforms to specify the command line args. @@ -319,7 +309,7 @@ class LLAppViewer : public LLApp virtual bool meetsRequirementsForMaximizedStart(); // Used on first login to decide to launch maximized - virtual void sendOutOfDiskSpaceNotification(); + void sendOutOfDiskSpaceNotification() override; protected: @@ -416,9 +406,6 @@ class LLAppViewer : public LLApp LLAppCoreHttp mAppCoreHttp; bool mIsFirstRun; - - eHibernationMode mCurrentHibernationMode = LL_HIBERNATE_MODE_DEFAULT; - boost::signals2::scoped_connection mOSHibernationModeChangeConnection; }; // Globals with external linkage. From viewer.h diff --git a/indra/newview/llappviewersdl.cpp b/indra/newview/llappviewersdl.cpp index b9e60afe1e2..d938ae6cbfe 100644 --- a/indra/newview/llappviewersdl.cpp +++ b/indra/newview/llappviewersdl.cpp @@ -98,82 +98,6 @@ static void handleUrl(const char* url_utf8); extern void default_unix_signal_handler(int, siginfo_t *, void *); #endif -#if LL_DARWIN -#include -static IOPMAssertionID sPowerAssertionID = kIOPMNullAssertionID; -#elif LL_LINUX && LL_DBUS -// Held open for as long as system sleep is inhibited; -1 when it is not. -static int sSleepInhibitFd = -1; - -// org.freedesktop.login1.Manager.Inhibit returns a file descriptor that keeps -// the inhibition alive until it is closed. -// -// "idle" rather than "sleep": we want to stop the machine idling to sleep -// under the viewer, which is what Windows' ES_SYSTEM_REQUIRED and macOS' -// NoIdleSleep do. Blocking "sleep" would also refuse a lid close or an -// explicit suspend, which no other platform here does. -static int take_logind_idle_inhibitor() -{ - DBusError err; - dbus_error_init(&err); - - DBusConnection* bus = dbus_bus_get(DBUS_BUS_SYSTEM, &err); - if (!bus) - { - LL_WARNS("OS") << "Cannot connect to system bus: " << err.message << LL_ENDL; - dbus_error_free(&err); - return -1; - } - - // We pump nothing on this connection and must not die with it. - dbus_connection_set_exit_on_disconnect(bus, false); - - DBusMessage* message = dbus_message_new_method_call("org.freedesktop.login1", - "/org/freedesktop/login1", - "org.freedesktop.login1.Manager", - "Inhibit"); - if (!message) - { - dbus_connection_unref(bus); - return -1; - } - - const char* what = "idle"; - const char* who = "Alchemy Viewer"; - const char* why = "Viewer is running"; - const char* how = "block"; - dbus_message_append_args(message, - DBUS_TYPE_STRING, &what, - DBUS_TYPE_STRING, &who, - DBUS_TYPE_STRING, &why, - DBUS_TYPE_STRING, &how, - DBUS_TYPE_INVALID); - - DBusMessage* reply = dbus_connection_send_with_reply_and_block(bus, message, 2000, &err); - dbus_message_unref(message); - - if (!reply) - { - LL_WARNS("OS") << "logind Inhibit failed: " << err.message << LL_ENDL; - dbus_error_free(&err); - dbus_connection_unref(bus); - return -1; - } - - int fd = -1; - if (!dbus_message_get_args(reply, &err, DBUS_TYPE_UNIX_FD, &fd, DBUS_TYPE_INVALID)) - { - LL_WARNS("OS") << "logind Inhibit returned no fd: " << err.message << LL_ENDL; - dbus_error_free(&err); - fd = -1; - } - dbus_message_unref(reply); - dbus_connection_unref(bus); - - return fd; -} -#endif - namespace { int gArgC = 0; @@ -890,70 +814,6 @@ bool LLAppViewerSDL::sendURLToOtherInstance(const std::string& url) } #endif // LL_DBUS -//virtual -void LLAppViewerSDL::setOSHibernationMode(eHibernationMode mode) -{ - // Deliberately no SDL_{Disable,Enable}ScreenSaver here. That flag is a - // single absolute global which llwindowsdl.cpp already drives from window - // focus, so writing it from here as well would have the two stomp each - // other. The screen half is handled by the platform assertion below. - -#if LL_DARWIN - // An IOPMAssertion is released by name, so drop the previous one before - // taking a new one - otherwise switching modes leaks the old assertion and - // the strongest one ever taken wins forever. - if (sPowerAssertionID != kIOPMNullAssertionID) - { - IOPMAssertionRelease(sPowerAssertionID); - sPowerAssertionID = kIOPMNullAssertionID; - } - - if (mode != LL_HIBERNATE_MODE_DEFAULT) - { - // NoDisplaySleep implies NoIdleSleep, so the screen mode needs only - // the one assertion. - CFStringRef type = (mode == LL_HIBERNATE_MODE_PREVENT_SCREEN) - ? kIOPMAssertionTypeNoDisplaySleep - : kIOPMAssertionTypeNoIdleSleep; - IOReturn result = IOPMAssertionCreateWithName(type, kIOPMAssertionLevelOn, - CFSTR("Alchemy Viewer"), - &sPowerAssertionID); - if (result != kIOReturnSuccess) - { - sPowerAssertionID = kIOPMNullAssertionID; - LL_WARNS("OS") << "IOPMAssertionCreateWithName failed: " << result << LL_ENDL; - } - } -#elif LL_LINUX && LL_DBUS - // logind hands back a file descriptor; holding it open is the inhibition, - // closing it releases it. Drop any previous one first, same reason as the - // macOS assertion above. - if (sSleepInhibitFd >= 0) - { - close(sSleepInhibitFd); - sSleepInhibitFd = -1; - } - - if (mode != LL_HIBERNATE_MODE_DEFAULT) - { - sSleepInhibitFd = take_logind_idle_inhibitor(); - if (sSleepInhibitFd < 0) - { - LL_WARNS("OS") << "Could not inhibit idle sleep via logind." << LL_ENDL; - } - else if (mode == LL_HIBERNATE_MODE_PREVENT_SCREEN) - { - // Keeping the display awake is the session screensaver's business, - // not logind's, and this backend does not speak to it yet. - LL_INFOS("OS") << "Idle sleep inhibited; keeping the display awake " - "is not implemented on this platform." << LL_ENDL; - } - } -#endif - - LL_INFOS("OS") << "OS hibernation mode set to " << (S32)mode << LL_ENDL; -} - bool LLAppViewerSDL::beingDebugged() { #if LL_WINDOWS diff --git a/indra/newview/llappviewersdl.h b/indra/newview/llappviewersdl.h index feeb70dc94f..ce92614e22f 100644 --- a/indra/newview/llappviewersdl.h +++ b/indra/newview/llappviewersdl.h @@ -58,7 +58,6 @@ class LLAppViewerSDL final : public LLAppViewer bool initSLURLHandler() override; bool sendURLToOtherInstance(const std::string& url) override; - void setOSHibernationMode(eHibernationMode mode) override; }; #endif // LL_LLAPPVIEWERSDL_H diff --git a/indra/newview/llappviewerwin32.cpp b/indra/newview/llappviewerwin32.cpp index b0a6842af4c..044570a8743 100644 --- a/indra/newview/llappviewerwin32.cpp +++ b/indra/newview/llappviewerwin32.cpp @@ -723,56 +723,6 @@ bool LLAppViewerWin32::sendURLToOtherInstance(const std::string& url) return false; } -void LLAppViewerWin32::setOSHibernationMode(eHibernationMode mode) -{ - // ES_CONTINUOUS tells Windows to reset the idle timer - // and restore normal operation - // ES_SYSTEM_REQUIRED prevents system sleep/hibernation - // ES_DISPLAY_REQUIRED prevents display sleep - - if (mode == LL_HIBERNATE_MODE_DEFAULT) - { - // Allow OS to hibernate - clear the previous execution state flags - // ES_CONTINUOUS without other flags allows the system to idle normally - SetThreadExecutionState(ES_CONTINUOUS); - LL_INFOS("OS") << "Permitted OS hibernation/sleep" << LL_ENDL; - } - else if (mode == LL_HIBERNATE_MODE_PREVENT) - { - // Prevent OS from hibernating while viewer is running - // ES_CONTINUOUS | ES_SYSTEM_REQUIRED keeps the system awake - EXECUTION_STATE result = SetThreadExecutionState( - ES_CONTINUOUS | ES_SYSTEM_REQUIRED - ); - if (result == NULL) - { - LL_WARNS("OS") << "Failed to prevent OS hibernation, error: " << GetLastError() << LL_ENDL; - } - else - { - LL_INFOS("OS") << "Prevented OS hibernation, but allowed display sleep" << LL_ENDL; - } - } - else if (mode == LL_HIBERNATE_MODE_PREVENT_SCREEN) - { - // Prevent OS from hibernating or turning screen off while viewer is running - // ES_CONTINUOUS | ES_SYSTEM_REQUIRED keeps the system awake - // ES_DISPLAY_REQUIRED keeps the display on - EXECUTION_STATE result = SetThreadExecutionState( - ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED - ); - - if (result == NULL) - { - LL_WARNS("OS") << "Failed to prevent OS hibernation and display sleep, error: " << GetLastError() << LL_ENDL; - } - else - { - LL_INFOS("OS") << "Prevented OS hibernation/sleep" << LL_ENDL; - } - } -} - bool LLAppViewerWin32::sendShutdownToOtherInstances(const std::wstring& install_dir) { // Velopack installs viewer like this: diff --git a/indra/newview/llappviewerwin32.h b/indra/newview/llappviewerwin32.h index 28689e6eff7..662afcecb54 100644 --- a/indra/newview/llappviewerwin32.h +++ b/indra/newview/llappviewerwin32.h @@ -63,7 +63,6 @@ class LLAppViewerWin32 : public LLAppViewer bool restoreErrorTrap() override; bool sendURLToOtherInstance(const std::string& url) override; - void setOSHibernationMode(eHibernationMode mode) override; std::string generateSerialNumber(); diff --git a/indra/newview/llcolorswatch.cpp b/indra/newview/llcolorswatch.cpp index e98f76b49a7..acec4a18ef6 100644 --- a/indra/newview/llcolorswatch.cpp +++ b/indra/newview/llcolorswatch.cpp @@ -404,6 +404,17 @@ void LLColorSwatchCtrl::setValid(bool valid ) } void LLColorSwatchCtrl::showPicker(bool take_focus) +{ + // Both ways in -- the click and the space bar -- come through here, so + // this is the one place an owner can take the picking over. + if (mPickerOverride && mPickerOverride(this)) + { + return; + } + showClassicPicker(take_focus); +} + +void LLColorSwatchCtrl::showClassicPicker(bool take_focus) { LLFloaterColorPicker* pickerp = (LLFloaterColorPicker*)mPickerHandle.get(); if (!pickerp) diff --git a/indra/newview/llcolorswatch.h b/indra/newview/llcolorswatch.h index 841d8140783..517accbd0e9 100644 --- a/indra/newview/llcolorswatch.h +++ b/indra/newview/llcolorswatch.h @@ -32,6 +32,8 @@ #include "llfloater.h" #include "lltextbox.h" +#include + // // Classes // @@ -95,6 +97,16 @@ class LLColorSwatchCtrl final void showPicker(bool take_focus); + /// Hand the picking to someone else. When set, opening the picker -- a + /// click, or the space bar -- asks this first, and the colour picker + /// floater opens only if it answers false. Unset, the swatch behaves + /// exactly as it always has. + typedef std::function picker_override_t; + void setPickerOverride(picker_override_t cb) { mPickerOverride = std::move(cb); } + /// The colour picker floater itself, whatever the override would say: + /// for an override that offers the full picker as a way on. + void showClassicPicker(bool take_focus); + /*virtual*/ bool handleMouseDown(S32 x, S32 y, MASK mask) override; /*virtual*/ bool handleMouseUp(S32 x, S32 y, MASK mask) override; /*virtual*/ bool handleDoubleClick(S32 x,S32 y,MASK mask) override; @@ -120,6 +132,7 @@ class LLColorSwatchCtrl final commit_callback_t mOnCancelCallback, mOnSelectCallback; commit_callback_t mPreviewCallback; + picker_override_t mPickerOverride; S32 mLabelWidth, mLabelHeight; diff --git a/indra/newview/llfloateraddpaymentmethod.cpp b/indra/newview/llfloateraddpaymentmethod.cpp deleted file mode 100644 index 64bd7b6aa10..00000000000 --- a/indra/newview/llfloateraddpaymentmethod.cpp +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @file llfloateraddpaymentmethod.cpp - * @brief LLFloaterAddPaymentMethod class implementation - * - * $LicenseInfo:firstyear=2020&license=viewerlgpl$ - * Second Life Viewer Source Code - * Copyright (C) 2020, Linden Research, Inc. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; - * version 2.1 of the License only. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA - * $/LicenseInfo$ - */ - -#include "llviewerprecompiledheaders.h" - -#include "llfloateraddpaymentmethod.h" -#include "llnotificationsutil.h" -#include "lluictrlfactory.h" -#include "llviewernetwork.h" -#include "llweb.h" - - -LLFloaterAddPaymentMethod::LLFloaterAddPaymentMethod(const LLSD& key) - : LLFloater(key) -{ -} - -LLFloaterAddPaymentMethod::~LLFloaterAddPaymentMethod() -{ -} - -bool LLFloaterAddPaymentMethod::postBuild() -{ - setCanDrag(false); - getChild("continue_btn")->setCommitCallback(boost::bind(&LLFloaterAddPaymentMethod::onContinueBtn, this)); - getChild("close_btn")->setCommitCallback(boost::bind(&LLFloaterAddPaymentMethod::onCloseBtn, this)); - return true; -} - -void LLFloaterAddPaymentMethod::onOpen(const LLSD& key) -{ - centerOnScreen(); -} - -void LLFloaterAddPaymentMethod::onContinueBtn() -{ - closeFloater(); - LLNotificationsUtil::add("AddPaymentMethod", LLSD(), LLSD(), - [this](const LLSD¬if, const LLSD&resp) - { - S32 opt = LLNotificationsUtil::getSelectedOption(notif, resp); - if (opt == 0) - { - const std::string& grid_id = LLGridManager::getInstance()->getGridId(); - const std::string& grid_id_lower = utf8str_tolower(grid_id); - LLWeb::loadURL(getString(grid_id_lower == "damballah" ? "continue_staging_url" : "continue_url")); - } - }); -} - -void LLFloaterAddPaymentMethod::onCloseBtn() -{ - closeFloater(); -} - -void LLFloaterAddPaymentMethod::centerOnScreen() -{ - LLVector2 window_size = LLUI::getInstance()->getWindowSize(); - centerWithin(LLRect(0, 0, ll_round(window_size.mV[VX]), ll_round(window_size.mV[VY]))); -} - diff --git a/indra/newview/llfloateraddpaymentmethod.h b/indra/newview/llfloateraddpaymentmethod.h deleted file mode 100644 index 38a43597dee..00000000000 --- a/indra/newview/llfloateraddpaymentmethod.h +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @file llfloateraddpaymentmethod.h - * @brief LLFloaterAddPaymentMethod class definition - * - * $LicenseInfo:firstyear=2020&license=viewerlgpl$ - * Second Life Viewer Source Code - * Copyright (C) 2020, Linden Research, Inc. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; - * version 2.1 of the License only. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - * - * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA - * $/LicenseInfo$ - */ - -#ifndef LL_FLOATER_ADDPAYMENTMETHOD_H -#define LL_FLOATER_ADDPAYMENTMETHOD_H - -#include "llfloater.h" - -class LLFloaterAddPaymentMethod final: - public LLFloater -{ - friend class LLFloaterReg; -public: - AL_VIEW_TYPE(LLFloaterAddPaymentMethod, LLFloater); - - bool postBuild() override; - void onOpen(const LLSD& key) override; - -private: - LLFloaterAddPaymentMethod(const LLSD& key); - - void centerOnScreen(); - - void onCloseBtn(); - void onContinueBtn(); - - /*virtual*/ ~LLFloaterAddPaymentMethod(); - -}; - -#endif diff --git a/indra/newview/llfloaterbuycurrency.cpp b/indra/newview/llfloaterbuycurrency.cpp index 45b9ac3b17b..7fba25ef0a4 100644 --- a/indra/newview/llfloaterbuycurrency.cpp +++ b/indra/newview/llfloaterbuycurrency.cpp @@ -40,6 +40,8 @@ #include "llviewchildren.h" #include "llviewerwindow.h" #include "lluictrlfactory.h" +#include "llviewercontrol.h" +#include "llviewernetwork.h" #include "llweb.h" #include "llwindow.h" #include "llappviewer.h" @@ -338,7 +340,10 @@ void LLFloaterBuyCurrency::handleBuyCurrency(bool has_piof, bool has_target, con } else { - LLFloaterReg::showInstance("add_payment_method"); + const std::string& grid_id = LLGridManager::getInstance()->getGridId(); + const std::string& grid_id_lower = utf8str_tolower(grid_id); + const std::string url = gSavedSettings.getString(grid_id_lower == "damballah" ? "PaymentMethodStagingURL" : "PaymentMethodURL"); + LLWeb::loadURL(url); } } diff --git a/indra/newview/llfloatersettingscolor.cpp b/indra/newview/llfloatersettingscolor.cpp index d9c382a1dc9..fc8bad4c618 100644 --- a/indra/newview/llfloatersettingscolor.cpp +++ b/indra/newview/llfloatersettingscolor.cpp @@ -29,306 +29,39 @@ #include "llfloatersettingscolor.h" -#include "llfloater.h" -#include "llfiltereditor.h" -#include "lluictrlfactory.h" -#include "llcombobox.h" -#include "llspinctrl.h" -#include "llcolorswatch.h" -#include "llviewercontrol.h" -#include "lltexteditor.h" - +#include "alcolortablepanel.h" +#include "lluicolortable.h" LLFloaterSettingsColor::LLFloaterSettingsColor(const LLSD& key) -: LLFloater(key), - mSettingList(NULL) +: LLFloater(key) { - mCommitCallbackRegistrar.add("CommitSettings", boost::bind(&LLFloaterSettingsColor::onCommitSettings, this)); - mCommitCallbackRegistrar.add("ClickDefault", boost::bind(&LLFloaterSettingsColor::onClickDefault, this)); } LLFloaterSettingsColor::~LLFloaterSettingsColor() -{} - -bool LLFloaterSettingsColor::postBuild() -{ - enableResizeCtrls(true, false, true); - - mAlphaSpinner = getChild("alpha_spinner"); - mColorSwatch = getChild("color_swatch"); - - mDefaultButton = getChild("default_btn"); - mSettingNameText = getChild("color_name_txt"); - - getChild("filter_input")->setCommitCallback(boost::bind(&LLFloaterSettingsColor::setSearchFilter, this, _2)); - - mSettingList = getChild("setting_list"); - mSettingList->setCommitOnSelectionChange(true); - mSettingList->setCommitCallback(boost::bind(&LLFloaterSettingsColor::onSettingSelect, this)); - - updateList(); - - gSavedSettings.getControl("ColorSettingsHideDefault")->getCommitSignal()->connect(boost::bind(&LLFloaterSettingsColor::updateList, this, false)); - - return LLFloater::postBuild(); -} - -void LLFloaterSettingsColor::draw() -{ - LLScrollListItem* first_selected = mSettingList->getFirstSelected(); - if (first_selected) - { - if(auto cell = first_selected->getColumn(1)) - { - updateControl(cell->getValue().asString()); - } - } - - LLFloater::draw(); -} - -void LLFloaterSettingsColor::onCommitSettings() -{ - LLScrollListItem* first_selected = mSettingList->getFirstSelected(); - if (!first_selected) - { - return; - } - auto cell = first_selected->getColumn(1); - - if (!cell) - { - return; - } - - auto color_name = cell->getValue().asString(); - if (color_name.empty()) - { - return; - } - - LLColor4 col4; - LLColor3 col3; - col3.setValue(mColorSwatch->getValue()); - col4 = LLColor4(col3, (F32)mAlphaSpinner->getValue().asReal()); - LLUIColorTable::instance().setColor(color_name, col4); - - updateDefaultColumn(color_name); -} - -// static -void LLFloaterSettingsColor::onClickDefault() { - LLScrollListItem* first_selected = mSettingList->getFirstSelected(); - if (first_selected) - { - auto cell = first_selected->getColumn(1); - if (cell) - { - auto name = cell->getValue().asString(); - LLUIColorTable::instance().resetToDefault(name); - updateDefaultColumn(name); - updateControl(name); - } - } } -// we've switched controls, or doing per-frame update, so update spinners, etc. -void LLFloaterSettingsColor::updateControl(const std::string& color_name) +void LLFloaterSettingsColor::onClose(bool app_quitting) { - hideUIControls(); - - if (!isSettingHidden(color_name)) + // The viewer writes them itself on the way out. + if (!app_quitting) { - mDefaultButton->setVisible(true); - mSettingNameText->setVisible(true); - mSettingNameText->setText(color_name); - mSettingNameText->setToolTip(color_name); - - LLColor4 clr = LLUIColorTable::instance().getColor(color_name); - mColorSwatch->setVisible(true); - // only set if changed so color picker doesn't update - if (clr != LLColor4(mColorSwatch->getValue())) - { - mColorSwatch->setOriginal(clr); - } - mAlphaSpinner->setVisible(true); - mAlphaSpinner->setLabel(std::string("Alpha")); - if (!mAlphaSpinner->hasFocus()) - { - mAlphaSpinner->setPrecision(3); - mAlphaSpinner->setMinValue(0.0); - mAlphaSpinner->setMaxValue(1.f); - mAlphaSpinner->setValue(clr.mV[VALPHA]); - } + LLUIColorTable::instance().saveUserSettings(); } - } -void LLFloaterSettingsColor::updateList(bool skip_selection) -{ - std::string last_selected; - LLScrollListItem* item = mSettingList->getFirstSelected(); - if (item) - { - LLScrollListCell* cell = item->getColumn(1); - if (cell) - { - last_selected = cell->getValue().asString(); - } - } - - mSettingList->deleteAllItems(); - - const auto& base_colors = LLUIColorTable::instance().getLoadedColors(); - for (const auto& pair : base_colors) - { - const auto& name = pair.first; - if (matchesSearchFilter(name) && !isSettingHidden(name)) - { - LLSD row; - - row["columns"][0]["column"] = "changed_color"; - row["columns"][0]["value"] = LLUIColorTable::instance().isDefault(name) ? "" : "*"; - - row["columns"][1]["column"] = "color"; - row["columns"][1]["value"] = name; - - LLScrollListItem* item = mSettingList->addElement(row, ADD_BOTTOM, nullptr); - if (!mSearchFilter.empty() && (last_selected == name) && !skip_selection) - { - std::string lower_name(name); - LLStringUtil::toLower(lower_name); - if (LLStringUtil::startsWith(lower_name, mSearchFilter)) - { - item->setSelected(true); - } - } - } - } - - for (const auto& pair : LLUIColorTable::instance().getUserColors()) - { - const auto& name = pair.first; - if (base_colors.find(name) == base_colors.end() && matchesSearchFilter(name) && !isSettingHidden(name)) - { - LLSD row; - - row["columns"][0]["column"] = "changed_color"; - row["columns"][0]["value"] = LLUIColorTable::instance().isDefault(name) ? "" : "*"; - - row["columns"][1]["column"] = "color"; - row["columns"][1]["value"] = name; - - LLScrollListItem* item = mSettingList->addElement(row, ADD_BOTTOM, nullptr); - if (!mSearchFilter.empty() && (last_selected == name) && !skip_selection) - { - std::string lower_name(name); - LLStringUtil::toLower(lower_name); - if (LLStringUtil::startsWith(lower_name, mSearchFilter)) - { - item->setSelected(true); - } - } - } - } - - mSettingList->updateSort(); - - if (!mSettingList->isEmpty()) - { - if (mSettingList->hasSelectedItem()) - { - mSettingList->scrollToShowSelected(); - } - else if (!mSettingList->hasSelectedItem() && !mSearchFilter.empty() && !skip_selection) - { - if (!mSettingList->selectItemByPrefix(mSearchFilter, false, 1)) - { - mSettingList->selectFirstItem(); - } - mSettingList->scrollToShowSelected(); - } - } - else - { - LLSD row; - - row["columns"][0]["column"] = "changed_color"; - row["columns"][0]["value"] = ""; - row["columns"][1]["column"] = "color"; - row["columns"][1]["value"] = "No matching colors."; - - mSettingList->addElement(row); - hideUIControls(); - } -} - -void LLFloaterSettingsColor::onSettingSelect() -{ - LLScrollListItem* first_selected = mSettingList->getFirstSelected(); - if (first_selected) - { - auto cell = first_selected->getColumn(1); - if (cell) - { - updateControl(cell->getValue().asString()); - } - } -} - -void LLFloaterSettingsColor::setSearchFilter(const std::string& filter) -{ - if(mSearchFilter == filter) - return; - mSearchFilter = filter; - LLStringUtil::toLower(mSearchFilter); - updateList(); -} - -bool LLFloaterSettingsColor::matchesSearchFilter(std::string setting_name) +bool LLFloaterSettingsColor::postBuild() { - // If the search filter is empty, everything passes. - if (mSearchFilter.empty()) return true; - - LLStringUtil::toLower(setting_name); - std::string::size_type match_name = setting_name.find(mSearchFilter); - - return (std::string::npos != match_name); -} + enableResizeCtrls(true, false, true); + mColors = getChild("colors"); -bool LLFloaterSettingsColor::isSettingHidden(const std::string& color_name) -{ - static LLCachedControl hide_default(gSavedSettings, "ColorSettingsHideDefault", false); - return hide_default && LLUIColorTable::instance().isDefault(color_name); + return LLFloater::postBuild(); } -void LLFloaterSettingsColor::updateDefaultColumn(const std::string& color_name) +void LLFloaterSettingsColor::onOpen(const LLSD& key) { - if (isSettingHidden(color_name)) - { - hideUIControls(); - updateList(true); - return; - } - - LLScrollListItem* item = mSettingList->getFirstSelected(); - if (item) + if (key.isMap() && key.has("name") && mColors) { - LLScrollListCell* cell = item->getColumn(0); - if (cell) - { - std::string is_default = LLUIColorTable::instance().isDefault(color_name) ? "" : "*"; - cell->setValue(is_default); - } + mColors->showColor(key["name"].asString()); } } - -void LLFloaterSettingsColor::hideUIControls() -{ - mColorSwatch->setVisible(false); - mAlphaSpinner->setVisible(false); - mDefaultButton->setVisible(false); - mSettingNameText->setVisible(false); -} - diff --git a/indra/newview/llfloatersettingscolor.h b/indra/newview/llfloatersettingscolor.h index 94a791ce579..4d4cdb426d3 100644 --- a/indra/newview/llfloatersettingscolor.h +++ b/indra/newview/llfloatersettingscolor.h @@ -28,14 +28,12 @@ #ifndef LLFLOATERCOLORSETTINGS_H #define LLFLOATERCOLORSETTINGS_H -#include "llcontrol.h" #include "llfloater.h" -class LLColorSwatchCtrl; -class LLScrollListCtrl; -class LLSpinCtrl; -class LLTextBox; +class ALColorTablePanel; +// The colour table in a window of its own. Opened with a name, it is +// opened on that colour. class LLFloaterSettingsColor final : public LLFloater { @@ -45,38 +43,15 @@ class LLFloaterSettingsColor final AL_VIEW_TYPE(LLFloaterSettingsColor, LLFloater); bool postBuild() override; - void draw() override; - - void updateControl(const std::string& color_name); - - void onCommitSettings(); - void onClickDefault(); - - bool matchesSearchFilter(std::string setting_name); - bool isSettingHidden(const std::string& color_name); + void onOpen(const LLSD& key) override; + // What was changed here is kept. + void onClose(bool app_quitting) override; private: LLFloaterSettingsColor(const LLSD& key); virtual ~LLFloaterSettingsColor(); - void updateList(bool skip_selection = false); - void onSettingSelect(); - void setSearchFilter(const std::string& filter); - - void updateDefaultColumn(const std::string& color_name); - void hideUIControls(); - - LLScrollListCtrl* mSettingList; - -protected: - LLUICtrl* mDefaultButton = nullptr; - LLTextBox* mSettingNameText = nullptr; - - LLSpinCtrl* mAlphaSpinner = nullptr; - LLColorSwatchCtrl* mColorSwatch = nullptr; - - std::string mSearchFilter; + ALColorTablePanel* mColors = nullptr; }; #endif //LLFLOATERCOLORSETTINGS_H - diff --git a/indra/newview/llpresetsmanager.cpp b/indra/newview/llpresetsmanager.cpp index 320330098c1..90a19ab6c25 100644 --- a/indra/newview/llpresetsmanager.cpp +++ b/indra/newview/llpresetsmanager.cpp @@ -30,6 +30,7 @@ #include "llviewerprecompiledheaders.h" #include "llpresetsmanager.h" +#include "alcurvemodel.h" #include "lldiriterator.h" #include "llfloater.h" @@ -43,10 +44,73 @@ #include "llagentcamera.h" #include "llfile.h" +#if !LL_RELEASE_FOR_DOWNLOAD +// The Looks whitelist, the settings declarations, and the bundled Look files +// must stay in lockstep by hand, and every consumer fails silent on drift: +// loadLooksPreset writes only keys present in BOTH the whitelist and the +// file, so a bundled Look that falls behind quietly stops resetting the keys +// it lacks -- and "Neutral" stops meaning neutral. Say so loudly at startup +// instead. One parse of a handful of small files, once per session. +static void audit_bundled_looks(const std::vector& whitelist) +{ + const std::string app_dir = gDirUtilp->getExpandedFilename(LL_PATH_APP_SETTINGS, PRESETS_LOOKS); + std::string file; + LLDirIterator look_iter(app_dir, "*.xml"); + while (look_iter.next(file)) + { + llifstream look_stream(gDirUtilp->add(app_dir, file)); + if (!look_stream.is_open()) + { + continue; + } + LLSD look; + LLSDSerialize::fromXML(look, look_stream); + if (!look.isMap()) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' is not a settings map" << LL_ENDL; + continue; + } + for (const std::string& name : whitelist) + { + if (!look.has(name)) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' is missing whitelisted key '" + << name << "'; applying it will leave that setting untouched" << LL_ENDL; + continue; + } + + // Presence is not the only way these files rot. Each Look carries + // a full copy of every setting's Comment, and nothing reads those + // copies -- loadLooksPreset takes only Value -- so a reworded + // description in settings_alchemy.xml leaves three stale duplicates + // behind with no symptom at all until someone diffs them by hand. + const LLControlVariable* ctrl = gSavedSettings.getControl(name).get(); + if (ctrl && look[name].isMap() && look[name].has("Comment") + && look[name]["Comment"].asString() != ctrl->getComment()) + { + LL_WARNS("Presets") << "Bundled Look '" << file << "' has a stale Comment for '" + << name << "'; it no longer matches the setting's own description" + << LL_ENDL; + } + } + } +} +#endif // !LL_RELEASE_FOR_DOWNLOAD + LLPresetsManager::LLPresetsManager() { copyDefaultLooks(); +#if !LL_RELEASE_FOR_DOWNLOAD + // Developer check, not a runtime one: it verifies that files in the source + // tree agree with each other, which a shipped build can do nothing about. + { + std::vector looks_whitelist; + getLooksControlNames(looks_whitelist); + audit_bundled_looks(looks_whitelist); + } +#endif + // Connect preset signals startWatching(PRESETS_GRAPHIC); startWatching(PRESETS_CAMERA); @@ -545,9 +609,11 @@ void LLPresetsManager::getLooksControlNames(std::vector& names) "RenderColorGradeLift", "RenderColorGradeGamma", "RenderColorGradeGain", - "RenderColorGradeCurveToe", - "RenderColorGradeCurveShoulder", - "RenderColorGradeCurveStrength", + ALToneCurveSet::settingName(ALToneCurveSet::CH_MASTER), + ALToneCurveSet::settingName(ALToneCurveSet::CH_RED), + ALToneCurveSet::settingName(ALToneCurveSet::CH_GREEN), + ALToneCurveSet::settingName(ALToneCurveSet::CH_BLUE), + "RenderColorGradeCurveAmount", // Split toning "RenderSplitToneAmount", "RenderSplitToneBalance", @@ -555,6 +621,8 @@ void LLPresetsManager::getLooksControlNames(std::vector& names) "RenderSplitToneHighlightTint", "RenderSplitToneMidtoneTint", "RenderSplitToneMidtoneAmount", + "RenderSplitToneShadowWidth", + "RenderSplitToneHighlightWidth", // HDR bloom aesthetics (not the structural mip/resolution knobs) "RenderBloomStrength", "RenderBloomThreshold", @@ -597,8 +665,8 @@ void LLPresetsManager::getLooksControlNames(std::vector& names) "RenderLensFlareStarburstSpikes", "RenderLensFlareStarburstSharpness", "RenderLensFlareStarburstLength", - "RenderLensFlareOcclusionRadius", - "RenderLensFlareOcclusionTaps", + "RenderLensFlareOcclusionScale", + "RenderLensFlareFadeTime", // Chromatic aberration "RenderChromaticAberrationStrength", "RenderChromaticAberrationFalloff", @@ -608,6 +676,53 @@ void LLPresetsManager::getLooksControlNames(std::vector& names) "RenderChromaticAberrationOffsetRY", "RenderChromaticAberrationOffsetBX", "RenderChromaticAberrationOffsetBY", + // Lens distortion + "RenderLensDistortionAmount", + "RenderLensDistortionK1", + "RenderLensDistortionK2", + "RenderLensDistortionSqueeze", + "RenderLensDistortionFit", + "RenderLensDistortionCenter", + "RenderLensDistortionTangential", + // Bokeh aesthetics. The camera optics themselves (CameraFNumber, + // CameraFocalLength and friends) are deliberately absent from this + // list -- a Look is an aesthetic, not a shot setup -- and + // RenderBokehHighlightClamp stays out for a third reason: it is a + // firefly guard like RenderBloomFireflyClamp, a stability control + // rather than a look. + "RenderBokehHighlightGain", + "RenderBokehHighlightThreshold", + "RenderBokehApertureBlades", + "RenderBokehApertureRotation", + "RenderBokehApertureCurvature", + "RenderBokehAnamorphicSqueeze", + "RenderBokehCatEyeAmount", + "RenderBokehFringeAmount", + "RenderBokehFringeNearTint", + "RenderBokehFringeFarTint", + // Aberrations contributed by the glass rather than the iris + "RenderBokehSphericalAberration", + "RenderBokehFieldStretch", + "RenderBokehFieldFalloff", + "RenderBokehComaAsymmetry", + // Cross-screen filter + "RenderCrossFilterStrength", + "RenderCrossFilterPoints", + "RenderCrossFilterAngle", + "RenderCrossFilterLength", + "RenderCrossFilterFalloff", + "RenderCrossFilterChromatic", + // Lens dirt + "RenderLensDirtStrength", + "RenderLensDirtBloomResponse", + "RenderLensDirtFlareResponse", + "RenderLensDirtGrime", + "RenderLensDirtMoteScale", + "RenderLensDirtSmudge", + "RenderLensDirtScratches", + "RenderLensDirtSeed", + "RenderLensDirtToe", + "RenderLensDirtGain", // Vignette "RenderVignetteAmount", "RenderVignetteCenter", @@ -925,26 +1040,59 @@ bool LLPresetsManager::loadLooksPreset(std::string name) std::vector allowed; getLooksControlNames(allowed); - S32 applied = 0; - mIgnoreChangedSignal = true; - for (const std::string& ctrl_name : allowed) + auto carries = [¶ms](const std::string& ctrl_name) { if (!params.has(ctrl_name)) { - continue; + return false; } const LLSD& entry = params[ctrl_name]; - if (!entry.isMap() || !entry.has("Value")) + return entry.isMap() && entry.has("Value"); + }; + + S32 applied = 0; + mIgnoreChangedSignal = true; + for (const std::string& ctrl_name : allowed) + { + if (!carries(ctrl_name)) { continue; } LLControlVariable* ctrl = gSavedSettings.getControl(ctrl_name).get(); if (ctrl) { - ctrl->set(entry["Value"]); + ctrl->set(params[ctrl_name]["Value"]); ++applied; } } + + // A Look is a full snapshot of the whitelist, so a whitelisted key the + // file does not mention was saved before that key existed and the Look + // never had it: apply its default rather than leave whatever the previous + // grade set, or a seeded copy of Neutral that predates a setting keeps it + // and stops meaning neutral. Three fences keep that from doing damage. + // Only a file carrying at least half the whitelist counts as a snapshot, + // so a truncated or hand-trimmed file applies what it has and touches + // nothing else. A key that is present but malformed is skipped, as it + // always was, rather than reset. And the master switch is left alone: a + // file that omits it was edited by hand, and its other keys are evidently + // meant to be seen. resetToDefault fires only on a real change. + const bool snapshot = applied * 2 >= static_cast(allowed.size()); + if (snapshot) + { + for (const std::string& ctrl_name : allowed) + { + if (params.has(ctrl_name) || ctrl_name == "RenderColorGrade") + { + continue; + } + LLControlVariable* ctrl = gSavedSettings.getControl(ctrl_name).get(); + if (ctrl) + { + ctrl->resetToDefault(true); + } + } + } mIgnoreChangedSignal = false; if (applied == 0) diff --git a/indra/newview/llspeakingindicatormanager.cpp b/indra/newview/llspeakingindicatormanager.cpp index 06458a9f3ce..01736147a0f 100644 --- a/indra/newview/llspeakingindicatormanager.cpp +++ b/indra/newview/llspeakingindicatormanager.cpp @@ -165,9 +165,10 @@ void SpeakingIndicatorManager::registerSpeakingIndicator(const LLUUID& speaker_i void SpeakingIndicatorManager::unregisterSpeakingIndicator(const LLUUID& speaker_id, const LLSpeakingIndicator* const speaking_indicator) { LL_DEBUGS("SpeakingIndicator") << "Unregistering indicator: " << speaker_id << "|"<< speaking_indicator << LL_ENDL; - speaking_indicators_mmap_t::iterator it; - it = mSpeakingIndicators.find(speaker_id); - for (;it != mSpeakingIndicators.end(); ++it) + // Several indicators can watch one speaker, and multimap::find promises + // only some entry with the key, so walk the whole run under it. + auto [it, end] = mSpeakingIndicators.equal_range(speaker_id); + for (; it != end; ++it) { if (it->second == speaking_indicator) { diff --git a/indra/newview/llviewerfloaterreg.cpp b/indra/newview/llviewerfloaterreg.cpp index 9e01d9abf9d..a3d3bf7a8d7 100644 --- a/indra/newview/llviewerfloaterreg.cpp +++ b/indra/newview/llviewerfloaterreg.cpp @@ -57,7 +57,6 @@ #include "llcompilequeue.h" #include "llfloater360capture.h" #include "llfloaterabout.h" -#include "llfloateraddpaymentmethod.h" #include "llfloaterauction.h" // [SL:KB] - Patch: Build-AssetRecovery | Checked: 2011-11-24 (Catznip-3.2) #include "llfloaterassetrecovery.h" @@ -273,7 +272,6 @@ class LLFloaterOpenHandler : public LLCommandHandler { const std::list blacklist_untrusted = { "360capture", - "add_payment_method", "appearance", "associate_listing", "avatar_picker", @@ -360,7 +358,6 @@ void LLViewerFloaterReg::registerFloaters() LLFloaterAboutUtil::registerFloater(); LLFloaterReg::add("360capture", "floater_360capture.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("about_land", "floater_about_land.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); - LLFloaterReg::add("add_payment_method", "floater_add_payment_method.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("appearance", "floater_my_appearance.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("associate_listing", "floater_associate_listing.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("auction", "floater_auction.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); diff --git a/indra/newview/llviewershadermgr.cpp b/indra/newview/llviewershadermgr.cpp index 5fe0ae518ba..e848234907d 100644 --- a/indra/newview/llviewershadermgr.cpp +++ b/indra/newview/llviewershadermgr.cpp @@ -172,6 +172,8 @@ LLGLSLShader gBloomDownsampleProgram; LLGLSLShader gBloomDownsampleFirstProgram; LLGLSLShader gBloomUpsampleProgram; LLGLSLShader gBloomCompositeProgram; +LLGLSLShader gCrossFilterProgram; +LLGLSLShader gLensDirtGenProgram; // Deferred rendering shaders LLGLSLShader gDeferredImpostorProgram; @@ -220,12 +222,15 @@ LLGLSLShader gDeferredEmissiveProgram; LLGLSLShader gDeferredEmissiveIndexedProgram; // multi-material indexed legacy glow LLGLSLShader gDeferredPostProgram; LLGLSLShader gDeferredPostProgramNoNear; +LLGLSLShader gDeferredPostProgramShaped; +LLGLSLShader gDeferredPostProgramNoNearShaped; LLGLSLShader gDeferredCoFProgram; LLGLSLShader gDeferredDoFCombineProgram; LLGLSLShader gDeferredDoFCombineProgramNoNear; LLGLSLShader gExposureProgram; LLGLSLShader gExposureProgramNoFade; LLGLSLShader gLuminanceProgram; +LLGLSLShader gLensFlareStateProgram; LLGLSLShader gFXAAProgram[4]; LLGLSLShader gSMAAEdgeDetectProgram[4]; LLGLSLShader gSMAABlendWeightsProgram[4]; @@ -1133,6 +1138,8 @@ bool LLViewerShaderMgr::loadShadersEffects() gBloomDownsampleFirstProgram.unload(); gBloomUpsampleProgram.unload(); gBloomCompositeProgram.unload(); + gCrossFilterProgram.unload(); + gLensDirtGenProgram.unload(); return true; } @@ -1220,6 +1227,30 @@ bool LLViewerShaderMgr::loadShadersEffects() success = gBloomUpsampleProgram.createShader(); } + if (success) + { + gCrossFilterProgram.mName = "Cross Screen Filter"; + gCrossFilterProgram.mShaderFiles.clear(); + gCrossFilterProgram.mShaderFiles.push_back(make_pair("effects/glowExtractV.glsl", GL_VERTEX_SHADER)); + gCrossFilterProgram.mShaderFiles.push_back(make_pair("effects/crossFilterF.glsl", GL_FRAGMENT_SHADER)); + gCrossFilterProgram.mShaderLevel = mShaderLevel[SHADER_EFFECT]; + gCrossFilterProgram.clearPermutations(); + gCrossFilterProgram.addPermutation("CROSS_TAPS", std::to_string(CROSS_FILTER_TAPS)); + success = gCrossFilterProgram.createShader(); + } + + if (success) + { + gLensDirtGenProgram.mName = "Lens Dirt Generator"; + gLensDirtGenProgram.mShaderFiles.clear(); + gLensDirtGenProgram.mShaderFiles.push_back(make_pair("effects/glowExtractV.glsl", GL_VERTEX_SHADER)); + gLensDirtGenProgram.mShaderFiles.push_back(make_pair("effects/lensDirtGenF.glsl", GL_FRAGMENT_SHADER)); + gLensDirtGenProgram.mShaderLevel = mShaderLevel[SHADER_EFFECT]; + gLensDirtGenProgram.clearPermutations(); + gLensDirtGenProgram.addPermutation("DIRT_MAX_LINES", std::to_string(LENS_DIRT_MAX_LINES)); + success = gLensDirtGenProgram.createShader(); + } + if (success) { gBloomCompositeProgram.mName = "HDR Bloom Composite"; @@ -1289,12 +1320,16 @@ bool LLViewerShaderMgr::loadShadersDeferred() gDeferredEmissiveProgram.unload(); gDeferredEmissiveIndexedProgram.unload(); gDeferredPostProgram.unload(); + gDeferredPostProgramNoNear.unload(); + gDeferredPostProgramShaped.unload(); + gDeferredPostProgramNoNearShaped.unload(); gDeferredCoFProgram.unload(); gDeferredDoFCombineProgram.unload(); gDeferredDoFCombineProgramNoNear.unload(); gExposureProgram.unload(); gExposureProgramNoFade.unload(); gLuminanceProgram.unload(); + gLensFlareStateProgram.unload(); for (auto i = 0; i < 4; ++i) { @@ -2806,6 +2841,20 @@ bool LLViewerShaderMgr::loadShadersDeferred() llassert(success); } + if (success) + { + // Sun coverage, colour and the filtered flare drive, 2x1, read by the + // colour-correct programs through uLensFlareStateMap. + gLensFlareStateProgram.mName = "Lens Flare State"; + gLensFlareStateProgram.mShaderFiles.clear(); + gLensFlareStateProgram.clearPermutations(); + gLensFlareStateProgram.mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); + gLensFlareStateProgram.mShaderFiles.push_back(make_pair("alchemy/lensFlareStateF.glsl", GL_FRAGMENT_SHADER)); + gLensFlareStateProgram.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; + success = gLensFlareStateProgram.createShader(); + llassert(success); + } + if (success) { gLuminanceProgram.mName = "Luminance"; @@ -3013,32 +3062,45 @@ bool LLViewerShaderMgr::loadShadersDeferred() if (success) { - gDeferredPostProgram.mName = "Deferred Post Shader"; - gDeferredPostProgram.mFeatures.isDeferred = true; - gDeferredPostProgram.mShaderFiles.clear(); - gDeferredPostProgram.mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); - gDeferredPostProgram.mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); - gDeferredPostProgram.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; - gDeferredPostProgram.clearPermutations(); - gDeferredPostProgram.addPermutation("FRONT_BLUR", "1"); - - success = gDeferredPostProgram.createShader(); - llassert(success); - } + // Four DoF gather variants across two orthogonal axes. Both are value + // tests in the shader, so the "off" build defines the symbol as "0" + // rather than omitting it -- omitting it would make #if FRONT_BLUR a + // compile error rather than a false branch. + struct PostVariant + { + LLGLSLShader* shader; + const char* name; + const char* front_blur; + const char* dof_shaped; + }; - if (success) - { - gDeferredPostProgramNoNear.mName = "Deferred Post Shader No Near Blur"; - gDeferredPostProgramNoNear.mFeatures.isDeferred = true; - gDeferredPostProgramNoNear.mShaderFiles.clear(); - gDeferredPostProgramNoNear.mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); - gDeferredPostProgramNoNear.mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); - gDeferredPostProgramNoNear.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; - gDeferredPostProgramNoNear.clearPermutations(); - gDeferredPostProgramNoNear.addPermutation("FRONT_BLUR", "0"); + const PostVariant post_variants[] = + { + { &gDeferredPostProgram, "Deferred Post Shader", "1", "0" }, + { &gDeferredPostProgramNoNear, "Deferred Post Shader No Near Blur", "0", "0" }, + { &gDeferredPostProgramShaped, "Deferred Post Shader Shaped", "1", "1" }, + { &gDeferredPostProgramNoNearShaped, "Deferred Post Shader No Near Blur Shaped", "0", "1" }, + }; - success = gDeferredPostProgramNoNear.createShader(); - llassert(success); + for (const PostVariant& variant : post_variants) + { + variant.shader->mName = variant.name; + variant.shader->mFeatures.isDeferred = true; + variant.shader->mShaderFiles.clear(); + variant.shader->mShaderFiles.push_back(make_pair("deferred/postDeferredNoTCV.glsl", GL_VERTEX_SHADER)); + variant.shader->mShaderFiles.push_back(make_pair("deferred/postDeferredF.glsl", GL_FRAGMENT_SHADER)); + variant.shader->mShaderLevel = mShaderLevel[SHADER_DEFERRED]; + variant.shader->clearPermutations(); + variant.shader->addPermutation("FRONT_BLUR", variant.front_blur); + variant.shader->addPermutation("DOF_SHAPED", variant.dof_shaped); + + success = variant.shader->createShader(); + llassert(success); + if (!success) + { + break; + } + } } if (success) diff --git a/indra/newview/llviewershadermgr.h b/indra/newview/llviewershadermgr.h index c32b8c8b36b..13ac975ff97 100644 --- a/indra/newview/llviewershadermgr.h +++ b/indra/newview/llviewershadermgr.h @@ -142,6 +142,21 @@ extern LLGLSLShader gBloomDownsampleProgram; extern LLGLSLShader gBloomDownsampleFirstProgram; extern LLGLSLShader gBloomUpsampleProgram; extern LLGLSLShader gBloomCompositeProgram; +// Taps per cross-filter pass. Injected into crossFilterF.glsl as CROSS_TAPS +// and used by pipeline.cpp for the pass strides and the falloff remap, so the +// chain's exact base-N tiling has one source of truth instead of three sites +// that each hard-coded 4 or 63. +constexpr S32 CROSS_FILTER_TAPS = 4; +// Shared by the generation gate and the composite, which read the same setting +// in two different files and must agree, or the effect changes brightness +// between "is it on" and "how bright". +constexpr F32 CROSS_FILTER_MAX_STRENGTH = 32.f; +extern LLGLSLShader gCrossFilterProgram; +// Upper bound on the generator's segment loops. Injected into lensDirtGenF as +// DIRT_MAX_LINES and used by pipeline.cpp to clamp the scratch count, so the +// loop bound and the CPU clamp cannot drift apart. +constexpr S32 LENS_DIRT_MAX_LINES = 32; +extern LLGLSLShader gLensDirtGenProgram; //interface shaders extern LLGLSLShader gHighlightProgram; @@ -187,8 +202,14 @@ extern LLGLSLShader gDeferredShadowGLTFAlphaMaskIndexedProgram; // multi extern LLGLSLShader gDeferredShadowMaterialIndexedProgram; // multi-material indexed legacy mask shadow extern LLGLSLShader gDeferredShadowGLTFAlphaBlendProgram; extern LLGLSLShader gDeferredShadowFullbrightAlphaMaskProgram; +// DoF gather blur, four variants over two orthogonal compile-time axes: +// FRONT_BLUR (RenderDepthOfFieldNearBlur) and DOF_SHAPED (any of the shaped +// aperture, cat's-eye or defocus fringe being active). The shaped code sits in +// the innermost sample loop, so it is compiled out rather than branched over. extern LLGLSLShader gDeferredPostProgram; extern LLGLSLShader gDeferredPostProgramNoNear; +extern LLGLSLShader gDeferredPostProgramShaped; +extern LLGLSLShader gDeferredPostProgramNoNearShaped; extern LLGLSLShader gDeferredCoFProgram; extern LLGLSLShader gDeferredDoFCombineProgram; extern LLGLSLShader gDeferredDoFCombineProgramNoNear; @@ -201,6 +222,7 @@ extern LLGLSLShader gDeferredPostNoDoFProgram; extern LLGLSLShader gExposureProgram; extern LLGLSLShader gExposureProgramNoFade; extern LLGLSLShader gLuminanceProgram; +extern LLGLSLShader gLensFlareStateProgram; extern LLGLSLShader gDeferredAvatarShadowProgram; extern LLGLSLShader gDeferredAvatarAlphaShadowProgram; extern LLGLSLShader gDeferredAvatarAlphaMaskShadowProgram; diff --git a/indra/newview/llviewertexturelist.h b/indra/newview/llviewertexturelist.h index 5cfd666ecb2..4d530f0c58b 100644 --- a/indra/newview/llviewertexturelist.h +++ b/indra/newview/llviewertexturelist.h @@ -272,6 +272,9 @@ class LLUIImageList : public LLImageProviderInterface, public LLSingleton preloadUIImage(const std::string& name, const std::string& filename, bool use_mips, const LLRect& scale_rect, const LLRect& clip_rect, LLUIImage::EScaleStyle stype); diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 34a89078625..1394ec4cc3b 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -1755,30 +1755,12 @@ void LLViewerWindow::handleCloseRequestCanceled() void LLViewerWindow::handleSuspendRequest() { - static LLCachedControl os_hibernation_mode(gSavedSettings, "OSHibernationMode", 0); - if (os_hibernation_mode == 0) - { - LL_INFOS() << "Got a 'suspend' event from OS" << LL_ENDL; - // Viewer doesn't handle hibernation. - // Just send statistics. - LLAppViewer::instance()->sendViewerStatistics(false); - } - else - { - LL_INFOS() << "Got a 'suspend' event from OS, disconnecting" << LL_ENDL; - // Viewer is set to prevent hibernation if agent isn't away. - // If we got here, likely Agent 'went' away then viewer got - // a hibernation message. - // We have a limited timeframe. Sends stats then disconnect. - LLViewerRegion* region = gAgent.getRegion(); - if (region) - { - LLAppViewer::instance()->sendViewerStatistics(true); - LLAppViewer::instance()->metricsSend(!gDisconnected); - // Make sure to show a message. - LLAppViewer::instance()->forceDisconnect(LLTrans::getString("YouHaveBeenDisconnected")); - } - } + LLAppViewer::instance()->sendViewerStatistics(false); + // Todo: this should send a disconnect request as viewer + // can't keep heartbeat up while suspended and will get + // disconnected within a minute. + // Add a disconnect here once 'prevent OS from sleeping' + // feature is ready. } bool LLViewerWindow::handleCloseRequest(LLWindow *window, bool from_user) diff --git a/indra/newview/llvoicewebrtc.cpp b/indra/newview/llvoicewebrtc.cpp index 913ff45da18..90fa8cf8a1f 100644 --- a/indra/newview/llvoicewebrtc.cpp +++ b/indra/newview/llvoicewebrtc.cpp @@ -321,7 +321,26 @@ void LLWebRTCVoiceClient::initWebRTC() mWebRTCDeviceInterface = llwebrtc::getDeviceInterface(); mWebRTCDeviceInterface->setDevicesObserver(this); - mMainQueue = LL::WorkQueue::getInstance("mainloop"); + + if (!mVoiceWorkQueue) + { + mVoiceWorkQueue = std::make_shared("VoiceWebRTC"); + // Service this queue every idle tick, on the main thread. + doOnIdleRepeating( + [queue = LL::WorkQueue::weak_t(mVoiceWorkQueue)]() -> bool + { + auto locked = queue.lock(); + if (!locked || locked->isClosed()) + { + // Done + return true; + } + locked->runPending(); + // Returning false means "call me again next idle". + return false; + }); + } + refreshDeviceLists(); } @@ -384,11 +403,11 @@ void LLWebRTCVoiceClient::drainConnections() LLTimer timer; while (!sessionState::allSessionsClosed() && timer.getElapsedTimeF32() < DRAIN_TIMEOUT_SECONDS) { - // OnPeerConnectionClosed comes back through the main queue, so it has + // OnPeerConnectionClosed comes back through the queue, so it has // to be pumped or the state machines never see connections finish. - if (auto main_queue = mMainQueue.lock()) + if (mVoiceWorkQueue) { - main_queue->runFor(std::chrono::milliseconds(DRAIN_POLL_MS)); + mVoiceWorkQueue->runFor(std::chrono::milliseconds(DRAIN_POLL_MS)); } sessionState::processSessionStates(); @@ -740,7 +759,7 @@ void LLWebRTCVoiceClient::voiceConnectionCoro() updatePosition(); } } - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceWorkQueue, [=, this] { if (sShuttingDown) { @@ -862,7 +881,7 @@ void LLWebRTCVoiceClient::OnDevicesChanged(const llwebrtc::LLWebRTCVoiceDeviceLi const llwebrtc::LLWebRTCVoiceDeviceList& capture_devices) { - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceWorkQueue, [=, this] { OnDevicesChangedImpl(render_devices, capture_devices); @@ -2554,7 +2573,7 @@ LLVoiceWebRTCConnection::LLVoiceWebRTCConnection(const LLUUID ®ionID, const s mWebRTCPeerConnectionInterface = llwebrtc::newPeerConnection(); mWebRTCPeerConnectionInterface->setSignalingObserver(this); - mMainQueue = LL::WorkQueue::getInstance("mainloop"); + mVoiceMainQueue = LLWebRTCVoiceClient::getInstance()->getVoiceWorkQueue(); } LLVoiceWebRTCConnection::~LLVoiceWebRTCConnection() @@ -2588,7 +2607,7 @@ LLVoiceWebRTCConnection::~LLVoiceWebRTCConnection() // callback from llwebrtc void LLVoiceWebRTCConnection::OnIceGatheringState(llwebrtc::LLWebRTCSignalingObserver::EIceGatheringState state) { - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { LL_DEBUGS("Voice") << "Ice Gathering voice account. " << state << LL_ENDL; @@ -2612,7 +2631,7 @@ void LLVoiceWebRTCConnection::OnIceGatheringState(llwebrtc::LLWebRTCSignalingObs // callback from llwebrtc void LLVoiceWebRTCConnection::OnIceCandidate(const llwebrtc::LLWebRTCIceCandidate& candidate) { - LL::WorkQueue::postMaybe(mMainQueue, [=, this] { mIceCandidates.push_back(candidate); }); + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { mIceCandidates.push_back(candidate); }); } void LLVoiceWebRTCConnection::processIceUpdates() @@ -2728,7 +2747,7 @@ void LLVoiceWebRTCConnection::processIceUpdatesCoro(connectionPtr_t connection) void LLVoiceWebRTCConnection::OnOfferAvailable(const std::string &sdp) { connectionPtr_t connection = shared_from_this(); - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=] { if (connection->mShutDown) { @@ -2756,7 +2775,7 @@ void LLVoiceWebRTCConnection::OnOfferAvailable(const std::string &sdp) void LLVoiceWebRTCConnection::OnAudioEstablished(llwebrtc::LLWebRTCAudioInterface* audio_interface) { connectionPtr_t connection = shared_from_this(); - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=] { if (connection->mShutDown) { @@ -2779,7 +2798,7 @@ void LLVoiceWebRTCConnection::OnAudioEstablished(llwebrtc::LLWebRTCAudioInterfac // callback from llwebrtc void LLVoiceWebRTCConnection::OnRenegotiationNeeded() { - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { LL_DEBUGS("Voice") << "Voice channel requires renegotiation." << LL_ENDL; setVoiceConnectionState(VOICE_STATE_SESSION_RETRY); @@ -2790,7 +2809,7 @@ void LLVoiceWebRTCConnection::OnRenegotiationNeeded() // callback from llwebrtc void LLVoiceWebRTCConnection::OnPeerConnectionClosed() { - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { LL_DEBUGS("Voice") << "Peer connection has closed." << LL_ENDL; if (mVoiceConnectionState == VOICE_STATE_WAIT_FOR_CLOSE) @@ -3305,7 +3324,7 @@ bool LLVoiceWebRTCConnection::connectionStateMachine() // llwebrtc callback void LLVoiceWebRTCConnection::OnDataReceived(const std::string& data, bool binary) { - LL::WorkQueue::postMaybe(mMainQueue, [=, this] { LLVoiceWebRTCConnection::OnDataReceivedImpl(data, binary); }); + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { LLVoiceWebRTCConnection::OnDataReceivedImpl(data, binary); }); } // @@ -3542,7 +3561,7 @@ void LLVoiceWebRTCConnection::OnDataReceivedImpl(const std::string &data, bool b void LLVoiceWebRTCConnection::OnDataChannelReady(llwebrtc::LLWebRTCDataInterface *data_interface) { connectionPtr_t connection = shared_from_this(); - LL::WorkQueue::postMaybe(mMainQueue, + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=] { if (connection->mShutDown) { @@ -3587,7 +3606,7 @@ void LLVoiceWebRTCConnection::sendJoin() void LLVoiceWebRTCConnection::OnStatsDelivered(const llwebrtc::LLWebRTCStatsMap& stats_data) { - LL::WorkQueue::postMaybe(mMainQueue, [=, this] + LL::WorkQueue::postMaybe(mVoiceMainQueue, [=, this] { if (mShutDown) { diff --git a/indra/newview/llvoicewebrtc.h b/indra/newview/llvoicewebrtc.h index 88d1106b6e5..8828975323c 100644 --- a/indra/newview/llvoicewebrtc.h +++ b/indra/newview/llvoicewebrtc.h @@ -42,6 +42,7 @@ class LLWebRTCProtocolParser; #include "llcoros.h" #include "llparcel.h" #include "llmutelist.h" +#include "workqueue.h" #include #include "llvoiceclient.h" @@ -81,6 +82,8 @@ class LLWebRTCVoiceClient : public LLSingleton, // drainConnections() and ~LLVoiceWebRTCConnection(). static bool isWebRTCTerminated() { return sWebRTCTerminated; } + LL::WorkQueue::weak_t getVoiceWorkQueue() const { return mVoiceWorkQueue; } + const LLVoiceVersionInfo& getVersion() override; void updateVersion(); @@ -464,7 +467,7 @@ class LLWebRTCVoiceClient : public LLSingleton, /// llwebrtc::terminate(). Bounded and best effort. void drainConnections(); - LL::WorkQueue::weak_t mMainQueue; + LL::WorkQueue::ptr_t mVoiceWorkQueue; F32 mTuningMicGain; int mTuningSpeakerVolume; @@ -684,7 +687,7 @@ class LLVoiceWebRTCConnection : } EVoiceConnectionState; EVoiceConnectionState mVoiceConnectionState; - LL::WorkQueue::weak_t mMainQueue; + LL::WorkQueue::weak_t mVoiceMainQueue; void setVoiceConnectionState(EVoiceConnectionState new_voice_connection_state) { diff --git a/indra/newview/pipeline.cpp b/indra/newview/pipeline.cpp index a4e4b74dee5..0a1870b8c9f 100644 --- a/indra/newview/pipeline.cpp +++ b/indra/newview/pipeline.cpp @@ -40,6 +40,7 @@ #include "llimagewebp.h" #include "llaudioengine.h" // For debugging. #include "llerror.h" +#include "llfocusmgr.h" #include "llviewercontrol.h" #include "llfasttimer.h" #include "llfontgl.h" @@ -55,6 +56,7 @@ #include "alsamplerstate.h" #include "aluniformbuffer.h" #include "alwhitebalancesolver.h" +#include "alcurvemodel.h" #include "llrender.h" #include "llstartup.h" #include "llwindow.h" // swapBuffers() @@ -654,6 +656,18 @@ void LLPipeline::init() gSavedSettings.getControl("RenderColorGrade")->getCommitSignal()->connect(boost::bind(&LLPipeline::setupGradingLUT, this)); gSavedSettings.getControl("RenderColorGradeLUT")->getCommitSignal()->connect(boost::bind(&LLPipeline::setupGradingLUT, this)); + + // Tone curve: a commit only marks the lookup row dirty. The bake itself + // runs from colorCorrect -- see bakeToneCurveLut for why not here. + for (S32 c = 0; c < ALToneCurveSet::CH_COUNT; ++c) + { + cntrl_ptr = gSavedSettings.getControl(ALToneCurveSet::settingName(static_cast(c))); + if (cntrl_ptr.notNull()) + { + cntrl_ptr->getCommitSignal()->connect( + [this](LLControlVariable*, const LLSD&, const LLSD&) { mToneCurveLutDirty = true; }); + } + } } LLPipeline::~LLPipeline() @@ -927,8 +941,8 @@ bool LLPipeline::allocateScreenBufferInternal(U32 resX, U32 resY) mRT->deferredScreen.shareDepthBuffer(mRT->screen); - if (shadow_detail > 0 || ssao || RenderDepthOfField) - { //only need mRT->deferredLight for shadows OR ssao OR dof + if (shadow_detail > 0 || ssao) + { //only need mRT->deferredLight for shadows OR ssao if (!mRT->deferredLight.allocate(resX, resY, screenFormat)) return false; } else @@ -936,6 +950,9 @@ bool LLPipeline::allocateScreenBufferInternal(U32 resX, U32 resY) mRT->deferredLight.release(); } + // Depth of field allocates nothing here: it borrows mWaterDis and + // bloomMip[0] while they are idle. See renderDoF. + U32 post_color_fmt = hdr ? GL_RGB10_A2 : GL_RGBA8; if(mRT != &mHeroProbeRT) { @@ -1290,6 +1307,18 @@ void LLPipeline::releaseGLBuffers() mSMAASearchMap = 0; } + // Clearing the recorded parameters matters as much as the release: they are + // what generateLensDirt compares against, so leaving them set would make it + // decide nothing had changed and skip rebuilding the plate it no longer has. + mLensDirtMap.release(); + mLensDirtParams = LensDirtParams(); + if (mToneCurveLut) + { + LLImageGL::deleteTextures(1, &mToneCurveLut); + mToneCurveLut = 0; + } + mToneCurveLutDirty = true; + releaseLUTBuffers(); mWaterDis.release(); @@ -1339,6 +1368,9 @@ void LLPipeline::releaseLUTBuffers() mLuminanceMap.release(); mLastExposure.release(); + mLensFlareState[0].release(); + mLensFlareState[1].release(); + mLensFlareStateValid = false; } void LLPipeline::releaseShadowBuffers() @@ -1376,6 +1408,9 @@ void LLPipeline::releaseScreenBuffers() rt.bloomMip[i].release(); } rt.bloomMipCount = 0; + rt.crossFilterReady = false; + rt.crossFilterWidth = 0; + rt.crossFilterHeight = 0; }; release_pack(mMainRT); release_pack(mAuxillaryRT); @@ -1537,6 +1572,23 @@ void LLPipeline::createGLBuffers() } } + if (!mToneCurveLut) + { + // Allocated empty; bakeToneCurveLut fills it on the next colorCorrect, + // and nothing samples it before then because uToneCurveAmount stays 0 + // until a bake reports a non-identity curve. RGBA16 rather than RGBA8 + // so the filter interpolates 16-bit samples instead of banding an + // 8-bit display, and a sized format because allocateTexture2D takes + // nothing else. One-shot: re-bakes go through setManualSubImage. + LLImageGL::generateTextures(1, &mToneCurveLut); + gGL.getTextureSlot(0)->bindManual(ALTextureSlot::TT_TEXTURE, mToneCurveLut); + LLImageGL::allocateTexture2D(ALTextureSlot::getInternalType(ALTextureSlot::TT_TEXTURE), GL_RGBA16, + ALToneCurveSet::LUT_SIZE, 1, GL_RGBA, GL_UNSIGNED_SHORT, nullptr); + gGL.getTextureSlot(0)->unbind(); + stop_glerror(); + mToneCurveLutDirty = true; // fresh storage holds nothing + } + createLUTBuffers(); setupGradingLUT(); @@ -1595,6 +1647,154 @@ void LLPipeline::createLUTBuffers() mLuminanceMap.allocate(256, 256, GL_R16F, false, false, ALTextureSlot::TT_TEXTURE, LLRenderTarget::MIPS_AUTO); mLastExposure.allocate(1, 1, GL_R16F); + + // Lens flare sun state, 2x1, two of them swapped each frame. Zero is the + // correct history: no drive, no instability, no reference luminance. + mLensFlareState[0].allocate(2, 1, GL_RGBA16F); + mLensFlareState[1].allocate(2, 1, GL_RGBA16F); + clearLensFlareState(); +} + +// Lens dirt plate generator. +// +// Generated rather than loaded from a bundled image: a plate built at the +// frame's own aspect keeps a mote round on an ultrawide with no cover-fit, and +// puts the grime itself on sliders instead of shipping fixed looks and hoping +// one fits the shot. +// +// This is not a per-frame pass. It runs when a parameter moves or the window +// resizes, and that budget is what lets the shader afford four cellular layers, +// two fBm fields and up to LENS_DIRT_MAX_LINES segment distance fields per +// pixel. The per-frame cost of the effect is still the one texture fetch in +// colorCorrect. +void LLPipeline::generateLensDirt() +{ + static LLCachedControl dirt_strength(gSavedSettings, "RenderLensDirtStrength", 0.f); + static LLCachedControl dirt_seed(gSavedSettings, "RenderLensDirtSeed", 7); + static LLCachedControl dirt_grime(gSavedSettings, "RenderLensDirtGrime", 1.f); + static LLCachedControl dirt_mote_scale(gSavedSettings, "RenderLensDirtMoteScale", 1.f); + static LLCachedControl dirt_smudge(gSavedSettings, "RenderLensDirtSmudge", 1.f); + static LLCachedControl dirt_scratches(gSavedSettings, "RenderLensDirtScratches", 0); + static LLCachedControl dirt_toe(gSavedSettings, "RenderLensDirtToe", 1.6f); + static LLCachedControl dirt_gain(gSavedSettings, "RenderLensDirtGain", 1.f); + + // Deliberately not gated on gSnapshotNoPost. That flag is true for the one + // frame a no-post snapshot is taken, so releasing the plate for it would + // buy a full regeneration on the very next frame -- a hitch every time + // someone takes one. colorCorrect already forces the strength uniform to 0 + // under a clean plate, which is the gate that actually matters. + const bool dirt_on = (dirt_strength() > 0.f) && gLensDirtGenProgram.isComplete(); + + if (!dirt_on) + { + // Release rather than merely skip, the same way the cross filter does: + // allocating lazily is only worth anything if switching the effect off + // gives the memory back. Doing it here rather than from a commit signal + // keeps one teardown path, inside the render loop where the target is + // owned, and lets the strength control stay a live slider -- wiring a + // slider to a reallocation handler would fire on every mouse-move. + if (mLensDirtMap.isComplete() || mLensDirtParams != LensDirtParams()) + { + mLensDirtMap.release(); + mLensDirtParams = LensDirtParams(); + } + return; + } + + // The frame's own resolution. There is no fitting to do when the plate is + // made at the shape it will be read at, and at GL_R8 even a 4K plate is + // about 8 MB -- cheap for something only allocated while the effect is on. + // + // Generating at full resolution is only affordable because the rebuild is + // debounced below. Capping the plate instead would bound the cost of one + // rebuild but not the number of them, which is the part that hurts. + const U32 gen_w = llmax(1u, mRT->screen.getWidth()); + const U32 gen_h = llmax(1u, mRT->screen.getHeight()); + + LensDirtParams want; + want.width = gen_w; + want.height = gen_h; + want.seed = (F32)llclamp(dirt_seed(), 0, 999); + want.grime = llclamp(dirt_grime(), 0.f, 2.f); + want.mote_scale = llclamp(dirt_mote_scale(), 0.5f, 2.f); + want.smudge = llclamp(dirt_smudge(), 0.f, 2.f); + want.scratches = llclamp(dirt_scratches(), 0, LENS_DIRT_MAX_LINES); + want.toe = llclamp(dirt_toe(), 0.6f, 4.f); + want.gain = llclamp(dirt_gain(), 0.5f, 2.5f); + + // Compared before the target is inspected, so a plate that failed to + // allocate is not retried -- and this warning not repeated -- every frame + // while VRAM stays exhausted. The next attempt happens when something + // actually moves, and the bind site keeps the effect off through + // isComplete() until one succeeds. + if (want == mLensDirtParams) + { + return; + } + + // Hold off while a generation slider is being dragged. A drag changes a + // parameter every frame, and at full resolution rebuilding on each one is a + // stutter rather than a preview -- the slower the machine, the more of the + // drag it stutters through, which is backwards. The rebuild instead lands + // once, on release, which is where the result is being looked for anyway. + // + // Only a drag is held off, which is the whole reason this is a UI signal + // rather than a settle timer: a typed value, a reset button, applying a + // Look, undo, and a window resize all arrive here with no slider down and + // rebuild on the spot, where a timer would have made every one of them wait + // for no reason. The first plate is never held off either -- until one + // exists the effect is simply absent, and a pause reads as a bug. + // + // The capture test is a failsafe rather than part of the logic. LLSlider + // raises the flag from handleMouseDown and lowers it from handleMouseUp, + // but it implements no onMouseCaptureLost, so a capture stolen mid-drag + // would otherwise leave the flag stuck and the plate frozen until something + // else moved. No captor means no drag, whatever the flag says. + if (mLensDirtSliderHeld && gFocusMgr.getMouseCapture() != nullptr) + { + return; + } + + mLensDirtParams = want; + + // Everything above is a comparison; the zone starts where the work does. + LL_PROFILE_GPU_ZONE("lens dirt generate"); + + if (mLensDirtMap.getWidth() != gen_w || mLensDirtMap.getHeight() != gen_h) + { + mLensDirtMap.release(); + if (!mLensDirtMap.allocate(gen_w, gen_h, GL_R8)) + { + LL_WARNS() << "Could not allocate the lens dirt plate; effect disabled until the parameters change" << LL_ENDL; + return; + } + LL_DEBUGS("Pipeline") << "Lens dirt plate at " << gen_w << "x" << gen_h << LL_ENDL; + } + + gLensDirtGenProgram.bind(); + + gLensDirtGenProgram.uniform2f(LLShaderMgr::LENS_DIRT_RESOLUTION, (F32)gen_w, (F32)gen_h); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_SEED, want.seed); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_GRIME, want.grime); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_MOTE_SCALE, want.mote_scale); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_SMUDGE, want.smudge); + gLensDirtGenProgram.uniform1i(LLShaderMgr::LENS_DIRT_SCRATCHES, want.scratches); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_TOE, want.toe); + gLensDirtGenProgram.uniform1f(LLShaderMgr::LENS_DIRT_GAIN, want.gain); + + // No clear: the fullscreen triangle writes every texel with blending off, + // so clearing first would be pure redundant fill. + { + LLGLDisable blend(GL_BLEND); + + mLensDirtMap.bindTarget(); + mScreenTriangleVB->setBuffer(); + mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + mLensDirtMap.flush(); + } + + gLensDirtGenProgram.unbind(); + stop_glerror(); } void LLPipeline::setupGradingLUT() @@ -1799,6 +1999,55 @@ void LLPipeline::setupGradingLUT() } +// Runs lazily from colorCorrect rather than from the settings signal for +// three reasons. setShaders() releases and recreates the texture behind the +// settings' back, and only a bake that runs on the way to drawing can refill +// it. A Look apply or an undo writes four curve settings in a row and a drag +// on the graph commits per mouse move, so one bake per rendered frame is the +// natural rate and the dirty flag coalesces the rest. And colorCorrect is the +// one place guaranteed a current context with nothing of its own bound yet -- +// the upload borrows texture slot 0. +void LLPipeline::bakeToneCurveLut() +{ + LL_PROFILE_ZONE_SCOPED; + mToneCurveLutDirty = false; + + // Once per change, so a name lookup is fine and no cached control is + // needed. controlExists keeps a build whose settings lag behind from + // asserting inside LLControlGroup. + ALToneCurveSet curves; + for (S32 c = 0; c < ALToneCurveSet::CH_COUNT; ++c) + { + const ALToneCurveSet::EChannel channel = static_cast(c); + const char* name = ALToneCurveSet::settingName(channel); + if (gSavedSettings.controlExists(name)) + { + curves.setCurveFromLLSD(channel, gSavedSettings.getLLSD(name)); + } + } + + mToneCurveIdentity = curves.isIdentity(); + if (mToneCurveIdentity || !mToneCurveLut) + { + // Identity never reaches the shader (colorCorrect uploads amount 0), so + // stale texels are harmless; and with no texture there is nowhere to + // bake. The flag stays clear either way: a recreated texture sets it + // again itself. + return; + } + + std::vector texels; + curves.bake(texels, ALToneCurveSet::LUT_SIZE); + + // setManualSubImage writes whatever is bound on the active unit. Slot 0 is + // the convention every raw-name upload in this file uses. + gGL.getTextureSlot(0)->bindManual(ALTextureSlot::TT_TEXTURE, mToneCurveLut); + LLImageGL::setManualSubImage(ALTextureSlot::getInternalType(ALTextureSlot::TT_TEXTURE), 0, + ALToneCurveSet::LUT_SIZE, 1, GL_RGBA, GL_UNSIGNED_SHORT, texels.data()); + gGL.getTextureSlot(0)->unbind(); + stop_glerror(); +} + void LLPipeline::restoreGL() { assertInitialized(); @@ -7751,11 +8000,207 @@ void LLPipeline::generateExposure(LLRenderTarget* src, LLRenderTarget* dst, bool } } +void LLPipeline::clearLensFlareState() +{ + // Only [0] is ever read as history: the swap in generateLensFlareState + // makes it last frame's target before anything samples it, and [1] is + // fully overwritten by the draw before it is read. + mLensFlareState[0].bindTarget(); + mLensFlareState[0].clear(); + mLensFlareState[0].flush(); + mLensFlareStateValid = false; +} + +// Sun coverage, colour and the temporally filtered flare drive, into a 2x1 +// target the colour-correct programs read through uLensFlareStateMap. The +// only place the flare's visibility changes, at a bounded rate; the filter +// and the reasons for it are in lensFlareStateF.glsl. Runs right before +// colorCorrect, against the frame's final depth, in both the HDR and +// non-HDR paths. +void LLPipeline::generateLensFlareState(LLRenderTarget* src) +{ + static LLCachedControl lens_flare_strength(gSavedSettings, "RenderLensFlareStrength", 0.f); + static LLCachedControl lens_flare_occlusion_scale(gSavedSettings, "RenderLensFlareOcclusionScale", 1.f); + static LLCachedControl lens_flare_fade_time(gSavedSettings, "RenderLensFlareFadeTime", 0.35f); + + if (gSnapshotNoPost) + { + // A clean-plate render: colorCorrect zeroes the strength for this + // frame, so hold the history rather than clear it, or the flare would + // blink out and fade back in after every no-post snapshot. + return; + } + + if (lens_flare_strength() <= 0.f || !gLensFlareStateProgram.isComplete()) + { + // Switched off, or nothing to run it with: leave no history behind, + // or a stale drive would flash back on the frame the flare returns. + if (mLensFlareStateValid) + { + clearLensFlareState(); + } + return; + } + + LL_PROFILE_GPU_ZONE("lens flare state"); + + // Sun (or moon) direction to screen UV, with a soft fade over a margin + // beyond the frame so the streak survives a sun just out of view. + // Gate/divide on clip w, not z: w is convention-independent (reverse-Z + // rewrites only the projection's z row), so w > 0 == "sun in front" and + // xy/w == ndc under both conventions; z would flip sign under reverse-Z. + LLEnvironment& environment = LLEnvironment::instance(); + const bool sun_up = environment.getIsSunUp(); + if (sun_up != mLensFlareSunUp) + { + // The history holds the old body's colour while the anchor moves to + // the new one this frame; a sun flare fading out on the moon is wrong, + // so start over. + mLensFlareSunUp = sun_up; + if (mLensFlareStateValid) + { + clearLensFlareState(); + } + } + const LLVector4 light_dir = sun_up ? mSunDir : mMoonDir; + LLVector4a sun_eye, sun_clip; + LLViewerCamera::getCurrent().getModelview().rotate(LLVector4a(light_dir.mV[0], light_dir.mV[1], light_dir.mV[2], 0.f), sun_eye); + LLViewerCamera::getCurrent().getProjection().transform4(sun_eye, sun_clip); + F32 edge_fade = 0.f; + if (sun_clip[3] > 0.f) + { + const LLVector2 sun_ndc(sun_clip[0] / sun_clip[3], sun_clip[1] / sun_clip[3]); + const LLVector2 sun_uv = sun_ndc * 0.5f + LLVector2(0.5f, 0.5f); + constexpr F32 margin = 0.2f; + edge_fade = llclamp((sun_uv.mV[VX] + margin) / margin, 0.f, 1.f) + * llclamp(((1.f + margin) - sun_uv.mV[VX]) / margin, 0.f, 1.f) + * llclamp((sun_uv.mV[VY] + margin) / margin, 0.f, 1.f) + * llclamp(((1.f + margin) - sun_uv.mV[VY]) / margin, 0.f, 1.f); + mLensFlareSunUV = sun_uv; + } + + // Probe radius as a fraction of screen height: the body's own angular + // radius (updateHeavenlyBodyGeometry: HEAVENLY_BODY_FACTOR x disk radius + // x sky scale, at unit distance) through the current vertical FOV, times + // the setting. It follows zoom and the sky's sun scale, so an occluder + // narrower than the disc dims the flare instead of cutting it. + LLSettingsSky::ptr_t psky = environment.getCurrentSky(); + F32 disk_radius = 0.5f; + if (gSky.mVOSkyp.notNull()) + { + disk_radius = (sun_up ? gSky.mVOSkyp->getSun() : gSky.mVOSkyp->getMoon()).getDiskRadius(); + } + const F32 body_scale = llmax(sun_up ? psky->getSunScale() : psky->getMoonScale(), 0.01f); + // The drawn quad grows towards the horizon (1.3x wide and 1.2x tall at + // dir.z = 0, llvosky.cpp); a circular probe takes the mean of the two. + const F32 enlargement = 1.f + (1.f - light_dir.mV[VZ]) * 0.25f; + const F32 half_tan = HEAVENLY_BODY_FACTOR * disk_radius * body_scale * enlargement; + const LLViewerCamera* camera = LLViewerCamera::getInstance(); + const F32 fov_y = llclamp(camera->getView(), 0.01f, F_PI - 0.01f); + // A tiled snapshot zooms the projection without touching the FOV, and the + // radius has to follow the projection the centre was put through. Capped + // so a wide sky sun scale at a narrow FOV cannot probe half the frame. + const F32 radius_uv = llmin(0.5f * half_tan / tanf(fov_y * 0.5f) * llmax(camera->getZoomFactor(), 1.f) + * llclamp(lens_flare_occlusion_scale(), 0.25f, 2.f), 0.3f); + + // The shader derives its time constants and slew from FadeTime itself, so + // the filter is defined in one file and its mirror can check it. + const F32 fade = llclamp(lens_flare_fade_time(), 0.1f, 1.f); + + // Ping-pong by swapping handles: [1] becomes last frame's state, the + // history this frame reads, and [0] is fully overwritten below. + mLensFlareState[0].swapFBORefs(mLensFlareState[1]); + + mLensFlareState[0].bindTarget(); + LLGLDepthTest depth(GL_FALSE, GL_FALSE); + + LLGLSLShader& shader = gLensFlareStateProgram; + shader.bind(); + const S32 diffuse_channel = shader.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); + const S32 depth_channel = shader.bindDepthTexture(LLShaderMgr::DEFERRED_DEPTH, &mRT->deferredScreen); + const S32 state_channel = shader.bindTexture(LLShaderMgr::LENS_FLARE_STATE_MAP, &mLensFlareState[1], ALSamplers::PointClamp); + + shader.uniform1f(LLShaderMgr::DT, gFrameIntervalSeconds); + shader.uniform2f(LLShaderMgr::SCREEN_RESOLUTION, (GLfloat)src->getWidth(), (GLfloat)src->getHeight()); + shader.uniform2f(LLShaderMgr::LENS_FLARE_SUN_POS, mLensFlareSunUV.mV[VX], mLensFlareSunUV.mV[VY]); + shader.uniform1f(LLShaderMgr::LENS_FLARE_SUN_VISIBILITY, edge_fade); + shader.uniform1f(LLShaderMgr::LENS_FLARE_OCCLUSION_RADIUS, radius_uv); + shader.uniform1f(LLShaderMgr::LENS_FLARE_FADE_TIME, fade); + + mScreenTriangleVB->setBuffer(); + mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + + if (state_channel > -1) + { + gGL.getTextureSlot(state_channel)->unbind(); + } + if (depth_channel > -1) + { + gGL.getTextureSlot(depth_channel)->unbind(); + } + if (diffuse_channel > -1) + { + gGL.getTextureSlot(diffuse_channel)->unbind(); + } + shader.unbind(); + mLensFlareState[0].flush(); + mLensFlareStateValid = true; +} + +namespace +{ + // Which quadrant of the borrowed target each cross filter buffer lives in. + enum ECrossFilterQuadrant : S32 + { + CROSS_FILTER_SCRATCH_A = 0, // top-left: the first pass of every arm + CROSS_FILTER_SCRATCH_B = 1, // top-right: the second + CROSS_FILTER_ACCUMULATOR = 2, // bottom-left: what every arm adds into + }; + + // A streak buffer's place inside the texture that holds it, as the two + // shaders want it: an origin and scale that map a region-relative + // coordinate into the texture, and the half-texel margin the coordinate + // is clamped to first. The defaults describe a whole texture. + struct CrossFilterRegion + { + U32 mPixelX = 0; // the quadrant's origin in texels, for the viewport + U32 mPixelY = 0; + F32 mOriginX = 0.f; + F32 mOriginY = 0.f; + F32 mScaleX = 1.f; + F32 mScaleY = 1.f; + F32 mClampX = 0.f; + F32 mClampY = 0.f; + }; + + CrossFilterRegion cross_filter_region(S32 quad, U32 streak_w, U32 streak_h, const LLRenderTarget& holder) + { + const F32 tex_w = (F32)llmax(holder.getWidth(), 1u); + const F32 tex_h = (F32)llmax(holder.getHeight(), 1u); + + CrossFilterRegion region; + region.mPixelX = (quad == CROSS_FILTER_SCRATCH_B) ? streak_w : 0u; + region.mPixelY = (quad == CROSS_FILTER_ACCUMULATOR) ? streak_h : 0u; + region.mOriginX = (F32)region.mPixelX / tex_w; + region.mOriginY = (F32)region.mPixelY / tex_h; + region.mScaleX = (F32)streak_w / tex_w; + region.mScaleY = (F32)streak_h / tex_h; + region.mClampX = 0.5f / (F32)llmax(streak_w, 1u); + region.mClampY = 0.5f / (F32)llmax(streak_h, 1u); + return region; + } +} void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool apply_tonemap, bool apply_color_grade) { LL_PROFILE_GPU_ZONE("colorcorrect"); + if (mToneCurveLutDirty) + { + // Borrows slot 0, so it has to run before anything below is bound. + bakeToneCurveLut(); + } + dst->bindTarget(); { LLGLDepthTest depth(GL_FALSE, GL_FALSE); @@ -7806,7 +8251,6 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->bind(); S32 diffuse_channel = shader->bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); - S32 depth_channel = shader->bindDepthTexture(LLShaderMgr::DEFERRED_DEPTH, &mRT->deferredScreen); S32 exposure_channel = shader->bindTexture(LLShaderMgr::EXPOSURE_MAP, &mExposureMap); // HDR bloom pyramid is folded into the tonemap variants of this shader @@ -7814,6 +8258,8 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // the composite no longer needs its own pass. When HDR is off the shader // variant lacks the sampler and bindTexture is a no-op via getTextureChannel. S32 bloom_channel = -1; + S32 cross_channel = -1; + S32 state_channel = -1; if (mRT->bloomMipCount > 0) { bloom_channel = shader->bindTexture(LLShaderMgr::BLOOM_SAMPLER, &mRT->bloomMip[0], ALSamplers::BilinearMirror); @@ -7830,6 +8276,40 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->uniform1f(LLShaderMgr::HALATION_STRENGTH, llmax(halation_strength(), 0.0f) * strength_gate); const LLColor3& tint = halation_tint(); shader->uniform3f(LLShaderMgr::HALATION_TINT, tint.mV[0], tint.mV[1], tint.mV[2]); + + // Cross-filter streaks fold in here rather than in a fullscreen + // pass of their own. That pass existed only to add a half-size + // buffer into bloomMip[0], which cost a full-resolution + // read-modify-write of the pyramid top every frame; this pass + // already samples that pyramid, so one more sampler replaces all + // of it. + // + // Added to bloom_term inside the shader rather than to the scene + // directly, which keeps two couplings that were previously free: + // the streaks stay scaled by bloom strength, and they keep + // lighting the lens dirt through lens_light. + static LLCachedControl streak_strength_setting(gSavedSettings, "RenderCrossFilterStrength", 0.f); + // Drawn this frame, not merely allocated: the accumulator is a + // quadrant of mWaterDis, whose completeness says nothing about + // what is in it. + const bool streaks_live = mRT->crossFilterReady && mWaterDis.isComplete(); + const F32 streaks = (streaks_live && !gSnapshotNoPost) + ? llclamp(streak_strength_setting(), 0.f, CROSS_FILTER_MAX_STRENGTH) * strength_gate + : 0.f; + shader->uniform1f(LLShaderMgr::CROSS_STRENGTH, streaks); + if (streaks > 0.f) + { + cross_channel = shader->bindTexture(LLShaderMgr::CROSS_FILTER_MAP, + &mWaterDis, + ALSamplers::BilinearClamp); + const CrossFilterRegion region = cross_filter_region(CROSS_FILTER_ACCUMULATOR, + mRT->crossFilterWidth, + mRT->crossFilterHeight, + mWaterDis); + shader->uniform4f(LLShaderMgr::CROSS_REGION, + region.mOriginX, region.mOriginY, region.mScaleX, region.mScaleY); + shader->uniform2f(LLShaderMgr::CROSS_CLAMP, region.mClampX, region.mClampY); + } } } @@ -7842,6 +8322,10 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // every variant including the no-post ones, so their strengths have to // be silenced here or a clean plate still carries both. const bool clean_plate = gSnapshotNoPost; + // The material preview renders through here too, inside the auxiliary + // target pack. The flare state was measured for the world frame, so + // only the main pack gets the flare; aberration is left as it was. + const bool world_frame = (mRT == &mMainRT); // Chromatic aberration parameters static LLCachedControl chromatic_aberration_strength(gSavedSettings, "RenderChromaticAberrationStrength", 0.f); @@ -7888,53 +8372,17 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app static LLCachedControl lens_flare_starburst_spikes(gSavedSettings, "RenderLensFlareStarburstSpikes", 4); static LLCachedControl lens_flare_starburst_sharpness(gSavedSettings, "RenderLensFlareStarburstSharpness", 24.f); static LLCachedControl lens_flare_starburst_length(gSavedSettings, "RenderLensFlareStarburstLength", 0.25f); - static LLCachedControl lens_flare_occlusion_radius(gSavedSettings, "RenderLensFlareOcclusionRadius", 0.02f); - static LLCachedControl lens_flare_occlusion_taps(gSavedSettings, "RenderLensFlareOcclusionTaps", 9); // Zeroing the master strength both hits the shader's early-out and - // skips the whole detail block below, sun projection included. - F32 strength = clean_plate ? 0.f : llclamp(lens_flare_strength(), 0.f, 1.f); + // skips the whole detail block below. + F32 strength = (clean_plate || !world_frame) ? 0.f : llclamp(lens_flare_strength(), 0.f, 1.f); shader->uniform1f(LLShaderMgr::LENS_FLARE_STRENGTH, strength); if (strength > 0.f) { - // Project sun direction to screen UV - LLEnvironment& environment = LLEnvironment::instance(); - bool sun_up = environment.getIsSunUp(); - LLVector4 light_dir = sun_up ? mSunDir : mMoonDir; - - LLVector4a sun_eye, sun_clip; - LLViewerCamera::getCurrent().getModelview().rotate(LLVector4a(light_dir.mV[0], light_dir.mV[1], light_dir.mV[2], 0.f), sun_eye); - LLViewerCamera::getCurrent().getProjection().transform4(sun_eye, sun_clip); - - F32 target_visibility = 0.f; - // Gate/divide on clip w, not z: w is convention-independent (reverse-Z rewrites - // only the projection's z row), so w > 0 == "sun in front" and xy/w == ndc under - // both forward and reverse-Z. (z would flip sign under reverse-Z and hide the flare.) - if (sun_clip[3] > 0.f) - { - const LLVector2 sun_ndc(sun_clip[0] / sun_clip[3], sun_clip[1] / sun_clip[3]); - const LLVector2 sun_uv = sun_ndc * 0.5f + LLVector2(0.5f, 0.5f); - - // Soft fade as sun approaches screen edges — generous margin - // lets the streak persist even when the sun is slightly off-screen. - F32 edge_fade = 1.f; - F32 margin = 0.2f; - edge_fade *= llclamp((sun_uv.mV[0] - (-margin)) / margin, 0.f, 1.f); - edge_fade *= llclamp(((1.f + margin) - sun_uv.mV[0]) / margin, 0.f, 1.f); - edge_fade *= llclamp((sun_uv.mV[1] - (-margin)) / margin, 0.f, 1.f); - edge_fade *= llclamp(((1.f + margin) - sun_uv.mV[1]) / margin, 0.f, 1.f); - - target_visibility = edge_fade; - shader->uniform2f(LLShaderMgr::LENS_FLARE_SUN_POS, sun_uv.mV[0], sun_uv.mV[1]); - } - - // Temporally smooth visibility to avoid flicker from depth sampling noise. - // Fade out faster than fade in for responsive occlusion. - F32 fade_speed = (target_visibility < mLensFlareSunVisibility) ? 0.15f : 0.05f; - mLensFlareSunVisibility = std::lerp(mLensFlareSunVisibility, target_visibility, fade_speed); - - shader->uniform1f(LLShaderMgr::LENS_FLARE_SUN_VISIBILITY, mLensFlareSunVisibility); + // Sun UV and the filtered drive both come from generateLensFlareState. + shader->uniform2f(LLShaderMgr::LENS_FLARE_SUN_POS, mLensFlareSunUV.mV[VX], mLensFlareSunUV.mV[VY]); + state_channel = shader->bindTexture(LLShaderMgr::LENS_FLARE_STATE_MAP, &mLensFlareState[0], ALSamplers::PointClamp); // Anamorphic streak shader->uniform1f(LLShaderMgr::LENS_FLARE_STREAK_LENGTH, llclamp(lens_flare_streak_length(), 0.01f, 2.f)); @@ -7969,15 +8417,41 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app F32 starburst_length = llclamp(lens_flare_starburst_length(), 0.f, 1.f); F32 starburst_falloff = 4.f / (starburst_length + 0.05f); shader->uniform1f(LLShaderMgr::LENS_FLARE_STARBURST_FALLOFF, starburst_falloff); - shader->uniform1f(LLShaderMgr::LENS_FLARE_OCCLUSION_RADIUS, llclamp(lens_flare_occlusion_radius(), 0.005f, 0.1f)); - shader->uniform1i(LLShaderMgr::LENS_FLARE_OCCLUSION_TAPS, llclamp(lens_flare_occlusion_taps(), 1, 32)); + const bool sun_up = LLEnvironment::instance().getIsSunUp(); LLColor4 light_color = linearColor3(sun_up ? mSunDiffuse : mMoonDiffuse); shader->uniform3f(LLShaderMgr::LENS_FLARE_LIGHT_COLOR, light_color.mV[0], light_color.mV[1], light_color.mV[2]); } - else + } + + // Lens dirt + // + // Forced off whenever there is no plate -- generateLensDirt allocates + // only while the effect is on, and gives the memory back when it is + // not -- so the shader's early-out fires and the sampler is never read + // unbound, the same guard the grading LUT and the reference still use. + // Also off under a clean plate: dirt is a look, not a quantisation aid. + // And only on the world frame, like the flare: the plate is made at + // the screen's shape, and the material preview is not that shape. + S32 dirt_channel = -1; + { + static LLCachedControl lens_dirt_strength(gSavedSettings, "RenderLensDirtStrength", 0.f); + static LLCachedControl lens_dirt_bloom(gSavedSettings, "RenderLensDirtBloomResponse", 1.f); + static LLCachedControl lens_dirt_flare(gSavedSettings, "RenderLensDirtFlareResponse", 1.f); + + const F32 dirt_strength = (clean_plate || !world_frame || !mLensDirtMap.isComplete()) + ? 0.f + : llclamp(lens_dirt_strength(), 0.f, 2.f); + + shader->uniform1f(LLShaderMgr::LENS_DIRT_STRENGTH, dirt_strength); + shader->uniform1f(LLShaderMgr::LENS_DIRT_BLOOM_RESPONSE, llclamp(lens_dirt_bloom(), 0.f, 2.f)); + shader->uniform1f(LLShaderMgr::LENS_DIRT_FLARE_RESPONSE, llclamp(lens_dirt_flare(), 0.f, 2.f)); + + if (dirt_strength > 0.f) { - mLensFlareSunVisibility = 0.f; + dirt_channel = shader->bindTexture(LLShaderMgr::LENS_DIRT_MAP, + &mLensDirtMap, + ALSamplers::BilinearClamp); } } @@ -8088,6 +8562,7 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app // Color correction LUT S32 cglut_channel = -1; + S32 tone_curve_channel = -1; if (color_grade) { // Per-section bypass. Uploading a group's identity rather than its @@ -8166,6 +8641,8 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app static LLCachedControl split_midtone_amount(gSavedSettings, "RenderSplitToneMidtoneAmount", 0.f); static LLCachedControl split_balance(gSavedSettings, "RenderSplitToneBalance", 0.f); static LLCachedControl split_amount(gSavedSettings, "RenderSplitToneAmount", 0.f); + static LLCachedControl split_shadow_width(gSavedSettings, "RenderSplitToneShadowWidth", 0.35f); + static LLCachedControl split_highlight_width(gSavedSettings, "RenderSplitToneHighlightWidth", 0.35f); constexpr F32 CG_LUMA_R = 0.2126f, CG_LUMA_G = 0.7152f, CG_LUMA_B = 0.0722f; auto tint_to_ratio = [](const LLColor3& tint, F32 out[3]) { @@ -8179,12 +8656,17 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app tint_to_ratio(split_shadow_tint(), shadow_ratio); tint_to_ratio(split_highlight_tint(), highlight_ratio); tint_to_ratio(split_midtone_tint(), midtone_ratio); - const F32 tone_balance = llclamp(split_balance(), -1.0f, 1.0f); + // Ramps as {scale, bias}. The balance -> mid map and the width floor + // live in ALCurveModel, so the Lightbox graph draws what this uploads. + const F32 tone_mid = ALCurveModel::splitToneMid(split_balance()); + const ALCurveModel::SplitToneRamp shadow_ramp = ALCurveModel::splitToneShadowRamp(tone_mid, split_shadow_width()); + const ALCurveModel::SplitToneRamp highlight_ramp = ALCurveModel::splitToneHighlightRamp(tone_mid, split_highlight_width()); shader->uniform3fv(LLShaderMgr::SPLIT_TONE_SHADOW_RATIO, 1, shadow_ratio); shader->uniform3fv(LLShaderMgr::SPLIT_TONE_HIGHLIGHT_RATIO, 1, highlight_ratio); shader->uniform3fv(LLShaderMgr::SPLIT_TONE_MIDTONE_RATIO, 1, midtone_ratio); shader->uniform1f(LLShaderMgr::SPLIT_TONE_MIDTONE_AMOUNT, skip_split ? 0.f : llclamp(split_midtone_amount(), 0.0f, 1.0f)); - shader->uniform1f(LLShaderMgr::SPLIT_TONE_MID, 0.5f + tone_balance * 0.4f); + shader->uniform2f(LLShaderMgr::SPLIT_TONE_SHADOW_RAMP, shadow_ramp.mScale, shadow_ramp.mBias); + shader->uniform2f(LLShaderMgr::SPLIT_TONE_HIGHLIGHT_RAMP, highlight_ramp.mScale, highlight_ramp.mBias); shader->uniform1f(LLShaderMgr::SPLIT_TONE_AMOUNT, skip_split ? 0.f : llclamp(split_amount(), 0.0f, 1.0f)); // --- Display-space grading --- @@ -8219,25 +8701,34 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app shader->uniform1f(LLShaderMgr::COLOR_GRADE_VIBRANCE, skip_basic ? 0.f : llclamp(cg_vibrance(), -1.0f, 1.0f)); shader->uniform1f(LLShaderMgr::COLOR_GRADE_HUE_SHIFT_NORM, skip_basic ? 0.f : llclamp(cg_hue_shift(), -180.0f, 180.0f) / 360.0f); - // Per-channel filmic curves. Per-channel `(shoulder - toe)` is - // pre-inverted on the CPU so the shader avoids three divisions. - static LLCachedControl cg_curve_toe(gSavedSettings, "RenderColorGradeCurveToe", LLColor3(0.f, 0.f, 0.f)); - static LLCachedControl cg_curve_shoulder(gSavedSettings, "RenderColorGradeCurveShoulder", LLColor3(1.f, 1.f, 1.f)); - static LLCachedControl cg_curve_strength(gSavedSettings, "RenderColorGradeCurveStrength", LLColor3(0.f, 0.f, 0.f)); - const LLColor3 curve_toe = cg_curve_toe(); - const LLColor3 curve_shoulder = cg_curve_shoulder(); - const LLColor3 curve_strength = cg_curve_strength(); - const F32 curve_inv_range[3] = { - 1.0f / llmax(curve_shoulder.mV[0] - curve_toe.mV[0], 1e-4f), - 1.0f / llmax(curve_shoulder.mV[1] - curve_toe.mV[1], 1e-4f), - 1.0f / llmax(curve_shoulder.mV[2] - curve_toe.mV[2], 1e-4f) }; - const F32 curve_strength_arr[3] = { - llclamp(curve_strength.mV[0], 0.0f, 1.0f), - llclamp(curve_strength.mV[1], 0.0f, 1.0f), - llclamp(curve_strength.mV[2], 0.0f, 1.0f) }; - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_TOE, 1, curve_toe.mV); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_INV_RANGE, 1, curve_inv_range); - shader->uniform3fv(LLShaderMgr::COLOR_GRADE_CURVE_STRENGTH, 1, skip_curve ? IDENTITY_ZEROS : curve_strength_arr); + // --- Tone curve (step 12) --- + // Baked into mToneCurveLut by bakeToneCurveLut when a curve setting + // commits; per frame this is a bind and three uniforms. Amount 0 is + // the shader's fast path and doubles as the bypass identity, so a + // bypassed section, an all-identity stack, a zero Amount and a + // missing texture all land there with nothing bound -- the same + // shape as the 3D LUT above. + static LLCachedControl cg_curve_amount(gSavedSettings, "RenderColorGradeCurveAmount", 1.f); + const F32 curve_amount = llclamp(cg_curve_amount(), 0.f, 1.f); + const bool curve_active = !skip_curve && !mToneCurveIdentity && mToneCurveLut != 0 && curve_amount > 0.f; + if (curve_active) + { + tone_curve_channel = shader->enableTexture(LLShaderMgr::COLOR_GRADE_CURVE_LUT); + if (tone_curve_channel > -1) + { + // Clamp: a lookup table must saturate past its ends (see bindBrdfLut). + gGL.getTextureSlot(tone_curve_channel)->bindManual(ALTextureSlot::TT_TEXTURE, mToneCurveLut, + gGL.getSampler(ALSamplers::BilinearClamp)); + } + F32 lut_scale, lut_bias; + ALToneCurveSet::lutScaleBias(ALToneCurveSet::LUT_SIZE, lut_scale, lut_bias); + shader->uniform2f(LLShaderMgr::COLOR_GRADE_CURVE_LUT_SCALE, lut_scale, lut_bias); + shader->uniform1f(LLShaderMgr::COLOR_GRADE_CURVE_AMOUNT, curve_amount); + } + else + { + shader->uniform1f(LLShaderMgr::COLOR_GRADE_CURVE_AMOUNT, 0.f); // fast path; sampler unread + } } mScreenTriangleVB->setBuffer(); @@ -8247,17 +8738,29 @@ void LLPipeline::colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool app { mCGLut->unbind(cglut_channel); } + if (dirt_channel > -1) + { + gGL.getTextureSlot(dirt_channel)->unbind(); + } + if (tone_curve_channel > -1) + { + gGL.getTextureSlot(tone_curve_channel)->unbind(); + } if (exposure_channel > -1) { gGL.getTextureSlot(exposure_channel)->unbind(); } + if (cross_channel > -1) + { + gGL.getTextureSlot(cross_channel)->unbind(); + } if (bloom_channel > -1) { gGL.getTextureSlot(bloom_channel)->unbind(); } - if (depth_channel > -1) + if (state_channel > -1) { - gGL.getTextureSlot(depth_channel)->unbind(); + gGL.getTextureSlot(state_channel)->unbind(); } gGL.getTextureSlot(diffuse_channel)->unbind(); shader->unbind(); @@ -8391,15 +8894,24 @@ void LLPipeline::generateGlow(LLRenderTarget* src) // HDR bloom pyramid: threshold-extract into mBloomMip[0], downsample down the // pyramid with a Karis-averaged 13-tap filter, then upsample back with an // additive 3x3 tent filter. Halation is carried alongside in the alpha channel. +bool LLPipeline::bloomHDRReady() const +{ + return mRT->bloomMipCount >= 3 && + gBloomExtractProgram.isComplete() && + gBloomDownsampleProgram.isComplete() && + gBloomDownsampleFirstProgram.isComplete() && + gBloomUpsampleProgram.isComplete(); +} + void LLPipeline::generateBloomHDR(LLRenderTarget* src) { LL_PROFILE_GPU_ZONE("bloom hdr generate"); - if (mRT->bloomMipCount < 3 || - !gBloomExtractProgram.isComplete() || - !gBloomDownsampleProgram.isComplete() || - !gBloomDownsampleFirstProgram.isComplete() || - !gBloomUpsampleProgram.isComplete()) + // Whatever streaks were in mWaterDis's quadrants belong to a previous + // frame; colorCorrect only reads them if this frame draws new ones. + mRT->crossFilterReady = false; + + if (!bloomHDRReady()) { return; } @@ -8476,6 +8988,220 @@ void LLPipeline::generateBloomHDR(LLRenderTarget* src) } } + // ---- Cross-screen (star) filter --------------------------------------- + // + // Streaks every thresholded highlight, the way an etched glass filter + // diffracts any bright point in frame. Distinct from the lens flare + // starburst, which is locked to the sun and drawn procedurally around it. + // + // Split across the upsample chain on purpose: the streak input has to be + // read *before* the upsample walk mutates the mips, but the result has to + // be added *after* it, or the walk would smear the streaks back through + // the pyramid. + static LLCachedControl streak_strength_setting(gSavedSettings, "RenderCrossFilterStrength", 0.f); + static LLCachedControl cross_points(gSavedSettings, "RenderCrossFilterPoints", 4); + static LLCachedControl cross_angle(gSavedSettings, "RenderCrossFilterAngle", 0.f); + static LLCachedControl cross_length(gSavedSettings, "RenderCrossFilterLength", 1.f); + static LLCachedControl cross_falloff(gSavedSettings, "RenderCrossFilterFalloff", 1.5f); + static LLCachedControl cross_chromatic(gSavedSettings, "RenderCrossFilterChromatic", 0.f); + + // Streaks ride the bloom pyramid and are scaled by bloom strength where + // they are composited, so at strength 0 they are invisible -- and the whole + // twelve-draw chain was still running to produce them. Folding the bloom + // strength into the gate reuses the release path below rather than adding a + // second one. + // + // Only the streaks, not the pyramid: generateLuminance binds bloomMip[0] as + // the emissive term for auto-exposure, so skipping the pyramid would meter + // the scene against a stale buffer. + static LLCachedControl bloom_strength_gate(gSavedSettings, "RenderBloomStrength", 0.325f); + + const F32 streak_strength = (no_post || bloom_strength_gate() <= 0.f) + ? 0.f + : llclamp(streak_strength_setting(), 0.f, CROSS_FILTER_MAX_STRENGTH); + const bool streaks_on = (streak_strength > 0.f) && gCrossFilterProgram.isComplete(); + bool streaks_ready = false; + + if (streaks_on) + { + // Streak from mip 0, which at this point in the pass still holds the + // raw thresholded extract: the downsample chain writes mips 1 and up + // and leaves mip 0 untouched, so it is the sharpest and cleanest + // "which pixels are bright" answer available. Not a lower mip to save + // fill: mip 1 is a 13-tap downsample, so the highlight being streaked + // is already a blob before the streak starts, and a streak can be no + // thinner than the point it is drawn from. + // + // Cost scales with RenderBloomResolutionScale, which sizes the whole + // pyramid -- lowering it makes the streaks cheaper and softer together. + // + // Twelve passes at four arms is a lot of fill at full resolution, and + // quartering the area is the cheapest lever that does not touch arm + // count or reach. The source stays mip 0, so the *point* being streaked + // is still the sharp extract rather than a pre-blurred mip -- what is + // lost is arm resolution, not arm origin. + // + // The first pass gets a proper box downsample for free: a half-res texel + // centre lands exactly on the corner between two full-res texels, so the + // bilinear fetch averages the 2x2 group rather than point-sampling it. + const U32 streak_w = llmax(1u, mRT->bloomMip[0].getWidth() / 2); + const U32 streak_h = llmax(1u, mRT->bloomMip[0].getHeight() / 2); + + // Three buffers, not two: each arm needs its own ping-pong chain, and + // the arms have to accumulate somewhere that is neither the chain's + // scratch nor its source. Accumulating straight into bloomMip[0] would + // work for the first arm and then feed the second arm its own output. + // + // None of the three is a target of its own. Each is half the pyramid + // base, so all three fit as quadrants of one full-frame target, and + // mWaterDis is that target: the water passes copy the scene into it + // afresh each frame and read it back before post begins, so from here + // until the next frame it is idle -- depth of field borrowed it for + // its sharp copy a few passes ago and is finished with it. RGBA16F + // under HDR, which is the only path with a pyramid. Sampling a + // quadrant rather than a texture is the one thing the shader has to + // know about, through uCrossRegion and uCrossClamp. + // + // Always fits: the pyramid base is at most the screen, and mWaterDis + // is the screen. Checked anyway, so a future resolution divisor or + // multiplier that breaks the assumption disables the effect rather + // than drawing over the wrong quadrant. + streaks_ready = mWaterDis.isComplete() + && (2 * streak_w <= mWaterDis.getWidth()) + && (2 * streak_h <= mWaterDis.getHeight()); + + if (streaks_ready) + { + const F32 angle_rad = llclamp(cross_angle(), 0.f, 360.f) * DEG_TO_RAD; + const S32 arms = llclamp(cross_points(), 2, 12); + + gCrossFilterProgram.bind(); + + // Base step in texels of the streak target, near 1 by design: it + // multiplies every offset, so at 2 the chain lands on even texels + // only and real gaps open between them. Reach comes from the three + // quadrupling passes (0..63 texels), not from scaling this up. + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_LENGTH, llclamp(cross_length(), 0.25f, 2.f)); + + // Falloff is authored as a 0..1.5 tightness and converted here to + // the exponential base the shader wants, rather than exposing the + // base: weights are pow(base, -step_index) and step_index reaches + // 63 across the chain, so a base of 1.5 attenuates the far taps by + // 1e-11 and everything usable lives between 1.0 and roughly 1.1. + // This maps the whole slider onto that band: the value is how many + // e-folds of brightness are lost between the core and the tip of + // an arm, over six. + const F32 tightness = llclamp(cross_falloff(), 0.1f, 3.f); + // The chain's exact reach, TAPS^3 - 1, derived from the same constant + // the shader compiles against -- see CROSS_FILTER_TAPS. + const F32 max_step = (F32)(CROSS_FILTER_TAPS * CROSS_FILTER_TAPS * CROSS_FILTER_TAPS - 1); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_FALLOFF, expf(tightness * 6.f / max_step)); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_CHROMATIC, llclamp(cross_chromatic(), 0.f, 1.f)); + + // One three-pass chain per arm, each strictly one-sided. + // + // Streaking every direction in a single pass is what produced the + // spikes: a tap could run forward in one pass and backward in the + // next, so net offsets became +/-i +/-4j +/-16k with independent + // signs and their weights tracked how far the path travelled rather + // than where it ended. Per-arm chains restore the base-4 tiling the + // whole construction depends on. + for (S32 arm = 0; arm < arms; ++arm) + { + const F32 theta = angle_rad + (2.f * F_PI * (F32)arm) / (F32)arms; + const F32 dir_x = cosf(theta); + const F32 dir_y = sinf(theta); + gCrossFilterProgram.uniform2f(LLShaderMgr::CROSS_DIR, dir_x, dir_y); + + // Pass 0 reads mip 0 whole; passes 1 and 2 read the previous + // pass's quadrant. Every pass writes a quadrant. + LLRenderTarget* sources[3] = { &mRT->bloomMip[0], &mWaterDis, &mWaterDis }; + const S32 source_quads[3] = { -1, CROSS_FILTER_SCRATCH_A, CROSS_FILTER_SCRATCH_B }; + const S32 dest_quads[3] = { CROSS_FILTER_SCRATCH_A, CROSS_FILTER_SCRATCH_B, CROSS_FILTER_ACCUMULATOR }; + const F32 scales[3] = { 1.f, (F32)CROSS_FILTER_TAPS, + (F32)(CROSS_FILTER_TAPS * CROSS_FILTER_TAPS) }; + + auto bind_quadrant = [&](S32 quad) + { + const CrossFilterRegion region = cross_filter_region(quad, streak_w, streak_h, mWaterDis); + mWaterDis.bindTarget(); + glViewport((GLint)region.mPixelX, (GLint)region.mPixelY, (GLsizei)streak_w, (GLsizei)streak_h); + }; + + auto streak_pass = [&](S32 pass) + { + LLRenderTarget* src = sources[pass]; + + gCrossFilterProgram.bindTexture(LLShaderMgr::DIFFUSE_MAP, src, ALSamplers::BilinearClamp); + + // Where the source sits in its texture. A whole texture is + // the identity region with no clamp of its own: the + // sampler's edge clamp does that job, as it always did. + CrossFilterRegion region; + if (source_quads[pass] >= 0) + { + region = cross_filter_region(source_quads[pass], streak_w, streak_h, mWaterDis); + } + gCrossFilterProgram.uniform4f(LLShaderMgr::CROSS_REGION, + region.mOriginX, region.mOriginY, region.mScaleX, region.mScaleY); + gCrossFilterProgram.uniform2f(LLShaderMgr::CROSS_CLAMP, region.mClampX, region.mClampY); + // Always the *streak target's* texel, never the source's. + // The base-4 tiling only holds if every pass steps in the + // same unit, and pass 0 reads a full-resolution mip while + // the rest read half-resolution scratch -- using each + // source's own texel would double the stride midway through + // the chain and break the tiling that the whole + // construction depends on. + gCrossFilterProgram.uniform2f(LLShaderMgr::CROSS_TEXEL, + 1.f / (F32)streak_w, + 1.f / (F32)streak_h); + gCrossFilterProgram.uniform1f(LLShaderMgr::CROSS_PASS_SCALE, scales[pass]); + + mScreenTriangleVB->setBuffer(); + mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + }; + + // Two scratch passes overwrite; blending stays off. No clear: + // the fullscreen triangle writes every texel of the quadrant + // with blending disabled, so a clear would be pure redundant + // fill. + { + LLGLDisable blend(GL_BLEND); + for (S32 pass = 0; pass < 2; ++pass) + { + bind_quadrant(dest_quads[pass]); + streak_pass(pass); + mWaterDis.flush(); + } + } + + // The arm's last pass adds into the shared accumulator -- except + // the first, which overwrites it. The fullscreen triangle covers + // every texel, so arm 0 establishes the buffer and a clear would + // be the same redundant fill the scratch passes avoid. It does + // couple correctness to the first iteration running, which holds + // because `arms` is clamped to at least 2 above. + { + LLGLState blend(GL_BLEND, arm > 0); + gGL.setSceneBlendType(LLRender::BT_ADD); + + bind_quadrant(dest_quads[2]); + streak_pass(2); + mWaterDis.flush(); + + gGL.setSceneBlendType(LLRender::BT_ALPHA); + } + } + + gCrossFilterProgram.unbind(); + + // Tell colorCorrect where to look. + mRT->crossFilterReady = true; + mRT->crossFilterWidth = streak_w; + mRT->crossFilterHeight = streak_h; + } + } + // Upsample chain: mip[i] -> mip[i-1] with additive blend. Walks from the // smallest mip back up to mip 0, leaving the final bloom in mBloomMip[0]. { @@ -8506,12 +9232,18 @@ void LLPipeline::generateBloomHDR(LLRenderTarget* src) gBloomUpsampleProgram.unbind(); gGL.setSceneBlendType(LLRender::BT_ALPHA); } + + // The summed arms stay in their quadrant of mWaterDis. colorCorrect samples + // them alongside the pyramid, so there is no composite pass here to write + // them into mip 0. } // Composite the bloom pyramid (mBloomMip[0]) additively into the pre-tonemap // scene buffer. Halation rides in the alpha channel and is tinted at composite. -// The main render path folds this into colorCorrectF (BLOOM_COMPOSITE); this -// function is retained for standalone use (e.g. offline capture paths). +// The main render path folds this into colorCorrectF (BLOOM_COMPOSITE). This +// function has never had a caller anywhere in the tree; it is kept as a +// standalone equivalent, but note it is no longer equivalent -- cross-filter +// streaks are composited only in colorCorrectF, so this path would drop them. void LLPipeline::compositeBloomHDR(LLRenderTarget* scene) { LL_PROFILE_GPU_ZONE("bloom hdr composite"); @@ -8904,7 +9636,22 @@ void LLPipeline::combineGlow(LLRenderTarget* src, LLRenderTarget* dst) dst->flush(); } -void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) +// Depth of field, run pre-tonemap on linear HDR and in place on mRT->screen. +// +// The three passes are: CoF (sharp copy + signed circle-of-confusion packed +// into alpha), a reduced-resolution gather blur, then a combine that mixes the +// two by CoF. The combine writes back into mRT->screen under +// setColorMask(true, false). +// +// That mask is the whole reason this can run before the tonemapper without +// touching a shader. mRT->screen.a carries the legacy alpha-tagged prim glow, +// which bloomExtractF reads in the HDR path and -- after colorCorrect passes +// alpha straight through -- glowExtractF reads as its *only* live key in the +// non-HDR path. dofCombineF's alpha output is CoF-flavoured garbage, so letting +// it land would feed circle-of-confusion into the glow key on every frame. +// Masking alpha off preserves prim glow exactly and leaves every alpha contract +// in the chain unchanged. +void LLPipeline::renderDoF() { LL_PROFILE_GPU_ZONE("dof"); { @@ -8922,6 +9669,45 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) { LLGLDisable blend(GL_BLEND); + // Scratch is borrowed rather than owned. Both passes below run + // inside renderFinalize, where two of the frame's full-size + // targets are idle: + // + // mWaterDis the water passes copy the scene into it afresh + // each frame and read it back before this point, so + // it carries nothing across frames and nothing reads + // it again until the next one. RGBA16F under HDR, + // which is what the sharp copy plus signed CoF in + // alpha wants; RGBA8 otherwise, the precision the + // pre-linear DoF had from deferredLight. + // + // bloomMip[0] holds last frame's bloom, which generateLuminance + // read at the top of renderFinalize and the extract + // pass overwrites after this. The pyramid's own + // R11F_G11F_B10F (RGBA16F with halation), and the + // blur carries no alpha anyway. Only while the + // extract is actually going to run: a blur left in + // there otherwise would be composited as bloom. + // + // Without HDR there is no pyramid, so the blur goes to postPongMap + // (RGBA8, idle until the AA chain). Together this is what keeps DoF + // at zero full-frame targets of its own: two would be 44 MB at + // 1440p. + static LLCachedControl has_hdr(gSavedSettings, "RenderHDREnabled", true); + const bool hdr = gGLManager.mGLVersion > 4.05f && has_hdr(); + + LLRenderTarget* sharp = &mWaterDis; + LLRenderTarget* blur = hdr ? (bloomHDRReady() ? &mRT->bloomMip[0] : nullptr) + : &mRT->postPongMap; + + if (!blur || !blur->isComplete() || !sharp->isComplete()) + { + // Nothing safe to borrow this frame -- a bloom shader failed + // to build, or the targets are mid-reallocation. Skip the + // effect rather than draw into something another pass reads. + return; + } + // depth of field focal plane calculations static F32 current_distance = 16.f; static F32 start_distance = 16.f; @@ -9030,17 +9816,17 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) blur_constant /= 1000.f; // convert to meters for shader F32 magnification = focal_length / (subject_distance - focal_length); - { // build diffuse+bloom+CoF - mRT->deferredLight.bindTarget(); + { // build sharp copy + CoF + sharp->bindTarget(); gDeferredCoFProgram.bind(); - gDeferredCoFProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); + gDeferredCoFProgram.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->screen, ALSamplers::PointMirror); gDeferredCoFProgram.bindDepthTexture(LLShaderMgr::DEFERRED_DEPTH, &mRT->deferredScreen); gDeferredCoFProgram.uniform1f(LLShaderMgr::DEFERRED_DEPTH_CUTOFF, RenderEdgeDepthCutoff); gDeferredCoFProgram.uniform1f(LLShaderMgr::DEFERRED_NORM_CUTOFF, RenderEdgeNormCutoff); - gDeferredCoFProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + gDeferredCoFProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_FOCAL_DISTANCE, -subject_distance / 1000.f); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_BLUR_CONSTANT, blur_constant); gDeferredCoFProgram.uniform1f(LLShaderMgr::DOF_TAN_PIXEL_ANGLE, tanf(1.f / LLDrawable::sCurPixelAngle)); @@ -9051,66 +9837,180 @@ void LLPipeline::renderDoF(LLRenderTarget* src, LLRenderTarget* dst) mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); gDeferredCoFProgram.unbind(); - mRT->deferredLight.flush(); - } - - U32 dof_width = (U32)(mRT->screen.getWidth() * CameraDoFResScale); - U32 dof_height = (U32)(mRT->screen.getHeight() * CameraDoFResScale); - - { // perform DoF sampling at half-res (preserve alpha channel) - src->bindTarget(); + sharp->flush(); + } + + // The blur runs at CameraDoFResScale of the screen, in a corner of + // the borrowed target. Under a reduced RenderBloomResolutionScale + // mip 0 is smaller than the screen, so the viewport is clamped to + // what it can hold and the combine is told the fraction the blur + // actually covers; the only effect of the clamp is a softer blur. + const U32 dof_width = llmax(1u, llmin((U32)(mRT->screen.getWidth() * CameraDoFResScale), blur->getWidth())); + const U32 dof_height = llmax(1u, llmin((U32)(mRT->screen.getHeight() * CameraDoFResScale), blur->getHeight())); + const F32 res_scale = llmin((F32)dof_width / (F32)mRT->screen.getWidth(), + (F32)dof_height / (F32)mRT->screen.getHeight()); + + { // gather blur at CameraDoFResScale into the borrowed scratch + // Writes to a separate target rather than in place, so no + // colour mask is needed to preserve alpha: the CoF it reads + // lives in the sharp copy's alpha. + blur->bindTarget(); glViewport(0, 0, dof_width, dof_height); - gGL.setColorMask(true, false); - static LLCachedControl RenderDepthOfFieldNearBlur(gSavedSettings, "RenderDepthOfFieldNearBlur", false); - LLGLSLShader& post_program = RenderDepthOfFieldNearBlur ? gDeferredPostProgram : gDeferredPostProgramNoNear; + + // Shaped aperture, anamorphic deformation, optical vignetting, + // defocus fringing and the two aberrations all live in the + // innermost sample loop, + // so they are compiled out rather than branched over. One + // define covers them all: the shaped variant is bound only + // when at least one is actually doing something, and within it + // each gates on its own uniform the way the lens flare's + // sub-effects do. `shaped` below must stay in lockstep with + // the effects inside the shader's DOF_SHAPED block -- an + // effect missing from it is a dead control whenever it is the + // only one active. + static LLCachedControl bokeh_blades(gSavedSettings, "RenderBokehApertureBlades", 0); + static LLCachedControl bokeh_rotation(gSavedSettings, "RenderBokehApertureRotation", 0.f); + static LLCachedControl bokeh_curvature(gSavedSettings, "RenderBokehApertureCurvature", 0.f); + static LLCachedControl bokeh_anamorphic(gSavedSettings, "RenderBokehAnamorphicSqueeze", 1.f); + static LLCachedControl bokeh_cat_eye(gSavedSettings, "RenderBokehCatEyeAmount", 0.f); + static LLCachedControl bokeh_fringe(gSavedSettings, "RenderBokehFringeAmount", 0.f); + static LLCachedControl bokeh_fringe_near(gSavedSettings, "RenderBokehFringeNearTint", LLColor3(1.f, 0.85f, 1.f)); + static LLCachedControl bokeh_fringe_far(gSavedSettings, "RenderBokehFringeFarTint", LLColor3(0.85f, 1.f, 0.9f)); + static LLCachedControl bokeh_spherical(gSavedSettings, "RenderBokehSphericalAberration", 0.f); + static LLCachedControl bokeh_field(gSavedSettings, "RenderBokehFieldStretch", 0.f); + static LLCachedControl bokeh_field_falloff(gSavedSettings, "RenderBokehFieldFalloff", 2.f); + static LLCachedControl bokeh_coma(gSavedSettings, "RenderBokehComaAsymmetry", 0.f); + + const S32 blades = llclamp(bokeh_blades(), 0, 11); + const F32 cat_eye = llclamp(bokeh_cat_eye(), 0.f, 1.5f); + const F32 fringe = llclamp(bokeh_fringe(), 0.f, 1.f); + const F32 squeeze = llclamp(bokeh_anamorphic(), 0.25f, 4.f); + const F32 spherical = llclamp(bokeh_spherical(), -1.f, 1.f); + const F32 field = llclamp(bokeh_field(), -1.f, 1.f); + const F32 coma = llclamp(bokeh_coma(), 0.f, 1.f); + const bool anamorphic = (squeeze < 0.999f) || (squeeze > 1.001f); + // Comatic asymmetry earns its place here even though it reads + // like a modifier: it biases the disc along the field + // direction, which exists whether or not anything stretched + // it, so it is a standalone effect rather than a shape control + // for the stretch. RenderBokehFieldFalloff genuinely is one and + // is deliberately absent. + const bool shaped = (blades >= 3) || (cat_eye > 0.f) || (fringe > 0.f) || anamorphic + || (spherical != 0.f) || (field != 0.f) || (coma > 0.f); + + LLGLSLShader& post_program = RenderDepthOfFieldNearBlur + ? (shaped ? gDeferredPostProgramShaped : gDeferredPostProgram) + : (shaped ? gDeferredPostProgramNoNearShaped : gDeferredPostProgramNoNear); post_program.bind(); - post_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, &mRT->deferredLight, ALSamplers::PointMirror); + post_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, sharp, ALSamplers::PointMirror); - post_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + post_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); post_program.uniform1f(LLShaderMgr::DOF_MAX_COF, CameraMaxCoF); - post_program.uniform1f(LLShaderMgr::DOF_RES_SCALE, CameraDoFResScale); + + // Gather weighting. Defaults are a plain energy-conserving + // average plus a firefly ceiling; the highlight boost is + // opt-in. See the note above dofSample for why the old + // `0.25 + r+g+b` weight could not survive the move to linear. + static LLCachedControl bokeh_threshold(gSavedSettings, "RenderBokehHighlightThreshold", 0.f); + static LLCachedControl bokeh_gain(gSavedSettings, "RenderBokehHighlightGain", 0.f); + static LLCachedControl bokeh_clamp(gSavedSettings, "RenderBokehHighlightClamp", 64.f); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_THRESHOLD, llmax(bokeh_threshold(), 0.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_GAIN, llmax(bokeh_gain(), 0.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_HIGHLIGHT_CLAMP, llmax(bokeh_clamp(), 0.f)); + + // Shaped-aperture uniforms. Skipped entirely for the unshaped + // programs, where they are not in the linked binary anyway -- + // the setters would no-op, but the sector bake would still run. + if (shaped) + { + // A regular N-gon's inscribed radius at angle theta is + // cos(pi/N) / cos(mod(theta + rot, 2pi/N) - pi/N) + // so the sector geometry is baked once here and the loop is + // left with the single cosine it genuinely needs per sample. + const F32 sides = (F32)llmax(blades, 3); + const F32 half_sector = F_PI / sides; + post_program.uniform1i(LLShaderMgr::BOKEH_BLADES, blades); + post_program.uniform1f(LLShaderMgr::BOKEH_APERTURE_ROTATION, + llclamp(bokeh_rotation(), 0.f, 360.f) * DEG_TO_RAD); + post_program.uniform1f(LLShaderMgr::BOKEH_APERTURE_CURVATURE, llclamp(bokeh_curvature(), 0.f, 1.f)); + post_program.uniform3f(LLShaderMgr::BOKEH_APERTURE_CONST, + half_sector, 2.f * half_sector, cosf(half_sector)); + // Anamorphic stretch, sent area-preserving: the two axes + // multiply to 1, so the slider changes the shape of the + // blur without also changing how much of it there is. + // Above 1 is taller than wide, the classic anamorphic oval; + // below 1 is wider than tall. + const F32 anam_root = sqrtf(squeeze); + post_program.uniform2f(LLShaderMgr::BOKEH_ANAMORPHIC, 1.f / anam_root, anam_root); + post_program.uniform1f(LLShaderMgr::BOKEH_CAT_EYE, cat_eye); + post_program.uniform1f(LLShaderMgr::BOKEH_FRINGE_AMOUNT, fringe); + post_program.uniform3fv(LLShaderMgr::BOKEH_FRINGE_NEAR_TINT, 1, bokeh_fringe_near().mV); + post_program.uniform3fv(LLShaderMgr::BOKEH_FRINGE_FAR_TINT, 1, bokeh_fringe_far().mV); + + // Aberrations. Spherical goes up raw: the shader folds in + // both the sign of the circle of confusion and the + // blur-size fade, because both depend on the fragment + // rather than on the frame. + post_program.uniform1f(LLShaderMgr::BOKEH_SPHERICAL, spherical); + post_program.uniform1f(LLShaderMgr::BOKEH_FIELD_STRETCH, field); + post_program.uniform1f(LLShaderMgr::BOKEH_FIELD_FALLOFF, + llclamp(bokeh_field_falloff(), 1.f, 4.f)); + post_program.uniform1f(LLShaderMgr::BOKEH_COMA_ASYMMETRY, coma); + } mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); post_program.unbind(); - src->flush(); - gGL.setColorMask(true, true); + blur->flush(); } - { // combine result based on alpha + { // combine result based on alpha, back into the scene buffer static LLCachedControl RenderDepthOfFieldNearBlur(gSavedSettings, "RenderDepthOfFieldNearBlur", false); LLGLSLShader& combine_program = RenderDepthOfFieldNearBlur ? gDeferredDoFCombineProgram : gDeferredDoFCombineProgramNoNear; - dst->bindTarget(); - glViewport(0, 0, dst->getWidth(), dst->getHeight()); + mRT->screen.bindTarget(); + glViewport(0, 0, mRT->screen.getWidth(), mRT->screen.getHeight()); + + // Colour only. See the note above renderDoF: screen.a is the + // prim-glow tag, and dofCombineF's alpha is CoF garbage. + gGL.setColorMask(true, false); combine_program.bind(); - combine_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, ALSamplers::PointMirror); - combine_program.bindTexture(LLShaderMgr::DEFERRED_LIGHT, &mRT->deferredLight, ALSamplers::PointMirror); + combine_program.bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, blur, ALSamplers::PointMirror); + combine_program.bindTexture(LLShaderMgr::DEFERRED_LIGHT, sharp, ALSamplers::PointMirror); - combine_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)dst->getWidth(), (GLfloat)dst->getHeight()); + combine_program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)mRT->screen.getWidth(), (GLfloat)mRT->screen.getHeight()); combine_program.uniform1f(LLShaderMgr::DOF_MAX_COF, CameraMaxCoF); - combine_program.uniform1f(LLShaderMgr::DOF_RES_SCALE, CameraDoFResScale); - combine_program.uniform1f(LLShaderMgr::DOF_WIDTH, (dof_width - 1) / (F32)src->getWidth()); - combine_program.uniform1f(LLShaderMgr::DOF_HEIGHT, (dof_height - 1) / (F32)src->getHeight()); + // The fraction of the screen the blur covers, for the CoF + // arithmetic -- CameraDoFResScale unless the clamp above bit. + combine_program.uniform1f(LLShaderMgr::DOF_RES_SCALE, res_scale); + // Where the blur sits inside the borrowed texture: the scale + // that maps a screen coordinate onto it, and the extent it is + // clamped to. Both normalised against the target the blur + // actually rendered into, which is no longer screen-sized -- + // mip 0 shrinks with RenderBloomResolutionScale. + combine_program.uniform2f(LLShaderMgr::DOF_UV_SCALE, + (F32)dof_width / (F32)blur->getWidth(), + (F32)dof_height / (F32)blur->getHeight()); + combine_program.uniform1f(LLShaderMgr::DOF_WIDTH, (dof_width - 1) / (F32)blur->getWidth()); + combine_program.uniform1f(LLShaderMgr::DOF_HEIGHT, (dof_height - 1) / (F32)blur->getHeight()); mScreenTriangleVB->setBuffer(); mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); combine_program.unbind(); - dst->flush(); + mRT->screen.flush(); + gGL.setColorMask(true, true); } } - else - { - copyRenderTarget(src, dst); - } + // No else: the pass is in place on mRT->screen, so when DoF is off + // there is nothing to copy and nothing to swap. } } @@ -9144,26 +10044,71 @@ void LLPipeline::renderFinalize() generateLuminance(&mRT->screen, &mLuminanceMap); generateExposure(&mLuminanceMap, &mExposureMap); - - // HDR bloom runs pre-tonemap against the linear scene buffer. The pyramid - // is generated here; the additive composite is folded into colorCorrect's - // tonemap variants (BLOOM_COMPOSITE permutation) so we avoid a separate - // fullscreen pass over the scene buffer. The legacy alpha-tagged prim-glow - // signal is carried into the extract pass, so prim glow survives the - // migration. compositeBloomHDR is preserved for standalone use cases. - generateBloomHDR(&mRT->screen); } + // Lens flare sun state (see lensFlareStateF.glsl), in both HDR paths. + generateLensFlareState(&mRT->screen); + // Read any pending scene probe here, while the buffer still holds linear - // radiance. One line later it has been white balanced, graded and - // tonemapped, and a sample taken then would describe the grade rather than - // the scene -- which is no use to a tool whose whole job is to decide what - // the grade should be. + // radiance and is still sharp. One line later it has been white balanced, + // graded and tonemapped, and a sample taken then would describe the grade + // rather than the scene -- which is no use to a tool whose whole job is to + // decide what the grade should be. It has to precede DoF for the same + // reason: a sample from a defocused pixel describes the blur, not the scene. serviceScenePixelProbe(&mRT->screen); + // Depth of field, in place on mRT->screen. + // + // Ahead of both the tonemapper and bloom. Pre-tonemap because gathering + // over display-space values is gathering over already-compressed + // highlights, which is why stock bokeh reads flat and why postDeferredF + // carried a weighting hack to fake the pop back. Pre-bloom because that is + // the optical order -- defocus happens at the aperture, veiling glare in + // the glass after it -- so a defocused highlight blooms as a soft disc + // instead of a sharp core floating on a blurred background. + // + // The residual, worth knowing before chasing it: the legacy alpha-tagged + // glow term reads the sharp glow tag over blurred RGB, so a defocused + // glowing prim's alpha-glow contribution hugs its sharp silhouette. The + // RGB-threshold term, which dominates in HDR, follows the blur correctly. + // If that reads badly in world, swapping this block with generateBloomHDR + // below restores bloom-first, at the cost of sharp bloom cores on + // defocused lights. + // + // SSR, luminance and exposure stay above deliberately: reflections keep + // their detail, and a blur conserves mean energy so metering is unaffected. + static LLCachedControl RenderDepthOfFieldInEditMode(gSavedSettings, "RenderDepthOfFieldInEditMode", false); + if (RenderDepthOfField && (RenderDepthOfFieldInEditMode || !LLToolMgr::getInstance()->inBuildMode()) && !gCubeSnapshot) + { + renderDoF(); + + // renderDoF calls setup3DViewport and runs a reduced viewport + // internally, so restore the world view before anything else draws. + gGLViewport[0] = gViewerWindow->getWorldViewRectRaw().mLeft; + gGLViewport[1] = gViewerWindow->getWorldViewRectRaw().mBottom; + gGLViewport[2] = gViewerWindow->getWorldViewRectRaw().getWidth(); + gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); + glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); + } + + if (hdr) + { + // HDR bloom runs pre-tonemap against the linear scene buffer -- now the + // defocused one. The pyramid is generated here; the additive composite + // is folded into colorCorrect's tonemap variants (BLOOM_COMPOSITE + // permutation) so we avoid a separate fullscreen pass over the scene + // buffer. The legacy alpha-tagged prim-glow signal is carried into the + // extract pass, so prim glow survives the migration. + generateBloomHDR(&mRT->screen); + } + // Handles tonemap, colorgrading, and gamma correction in one pass. In the HDR // path, this also applies eye adaptation and bloom. In the non-HDR path, this // is just a linear copy with color correction. + // Ahead of colorCorrect, which samples the plate, and outside the bloom + // block above because the dirt is lit by the lens flare as well as by bloom. + generateLensDirt(); + colorCorrect(&mRT->screen, &mRT->postPingMap, hdr, true); LLVertexBuffer::unbind(); @@ -9209,19 +10154,6 @@ void LLPipeline::renderFinalize() gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); - static LLCachedControl RenderDepthOfFieldInEditMode(gSavedSettings, "RenderDepthOfFieldInEditMode", false); - if (RenderDepthOfField && (RenderDepthOfFieldInEditMode || !LLToolMgr::getInstance()->inBuildMode()) && !gCubeSnapshot) - { - renderDoF(sourceBuffer, targetBuffer); - std::swap(sourceBuffer, targetBuffer); - } - - gGLViewport[0] = gViewerWindow->getWorldViewRectRaw().mLeft; - gGLViewport[1] = gViewerWindow->getWorldViewRectRaw().mBottom; - gGLViewport[2] = gViewerWindow->getWorldViewRectRaw().getWidth(); - gGLViewport[3] = gViewerWindow->getWorldViewRectRaw().getHeight(); - glViewport(gGLViewport[0], gGLViewport[1], gGLViewport[2], gGLViewport[3]); - // [RLVa:KB] - @setsphere if (RlvActions::hasBehaviour(RLV_BHVR_SETSPHERE)) { @@ -9301,6 +10233,205 @@ void LLPipeline::renderFinalize() // wanted. const bool clean_plate = gSnapshotNoPost; + // Lens distortion + // + // Precompute shader-friendly forms once on the CPU: fold the master + // amount into every coefficient, pre-reciprocate the squeeze, and + // solve the auto-fit scale. The shader is then one gate followed by + // pure polynomial evaluation -- no per-pixel divides, no solve. + // + // Sign convention, worth stating because it is easy to get backwards: + // this is a gather, so the shader asks "where does this output pixel + // come from". Under that map a coefficient below 1 pulls the sample + // toward the centre, which stretches the middle of the source out to + // the frame edge -- barrel. So negative k1 reads as barrel and + // positive as pincushion, matching a lens profile, and it is + // *pincushion* that pushes samples off the source and would show + // black corners without a fit. + static LLCachedControl distort_amount(gSavedSettings, "RenderLensDistortionAmount", 0.0f, "[0, 1] default 0."); + static LLCachedControl distort_k1(gSavedSettings, "RenderLensDistortionK1", -0.2f); + static LLCachedControl distort_k2(gSavedSettings, "RenderLensDistortionK2", 0.0f); + static LLCachedControl distort_squeeze(gSavedSettings, "RenderLensDistortionSqueeze", 1.0f); + static LLCachedControl distort_fit(gSavedSettings, "RenderLensDistortionFit", 1); + static LLCachedControl distort_center(gSavedSettings, "RenderLensDistortionCenter", LLVector3(0.f, 0.f, 0.f)); + static LLCachedControl distort_tangential(gSavedSettings, "RenderLensDistortionTangential", LLVector3(0.f, 0.f, 0.f)); + + const F32 distort = clean_plate ? 0.f : llclamp(distort_amount(), 0.f, 1.f); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::LENS_DISTORT_AMOUNT, distort); + + // Zeroing the master both hits the shader's early-out and skips the + // whole solve below, exactly as the lens flare does with its strength. + if (distort > 0.f) + { + // Every shape parameter fades with the master amount, each toward + // its own neutral: the polynomial coefficients and decentering + // toward 0, the squeeze toward 1. The squeeze included -- scaling + // only the coefficients would have the radial bend fade in + // smoothly while a non-neutral stretch snapped on in one frame. + const F32 k1 = llclamp(distort_k1(), -0.5f, 0.5f) * distort; + const F32 k2 = llclamp(distort_k2(), -0.25f, 0.25f) * distort; + const F32 p1 = llclamp(distort_tangential().mV[0], -0.05f, 0.05f) * distort; + const F32 p2 = llclamp(distort_tangential().mV[1], -0.05f, 0.05f) * distort; + const F32 cx = llclamp(distort_center().mV[0], -0.5f, 0.5f) * distort; + const F32 cy = llclamp(distort_center().mV[1], -0.5f, 0.5f) * distort; + const F32 squeeze = 1.f + (llclamp(distort_squeeze(), 0.5f, 2.5f) - 1.f) * distort; + + // Same aspect basis the shader uses, and the same one the CA path + // derives from uResolution -- one component stays 1.0 and the + // other carries the ratio, so radial distance is measured in + // physical units and the corners really are further out than the + // edge midpoints. + const F32 res_w = (F32)gViewerWindow->getWorldViewRectRaw().getWidth(); + const F32 res_h = (F32)gViewerWindow->getWorldViewRectRaw().getHeight(); + const F32 aspect = res_w / llmax(res_h, 1.f); + const F32 axis_x = llmax(aspect, 1.f); + const F32 axis_y = llmax(1.f / llmax(aspect, 1e-4f), 1.f); + const F32 sq_x = 1.f / squeeze; + const F32 sq_y = 1.f; + + // The shader's warp with the fit scale left at 1. Scale is a pure + // multiplier on the result, so solving for it afterwards is exact + // rather than iterative. + auto base_offset = [&](F32 u, F32 v, F32& out_x, F32& out_y) + { + const F32 qx = (u - 0.5f - cx) * axis_x; + const F32 qy = (v - 0.5f - cy) * axis_y; + const F32 r2 = qx * qx + qy * qy; + const F32 radial = 1.f + r2 * (k1 + r2 * k2); + const F32 tx = 2.f * p1 * qx * qy + p2 * (r2 + 2.f * qx * qx); + const F32 ty = p1 * (r2 + 2.f * qy * qy) + 2.f * p2 * qx * qy; + out_x = (qx * radial + tx) / axis_x * sq_x; + out_y = (qy * radial + ty) / axis_y * sq_y; + }; + + // Scale at which the ray from the optical axis along `base` leaves + // the source frame -- a two-slab exit test. Below that scale the + // sample is inside the image; above it, off the edge and black. + const F32 origin_x = 0.5f + cx; + const F32 origin_y = 0.5f + cy; + auto exit_scale = [&](F32 bx, F32 by) -> F32 + { + F32 best = 1e30f; + if (bx > 1e-6f || bx < -1e-6f) + { + best = llmin(best, ((bx > 0.f ? 1.f : 0.f) - origin_x) / bx); + } + if (by > 1e-6f || by < -1e-6f) + { + best = llmin(best, ((by > 0.f ? 1.f : 0.f) - origin_y) / by); + } + return best; + }; + + F32 fit_scale = 1.f; + const S32 fit_mode = llclamp(distort_fit(), 0, 2); + const LensDistortFit::Inputs inputs = { k1, k2, p1, p2, cx, cy, axis_x, axis_y, sq_x, fit_mode }; + if (inputs == mLensDistortFit.inputs) + { + fit_scale = mLensDistortFit.scale; + } + else if (fit_mode != 0) + { + // Walk the frame boundary densely instead of probing only the + // corners and edge midpoints. + // + // With a non-zero secondary coefficient the radial polynomial + // 1 + k1*r^2 + k2*r^4 stops being monotonic in r -- that + // non-monotonicity *is* the moustache bend -- so the largest + // outward displacement along an edge can fall between two + // sparse probes. Eight probes let that region escape the solve, + // which showed up as curved black arcs along the edges at + // extreme settings even in Fit mode. Dense sampling is also + // what keeps this honest once the tangential terms are + // non-zero, since those break the clean radial structure a + // corners-dominate argument leans on. + // + // Sixty-four evaluations of a short polynomial, once per change + // of the inputs above. + const S32 probes_per_edge = 16; + + // Fit (1): the smallest exit scale over every probe with a + // satisfiable constraint, so nothing that scaling can save + // ever goes black. Not quite "nothing goes black anywhere": + // with the optical axis pinned on the frame edge, fold-over + // can fling a pixel straight off that edge, and no positive + // scale brings it back -- Fit degrades to best-effort there + // rather than collapsing the whole frame chasing an + // impossible constraint. + // Fill (2): the largest, so every probe is reachable -- the + // whole source stays visible, at the cost of black corners. + F32 solved = (fit_mode == 1) ? 1e30f : 0.f; + + auto consider_probe = [&](F32 u, F32 v) + { + F32 bx, by; + base_offset(u, v, bx, by); + const F32 s = exit_scale(bx, by); + // >= 1e30 is no constraint at all (base ~ 0 near the + // optical axis); ~0 is the unsatisfiable case above. + if (s < 1e-4f || s >= 1e30f) + { + return; + } + solved = (fit_mode == 1) ? llmin(solved, s) : llmax(solved, s); + }; + + for (S32 edge = 0; edge < 4; ++edge) + { + for (S32 i = 0; i < probes_per_edge; ++i) + { + // t == 0 lands exactly on a corner, so all four corners + // are still probed; the rest subdivide each edge. + const F32 t = (F32)i / (F32)probes_per_edge; + switch (edge) + { + case 0: consider_probe(t, 0.f); break; // top + case 1: consider_probe(1.f, t); break; // right + case 2: consider_probe(1.f - t, 1.f); break; // bottom + default: consider_probe(0.f, 1.f - t); break; // left + } + } + } + + // Fit also probes the interior. Boundary-only probing assumes + // the binding constraint lies on the frame edge, which holds + // while the polynomial is monotonic over the frame -- but + // strong barrel folds it over (the radial factor goes negative + // past its turning point), and then an interior pixel can be + // flung further than any boundary pixel: at clamp-edge settings + // a boundary-only solve passes all 64 probes while a mid-frame + // island escapes. A 15x15 grid catches every satisfiable + // interior bind for a few hundred cheap evaluations. Fit only: + // Fill takes the max, which the + // near-axis interior would poison with huge exit scales. + if (fit_mode == 1) + { + const S32 grid = 15; + for (S32 gy = 1; gy <= grid; ++gy) + { + for (S32 gx = 1; gx <= grid; ++gx) + { + consider_probe((F32)gx / (F32)(grid + 1), + (F32)gy / (F32)(grid + 1)); + } + } + } + + if (solved > 0.f && solved < 1e30f) + { + fit_scale = llclamp(solved, 0.1f, 10.f); + } + } + mLensDistortFit.inputs = inputs; + mLensDistortFit.scale = fit_scale; + + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_K, k1, k2); + gBlitWithEffectsProgram.uniform1f(LLShaderMgr::LENS_DISTORT_SCALE, fit_scale); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_SQUEEZE, sq_x, sq_y); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_CENTER, cx, cy); + gBlitWithEffectsProgram.uniform2f(LLShaderMgr::LENS_DISTORT_TANGENTIAL, p1, p2); + } + // Vignette static LLCachedControl vignette_amount(gSavedSettings, "RenderVignetteAmount", 0.0f, "[0, 1] default 0."); static LLCachedControl vignette_radius(gSavedSettings, "RenderVignetteRadius", 1.0f); diff --git a/indra/newview/pipeline.h b/indra/newview/pipeline.h index 6b44d6e1b0b..ea5ee039966 100644 --- a/indra/newview/pipeline.h +++ b/indra/newview/pipeline.h @@ -96,6 +96,11 @@ class LLPipeline void createGLBuffers(); void createLUTBuffers(); void setupGradingLUT(); + void generateLensDirt(); + /// Re-bake the tone curve lookup row from the four RenderColorGradeCurve* + /// settings. Runs from colorCorrect when mToneCurveLutDirty is set: once + /// per change, never per frame. + void bakeToneCurveLut(); //allocate the largest screen buffer possible up to resX, resY //returns true if full size buffer allocated, false if some other size is allocated @@ -140,15 +145,25 @@ class LLPipeline void copyScreenSpaceReflections(LLRenderTarget* src, LLRenderTarget* dst); void generateLuminance(LLRenderTarget* src, LLRenderTarget* dst); void generateExposure(LLRenderTarget* src, LLRenderTarget* dst, bool use_history = true); + void generateLensFlareState(LLRenderTarget* src); + void clearLensFlareState(); void colorCorrect(LLRenderTarget* src, LLRenderTarget* dst, bool tonemap, bool colorgrade); void generateGlow(LLRenderTarget* src); + // Whether generateBloomHDR will run this frame: the pyramid is deep + // enough and every shader it needs built. renderDoF asks before it + // borrows bloomMip[0], because a blur left in there would be composited + // as bloom if the extract never overwrote it. + bool bloomHDRReady() const; void generateBloomHDR(LLRenderTarget* src); void compositeBloomHDR(LLRenderTarget* scene); void applyCAS(LLRenderTarget* src, LLRenderTarget* dst); void applyFXAA(LLRenderTarget* src, LLRenderTarget* dst); void generateSMAABuffers(LLRenderTarget* src); void applySMAA(LLRenderTarget* src, LLRenderTarget* dst); - void renderDoF(LLRenderTarget* src, LLRenderTarget* dst); + // Operates in place on mRT->screen: the combine writes colour back under a + // mask that leaves the prim-glow alpha untouched, so callers neither pass + // buffers nor swap afterwards. + void renderDoF(); void copyRenderTarget(LLRenderTarget* src, LLRenderTarget* dst); void combineGlow(LLRenderTarget* src, LLRenderTarget* dst); void visualizeBuffers(LLRenderTarget* src, LLRenderTarget* dst, U32 bufferIndex); @@ -823,6 +838,13 @@ class LLPipeline LLRenderTarget postPingMap; LLRenderTarget postPongMap; + // Depth of field owns no scratch. It runs pre-tonemap on linear HDR, + // so it cannot use postPingMap (GL_RGB10_A2 under HDR), and it does + // not widen deferredLight to serve one late pass. It borrows two + // full-frame targets that are idle for exactly the stretch of + // renderFinalize it runs in: mWaterDis for the sharp copy plus CoF, + // bloomMip[0] for the blur. renderDoF says why each is free. + //sun shadow map LLRenderTarget shadow[4]; @@ -830,6 +852,15 @@ class LLPipeline // mBloomMip[0] is full-res extract; subsequent levels are halved. LLRenderTarget bloomMip[BLOOM_MAX_MIPS]; U32 bloomMipCount = 0; + + // Cross-screen filter state for this frame. The three streak buffers + // own no memory: they are quadrants of mWaterDis, which is idle from + // the water pass to the next frame -- see generateBloomHDR. What + // colorCorrect needs to know is whether streaks were drawn this frame + // and at what size, to find the accumulator's quadrant. + bool crossFilterReady = false; + U32 crossFilterWidth = 0; + U32 crossFilterHeight = 0; }; // main full resoltuion render target @@ -859,6 +890,12 @@ class LLPipeline LLRenderTarget mExposureMap; LLRenderTarget mLastExposure; + // lens flare sun state, 2x1, swapped each frame: [0] is this frame's, [1] + // last frame's. Layout in lensFlareStateF.glsl. + LLRenderTarget mLensFlareState[2]; + bool mLensFlareStateValid = false; + bool mLensFlareSunUp = true; // which body the history describes + // FXAA helper target LLRenderTarget mFXAAMap; LLRenderTarget mSMAABlendBuffer; @@ -985,6 +1022,73 @@ class LLPipeline U32 mSMAASearchMap = 0; U32 mSMAASampleMap = 0; + // Lens dirt plate, generated rather than loaded -- see generateLensDirt. + // Nothing is allocated until the effect is switched on, and the memory goes + // back when it is switched off, so an incomplete target is also the signal + // to force the strength uniform to 0 and leave the sampler unread. + LLRenderTarget mLensDirtMap; + + // What the current plate was generated from. Comparing the whole set each + // frame is what triggers a rebuild, which covers parameter edits and window + // resizes through one test and needs no commit-signal plumbing. It also + // records a *failed* attempt, so a plate that could not be allocated is + // retried when something changes rather than on every frame. + struct LensDirtParams + { + U32 width = 0; + U32 height = 0; + F32 seed = -1.f; + F32 grime = -1.f; + F32 mote_scale = -1.f; + F32 smudge = -1.f; + S32 scratches = -1; + F32 toe = -1.f; + F32 gain = -1.f; + + bool operator==(const LensDirtParams&) const = default; + }; + LensDirtParams mLensDirtParams; + + // The lens distortion auto-fit scale, and what it was solved for. The + // solve walks the frame edge and an interior grid, and its inputs move + // only when a slider or the window does, so it is compared and skipped + // each frame the way the dirt plate is. + struct LensDistortFit + { + struct Inputs + { + F32 k1 = 0.f; + F32 k2 = 0.f; + F32 p1 = 0.f; + F32 p2 = 0.f; + F32 cx = 0.f; + F32 cy = 0.f; + F32 axis_x = 0.f; + F32 axis_y = 0.f; + F32 sq_x = 0.f; + S32 fit_mode = -1; + + bool operator==(const Inputs&) const = default; + }; + Inputs inputs; + F32 scale = 1.f; + }; + LensDistortFit mLensDistortFit; + + // Raised by the Lightbox while one of the generation sliders is being + // dragged. The plate is full-resolution, so rebuilding on every frame of a + // drag is a stutter rather than a preview -- and worse the slower the + // machine, which is backwards. Holding off means the rebuild lands once, on + // release, which is also where the user expects to see the result. + bool mLensDirtSliderHeld = false; + // Tone curve LUT: a 512x1 RGBA16 row (ALToneCurveSet::LUT_SIZE) whose R, G + // and B texels hold master(channel(x)) for each channel. A raw GL name + // like the SMAA maps above: created empty in createGLBuffers, filled by + // bakeToneCurveLut, released in releaseGLBuffers. + U32 mToneCurveLut = 0; + bool mToneCurveLutDirty = true; // settings changed, or the texture was recreated, since the last bake + bool mToneCurveIdentity = true; // the last bake found all four curves on the diagonal + LLColor4 mSunDiffuse; LLColor4 mMoonDiffuse; LLVector4 mSunDir; @@ -994,7 +1098,8 @@ class LLPipeline LLVector4 mTransformedSunDir; LLVector4 mTransformedMoonDir; - F32 mLensFlareSunVisibility = 0.f; + // Sun (or moon) on screen this frame in UV, from generateLensFlareState. + LLVector2 mLensFlareSunUV = LLVector2(0.5f, 0.5f); bool mInitialized; bool mShadersLoaded; diff --git a/indra/newview/skins/default/colors.xml b/indra/newview/skins/default/colors.xml index dcb13077c48..78ee08527d7 100644 --- a/indra/newview/skins/default/colors.xml +++ b/indra/newview/skins/default/colors.xml @@ -1268,11 +1268,14 @@ name="XUIStudioGrid" value="0.3 0.82 1 0.12" /> + diff --git a/indra/newview/skins/default/xui/da/panel_preferences_setup.xml b/indra/newview/skins/default/xui/da/panel_preferences_setup.xml index a611ef13a53..92ba6ad2589 100644 --- a/indra/newview/skins/default/xui/da/panel_preferences_setup.xml +++ b/indra/newview/skins/default/xui/da/panel_preferences_setup.xml @@ -22,14 +22,6 @@ - - Forhindr OS i at gå i dvale, hvis du ikke er væk: - - - - - - diff --git a/indra/newview/skins/default/xui/de/panel_preferences_setup.xml b/indra/newview/skins/default/xui/de/panel_preferences_setup.xml index 790cec2fc1d..4cc48e757a8 100644 --- a/indra/newview/skins/default/xui/de/panel_preferences_setup.xml +++ b/indra/newview/skins/default/xui/de/panel_preferences_setup.xml @@ -34,12 +34,4 @@ Proxy-Einstellungen: + + + + + + tab_min_width="70" + tab_padding_right="37" + fill_width="true"> + name="tab_look" + tool_tip="Exposure and tone mapping, color grading, the 3D LUT and the tone curve. A number on this tab counts the grading sections switched off for comparison." /> + name="tab_lens" + tool_tip="Camera and film effects: depth of field and bokeh, lens aberrations, bloom/glow and the cross filter, lens flare, lens dirt, chromatic aberration, distortion, vignette, grain and sharpening." /> + name="tab_scene" + tool_tip="Render quality: anti-aliasing, reflections, mirrors, shadows and ambient occlusion, performance, and preview aids." /> + end; fill_width gives the four tabs equal shares of the strip + whatever their order, so the order is free to change if that reads + better in use. --> + name="tab_sky" + tool_tip="The day cycle, which changes the world's environment for you, and sky effects drawn only on your screen." /> + + diff --git a/indra/newview/skins/default/xui/en/floater_settings_color.xml b/indra/newview/skins/default/xui/en/floater_settings_color.xml index 0722677f1d1..9da0bb9e5fd 100644 --- a/indra/newview/skins/default/xui/en/floater_settings_color.xml +++ b/indra/newview/skins/default/xui/en/floater_settings_color.xml @@ -2,116 +2,24 @@ - + - - - - - - Color name - - - - - - - - - - - \ No newline at end of file + left="8" + name="colors" + tool_tip="The skin's named colours, which its text, buttons and windows are painted with. Change one and every widget using it follows; the way back on a row is the skin's colour again." + top="24" + width="504" /> + diff --git a/indra/newview/skins/default/xui/en/floater_xui_studio.xml b/indra/newview/skins/default/xui/en/floater_xui_studio.xml index 7f98494dd3d..3fc8c63aa85 100644 --- a/indra/newview/skins/default/xui/en/floater_xui_studio.xml +++ b/indra/newview/skins/default/xui/en/floater_xui_studio.xml @@ -204,6 +204,7 @@ [NAME] works, and nothing new should write it. [NAME] works, and nothing new should write it: [INSTEAD] is the name to use. Take [NAME] out of this file again, and let whatever was in force before it be in force. + Textures Holds the gap between this element's left edge and its parent's, so the element stays where it is when the parent widens. Click to hold it or to let it go. Holds the gap between this element's bottom edge and its parent's, so the element stays where it is when the parent grows taller. Click to hold it or to let it go. Holds the gap between this element's right edge and its parent's, so the element travels with that edge when the parent widens. Click to hold it or to let it go. @@ -544,6 +545,13 @@ + + + + + - - - - - - - - - - - + + + height="18" + name="bokeh_blades_combo" + tool_tip="Shape of out-of-focus highlights. Circular is the idealised lens; real diaphragms have blades and produce visible polygons" + enabled_control="RenderDepthOfField" + control_name="RenderBokehApertureBlades"> + + + + + + + + + - - - + - + min_val="0" + max_val="1.5" + name="bokeh_cat_eye" + tool_tip="Optical vignetting; clips bokeh into lens-shaped slivers by distance from the image circle centre, so a wide display clips at the sides well before the top and bottom" + enabled_control="RenderDepthOfField" + control_name="RenderBokehCatEyeAmount" /> - + - - + - - + - + height="18" + label="Defocus Fringe" + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="1" + name="bokeh_fringe_amount" + tool_tip="Colour cast on the rim of defocused highlights, flipping hue either side of focus. Separate from the frame-wide Chromatic Aberration section" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFringeAmount" /> - + - - - - - - - - - + width="60" + height="24" + label_height="0" + name="bokeh_fringe_near_tint" + tool_tip="Cast on highlights in front of the focal plane" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFringeNearTint" /> + name="bokeh_fringe_far_label" + value="Far tint" /> + name="bokeh_fringe_far_tint" + tool_tip="Cast on highlights behind the focal plane" + enabled_control="RenderDepthOfField" + control_name="RenderBokehFringeFarTint" /> - + - Applies when HDR rendering is off. - - + - + + + name="aberration_coma" + tool_tip="Draws each highlight into a comet with its tail pointing away from frame centre; zero on the optical axis, strongest in the corners" + enabled_control="RenderDepthOfField" + control_name="RenderBokehComaAsymmetry" /> - + + + + - + height="18" + label="Scatter" + decimal_digits="2" + increment="0.01" + min_val="0.5" + max_val="2.5" + name="bloom_scatter" + tool_tip="Bloom spread per octave. Above about 2.5 the filter starts to alias." + enabled_control="RenderHDREnabled" + control_name="RenderBloomScatter" /> - + + + + min_val="3" + max_val="7" + name="bloom_mips" + tool_tip="Bloom pyramid levels; more mips = wider, softer bloom. Changing causes a brief hitch." + enabled_control="RenderHDREnabled" + control_name="RenderBloomMipCount" /> + + + + + + + + - + + name="bloom_halation_strength" + tool_tip="Photographic halation amount; 0.25 is a gentle cinematic feel" + enabled_control="RenderBloomHalation" + control_name="RenderBloomHalationStrength" /> + + - - + + + + + top="7" + right="-8" + height="18" + label="Strength" + decimal_digits="2" + increment="0.01" + min_val="0" + max_val="32" + name="crossfilter_strength" + tool_tip="Streaks every bright point in frame into a star, the way an etched glass filter does. Rides on the bloom pyramid, so bloom strength 0 silences it" + enabled_control="RenderHDREnabled" + control_name="RenderCrossFilterStrength" /> + + + + + + + + + - - + min_val="0.25" + max_val="2" + name="crossfilter_length" + tool_tip="How far the arms reach. 1.0 is the tuned value; the range stops where the arms would start breaking into a lattice" + enabled_control="RenderHDREnabled" + control_name="RenderCrossFilterLength" /> - + + + + - - - - + - - - + - - + name="crossfilter_chromatic" + tool_tip="Rainbow spread along the arms. A real star filter is a diffraction grating, so the tips go coloured while the core stays white" + enabled_control="RenderHDREnabled" + control_name="RenderCrossFilterChromatic" /> + + + + - + Applies when HDR rendering is off. + + + - - - + + max_val="4" + name="glow_width" + tool_tip="Glow sample size; wider is softer but eventually pixelated" + disabled_control="RenderHDREnabled" + control_name="RenderGlowWidth" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1224,6 +1528,7 @@ rebuild. Revisit if RenderBloomHDR gets wired.) height="18" label="B" label_width="10" + scrub="true" decimal_digits="2" increment="0.01" min_val="0" @@ -1295,16 +1600,14 @@ rebuild. Revisit if RenderBloomHDR gets wired.) top="0" right="-1" name="sec_flare"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - + + + + + + + + + + + + + + + + + + min_val="0" + max_val="32" + name="lensdirt_scratches" + tool_tip="How many scratches cross the glass. 0 is undamaged. They are straight and bright where dust is soft, so a few go a long way" + control_name="RenderLensDirtScratches"> + + + - + + + + + + + + + + + + + + + + + max_val="2" + name="lensdirt_bloom_response" + tool_tip="How much bloom lights up the dirt. 0 lets bloom pass through a clean lens" + control_name="RenderLensDirtBloomResponse" /> + - + + + + - + - + - - + - + height="18" + label="Blue offset Y" + decimal_digits="2" + increment="0.01" + min_val="-1" + max_val="1" + name="ca_offset_by" + tool_tip="Blue channel vertical shift direction" + control_name="RenderChromaticAberrationOffsetBY" /> - + - - + + max_val="2.5" + name="distortion_squeeze" + tool_tip="Horizontal desqueeze; 1.0 is a spherical lens, 2.0 matches anamorphic cinema" + control_name="RenderLensDistortionSqueeze" /> - + + + + - - - - + + + height="18" + name="distortion_fit_combo" + tool_tip="How the bent image is rescaled: none leaves pincushion showing black corners, Fit scales until nothing goes black, Fill keeps the whole frame visible and may letterbox the corners" + control_name="RenderLensDistortionFit"> + + + + - - - + + + + min_val="-0.5" + max_val="0.5" + name="vec3_RenderLensDistortionCenter_1"> + + - + top_pad="10" + width="110" + height="15" + name="distortion_tangential_label" + tool_tip="Decentering terms p1 and p2, modelling a lens element mounted slightly off-axis. Realistic values are tiny." + value="Tangential" /> + + + + + + @@ -2502,16 +2843,14 @@ rebuild. Revisit if RenderBloomHDR gets wired.) top="0" right="-1" name="sec_vignette"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tool_tip="Rotates every hue around the colour wheel, in degrees. 30 warms everything; 180 is a full complement." + enabled_control="RenderColorGrade" + control_name="RenderColorGradeHueShift" /> - - - - + height="18" + label="Highlight width" + text_width="56" + decimal_digits="2" + increment="0.01" + min_val="0.02" + max_val="0.5" + name="split_highlight_width" + tool_tip="How far above the split point the highlight tint reaches full strength. 0.02 is a hard edge, 0.5 a very soft one. The right handle on the graph." + enabled_control="RenderColorGrade" + control_name="RenderSplitToneHighlightWidth" /> - - - - - - - - - - - - - - + + + - - - - - - - - - - + control_name="RenderColorGradeCurveAmount" /> - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - + Reset to [VALUE] + hidden hidden here diff --git a/indra/newview/skins/default/xui/en/xui_notes.xml b/indra/newview/skins/default/xui/en/xui_notes.xml index 2f2748924c8..d2c70d42a21 100644 --- a/indra/newview/skins/default/xui/en/xui_notes.xml +++ b/indra/newview/skins/default/xui/en/xui_notes.xml @@ -161,6 +161,7 @@ + diff --git a/indra/newview/skins/default/xui/es/panel_preferences_setup.xml b/indra/newview/skins/default/xui/es/panel_preferences_setup.xml index 0813846724e..c118ec760e9 100644 --- a/indra/newview/skins/default/xui/es/panel_preferences_setup.xml +++ b/indra/newview/skins/default/xui/es/panel_preferences_setup.xml @@ -34,12 +34,4 @@ Configuración de proxy: