Compare commits

...

19 Commits

Author SHA1 Message Date
Niels Laute
49f43fdff8 Awake flyout: POP header animation tuning, split Start/Stop, app icon in active header, pending-selection fix
- Tune active-header composition glow: more shine sweeps, softer/blurred streaks, gentler easing on drift/flash/sweep
- Make HeaderGlowHost fill the entire active header (removes the hard glow cutoff)
- Split the shared Start/Stop button: individual Start in the idle header, Stop-only in the active header
- Show the bound app's icon (instead of the infinity glyph) in the active header when tracking an app
- Fix Refresh() ordering so IsProcessBound/BoundAppName are set before Mode, keeping the While-app card selected after picking an app while a timed/expirable session is running
- Custom duration allows up to 23h / 59m
- Program.cs: dispose the single-instance mutex on ProcessExit instead of ReleaseMutex (avoids cross-thread ApplicationException)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 14:00:22 +02:00
Niels Laute
5b7c461d6e Merge remote-tracking branch 'origin/main' into niels9001/awake-flyout 2026-06-28 14:31:30 +02:00
Niels Laute
536e768cac Extend MSIX context-menu icon sizing to the standard logo set (#48925)
## Summary

Addresses #48924. Several MSIX context-menu items did not ship the full
standard logo set (e.g. File Locksmith was missing the 44x44 logo), so
Windows fell back to a default icon in some surfaces.

This updates the MSIX assets for **File Locksmith**, **Image Resizer**
and **PowerRename** to provide the full logo set: 44x44 logo,
small/large/150x150 tiles, and store logo.

## PR Checklist
- [x] Closes #48924
- [ ] Tested manually

## Validation
Icon-asset only change; no code modified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 09:33:50 +02:00
Copilot
70ff4013b9 Add Shortcut Guide V2 manifest spec link to copilot-instructions.md (#48967)
Adds a reference to the WinGet Manifest Keyboard Shortcuts schema spec
in `.github/copilot-instructions.md` so AI agents know where to find the
correct field definitions, file naming conventions, and the `+` prefix
rule when creating or editing Shortcut Guide V2 manifest files.

## Summary of the Pull Request

Adds a new `## Shortcut Guide V2 Manifests` section to
`.github/copilot-instructions.md` linking to [`doc/specs/WinGet Manifest
Keyboard Shortcuts
schema.md`](../doc/specs/WinGet%20Manifest%20Keyboard%20Shortcuts%20schema.md).
This ensures agents authoring new manifest files follow the correct
schema and naming scheme (e.g., `<PackageId>.<locale>.yml`, `+` prefix
for apps without a WinGet package).

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

`.github/copilot-instructions.md` gains a dedicated section:

```markdown
## Shortcut Guide V2 Manifests

When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:

- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
```

No production code changes.

## Validation Steps Performed

- Verified the relative link resolves to the correct spec file in the
repository.
- Confirmed the section is correctly placed before "Detailed
Documentation".

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-27 21:07:48 +00:00
ABHIJEET KALE
7a04d4c270 [ShortcutGuide] Add DaVinci Resolve keyboard shortcut manifest (#48651) (#48652)
Adds a shortcut manifest for DaVinci Resolve (professional video editing
and color grading application by Blackmagic Design) so it appears in the
Shortcut Guide overlay when the app is focused.

The manifest contains 88 of the most commonly used DaVinci Resolve
keyboard shortcuts organized into 8 categories:

| Section | Shortcuts | Highlights |
|---------|-----------|------------|
| Popular shortcuts | 25 | Page navigation (F5-F8), Playback (JKL,
Space), Edit basics (Cut, Blade, Ripple Delete) |
| Timeline navigation | 13 | Frame/clip/track navigation, zoom, edit
point jumping |
| Edit | 14 | Cut/Copy/Paste/Duplicate, Render in Place, Compound Clip |
| Color | 17 | Node management (Alt+S/P/L), viewer modes (1-5),
Grade/Keyframe |
| Fairlight | 8 | Mute/Solo/Record/Automation, Bounce Mix |
| Fusion | 8 | View switching (1-4), Merge, Keyframe |
| Media | 8 | Import, Smart Bin, Reveal in Explorer, Rename |
| Deliver | 5 | Render Queue, Start Render, Output settings |

The manifest follows the same YAML schema as existing manifests. No code
changes needed -- manifests are auto-discovered at startup.
2026-06-27 19:48:39 +00:00
Michael Jolley
8c434cd6f4 CmdPal: Fix LayoutCycleException in gallery screenshot strip (#48090)
## Summary

Extension Gallery detail pages crash with `LayoutCycleException` /
`AG_E_LAYOUT_CYCLE` when scrolling the screenshot strip horizontally on
extensions that have many screenshots.

## Root Cause

The screenshot `ItemsView` uses a horizontal `StackLayout` and is placed
in a Grid row with `Height="Auto"`, inside a vertical `ScrollViewer`.
The `ItemsView` itself contains an internal `ScrollView` for horizontal
scrolling.

When screenshots load asynchronously (`BitmapImage` decode completes),
this triggers a layout feedback loop:

1. Image decodes → `ItemContainer` re-measures
2. Auto-height Grid row re-measures, offering `ItemsView` new available
height
3. `ItemsView`'s internal `ScrollView` recalculates extents
4. Parent Grid row invalidates → outer `ScrollViewer` re-layouts →
re-measures children → back to step 1

## Fix

Two XAML-only changes to `ExtensionGalleryItemPage.xaml`:

1. **Fixed Grid row height**: Changed the screenshot strip row from
`Height="Auto"` to `Height="232"` (200px image + 16px top/bottom
padding). The parent no longer queries `ItemsView` for desired height,
breaking the cycle.

2. **Fixed item width**: Added `Width="356"` to the screenshot `Border`
template (16:9 ratio: 200 × 16/9 ≈ 356). Provides a stable size before
async image decode, preventing re-measure triggers.

Fixes #47901

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 12:32:13 +02:00
ScymicX
d983dbc285 Fix VS Code workspace UNC paths (#48922)
## Summary of the Pull Request

Fixes #47719

VS Code stores recently opened workspaces as URIs. A workspace on a
Windows network share can be stored as
`file://server/share/workspace.code-workspace`.

The VS Code Workspaces plugin interpreted the server part of that URI as
a VS Code remote authority. Because it was not a recognized remote
authority, valid UNC workspaces were discarded.

This change recognizes file URIs with a server authority as local UNC
paths and converts them to the Windows UNC format: `\\server\share\...`.

## PR Checklist

* [x] Closes #47719
* [x] **Communication:** This implementation follows the suggested
direction in the issue discussion.
* [ ] **Tests:** No automated regression test added.
* [x] **Localization:** No user-facing strings were changed.
* [x] **New binaries:** No binaries were added.
* [x] **Documentation updated:** No documentation changes are required.

## Validation Steps Performed

* Built the full PowerToys solution locally in `Release | x64` with 0
errors.
* Started the locally built PowerToys instance.
* Verified that the VS Code Workspaces plugin finds local workspaces.
* Verified that UNC workspaces using both a hostname and an IP address
appear in PowerToys Run.
* Opened a UNC workspace successfully from PowerToys Run.
2026-06-26 22:00:25 +00:00
Niels Laute
fb6843b0f1 Refactor transparent overlay into TransparentWindow + TransientSurface (#48915)
## Summary

Refactors the reusable transparent-overlay infrastructure in
`src/common/Common.UI.Controls/` into a clean separation between a pure
host window and a self-animating acrylic surface.

### What changed

- **`TransparentWindow`** is now animation-agnostic. It raises `Showing`
/ `Hiding` events; `Hiding` exposes a deferral so the HWND stays visible
until the surface's out-animation finishes.
- **`TransientSurface`** (renamed from `TransparentCard`) is a
self-animating "pseudo-window" content control. It owns all chrome —
`ThemeShadow`, always-active desktop acrylic, 1px border, rounded
corners — and its own show/hide slide animations.
- `SlideFrom` (`None`/`Left`/`Top`/`Right`/`Bottom`) selects the slide
edge. `None` is the default and plays **no animation at all** (instant
show/hide).
- `AcrylicKind` (new) is exposed and bound to the backdrop via
`TemplateBinding`, defaulting to **thin acrylic**. Consumers can
override to `Default`/`Base`.
- **`AlwaysActiveDesktopAcrylicBackdrop`** gains a matching `Kind`
dependency property.
- **CmdPal `ToastWindow`** is migrated to the new pattern as the proving
consumer (`Surface.SubscribeTo(this)`).

### Coordination model

A module declares a `<TransientSurface>` as the window's content and
calls `SubscribeTo(window)` once. The window raises `Showing`/`Hiding`;
the surface animates itself in/out and uses the `Hiding` deferral to
keep the window alive until the out-animation completes.

## Testing

- `Common.UI.Controls` builds clean (x64 Debug, exit 0).
- `Microsoft.CmdPal.UI` builds clean (x64 Debug, exit 0).
- ToastWindow keeps its slide-up animation (`SlideFrom="Bottom"`).


https://github.com/user-attachments/assets/a06b0f1a-740a-4fcd-bba8-6f7a64ed261b

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 21:25:17 +00:00
Clint Rutkas
6dd1ce5dd1 Dev/crutkas/ripple v2.1 + spelling allow-list follow-up (#48232)
## Summary of the Pull Request

Adds a follow-up metadata fix to the existing Mouse Highlighter ripple
v2.1 work by allowing the term `xhair` in repo spell-check
configuration.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

- Added `xhair` to `.github/actions/spell-check/expect.txt`.
- This addresses spelling feedback on MouseHighlighter ripple/crosshair
code without changing runtime behavior.
- No functional changes to Mouse Highlighter logic were made in this
follow-up commit.

## Validation Steps Performed

- Verified the only content change is the new `xhair` entry in
spell-check expected words.
- Ran secret scanning on changed file
(`.github/actions/spell-check/expect.txt`) with no findings.
- Ran parallel validation:
  - Code Review: no issues.
  - CodeQL: skipped as trivial metadata-only change.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-26 21:35:27 +02:00
Michael Jolley
9ea30ec523 CmdPal: Fix fallback results showing when disabled in Command Palette (#48777)
Fallback results were showing in Command Palette search results even
when the user had disabled them in settings.

When a fallback command is disabled (e.g., VS Code for Command Palette
with `IsEnabled = false`), it is excluded from
`configuredGlobalFallbackIds` (which only contains enabled + global
fallbacks). However, during search filtering, all fallbacks NOT in
`configuredGlobalFallbackIds` were unconditionally added to
`commonFallbacks`, which gets scored and displayed in results. This
means disabled fallbacks still appeared.

To fix, I added an `IsEnabled` check when building the `commonFallbacks`
list in `MainListPage.cs`. Disabled fallback commands are now properly
excluded from search results.

Fixes #48504

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 12:40:53 -05:00
Niels Laute
c777fcc1e4 Fix CmdPal gallery crash when extension has no homepage (#48869)
## Summary

Selecting an extension in the CmdPal Extension Gallery crashed the app
when that extension had **no `homepage`** defined.

## Root cause

In `ExtensionGalleryItemPage.xaml`, the "View repository"
`HyperlinkButton` bound its `NavigateUri` (a `System.Uri`) directly to
the raw string property `ViewModel.Homepage`:

```xml
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
```

`x:Bind` evaluates **all** bindings on an element regardless of
`Visibility`, and to assign a `string` to a `Uri` target it generates a
`new Uri(value)` conversion. When `homepage` is undefined, `Homepage` is
`null`, so the binding executes `new Uri(null)` →
`ArgumentNullException` → the page crashes on load. The
`Visibility="{... HasHomepage}"` collapse did not help because the
`NavigateUri` binding still runs.

Every other `NavigateUri` x:Bind in the codebase (`Link`, `LinkUri`) is
already bound to a `Uri?`, so the homepage hyperlink was the lone
offender.

## Fix

- **`ExtensionGalleryItemViewModel.cs`** — Added a validated `Uri?
HomepageUri` property backed by the existing `_homepageHttpUri` (already
`null` for missing or non-web homepages).
- **`ExtensionGalleryItemPage.xaml`** — Bound `NavigateUri` to
`ViewModel.HomepageUri` instead of the raw string. `null` is valid for
`NavigateUri`, so no conversion occurs. The tooltip still shows the
`Homepage` string.
- **Tests** — Added coverage asserting `HomepageUri` is set for a web
homepage and `null` when missing.

## Verification

- `Microsoft.CmdPal.UI.ViewModels`, `Microsoft.CmdPal.UI` (XAML
compile), and the unit test project all built cleanly (x64/Release, exit
code 0).
- All 24 `ExtensionGalleryItemViewModelTests` pass, including the two
new cases.
- Manually verified in VS that opening an extension without a homepage
no longer crashes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>



https://github.com/user-attachments/assets/b268bafb-6bee-4862-9fbf-7a0e06675e36

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 17:14:37 -05:00
Michael Jolley
28e078897a [CmdPal] Fix memory leak in PerformanceWidgetsPage network band items (#48880)
## Summary

Fixes a memory leak in the Performance Monitor dock extension where
`GetItems()` created **new** `ListItem` instances for `_networkUpItem`
and `_networkDownItem` on every call.

## Problem

When the dock subscribes to `ItemsChanged` and calls `GetItems()` to
refresh, the band page path allocates 2 new `ListItem` objects each time
— the old ones are replaced in the fields but never collected (they
remain referenced by the `DockItemViewModel` wrappers until the next
refresh cycle). Under normal operation this leaks ~2 objects/second
indefinitely.

## Fix

Move `_networkUpItem`/`_networkDownItem` creation into the constructor
(matching the pattern used by CPU, Memory, GPU, and Battery items).
`GetItems()` now returns stable references. The `Updated` event handler
already updates their `.Title` properties, which propagates to the UI
via `PropChanged` → `CommandItemViewModel.Model_PropChanged`.

## Validation

- Build succeeds (`Microsoft.CmdPal.Ext.PerformanceMonitor.csproj`)
- Network up/down band items still receive title updates via the
existing `Updated` handler
- No `RaiseItemsChanged()` needed — `ListItem.Title` setter fires
`PropChanged`, which `DockItemViewModel` already observes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 21:06:13 +00:00
Michael Jolley
64f1243bdf Skip auto-labeling PRs that already have labels (#48877)
## Summary

The auto-labeler workflow now skips pull requests that already have
labels applied before running the AI classification. This avoids
overwriting or duplicating labels that were manually set by contributors
or maintainers.

## Changes

- Added a check in `labelIssue()` that returns early for PRs with
existing labels, logging which labels are already present.
- Issues continue to be labeled regardless (only PRs get the skip
logic).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 19:22:58 +00:00
Mario Hewardt
e1074bc835 ZoomIt - Update notices (#48843)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Update notices for ZoomIt dependency

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-25 17:31:51 +02:00
Michael Jolley
2390aacbfc CmdPal: Prevent same-page settings navigation (#48703)
Fixes #48698 by preventing the Command Palette settings frame from
navigating to the same page again, which avoids adding a self-navigation
entry to the back stack.

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 14:54:39 +02:00
Niels Laute
4677ef50e9 [Awake] Flyout: status icon + Settings-paradigm controls
Matches tray icon by mode (disabled/indefinite/timed/expirable). Body now
stretches with a fixed bottom bar like QuickAccess. Replaced RadioButton
preset list with H/M NumberBox bound to AwakeSettings.IntervalHours/Minutes
and stacked Expirable Date/Time pickers vertically. ToggleSwitch is now a
CheckBox. Drops the Apply button - Expirable auto-applies on picker change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 20:51:46 +02:00
Niels Laute
e87527d6a4 [Awake] Flyout: ComboBox mode picker + icon-only bottom bar
Per design feedback:

- Replace the `tkcontrols:Segmented` mode picker with a plain
  `ComboBox`. Less visually heavy than 4 horizontal segments at
  320 dip width, and the picker collapses while you read the rest
  of the flyout.
- Replace the text-button footer (`Open Settings` / `Exit Awake`)
  with a 48px bottom bar of small (32x32) icon-only `Button`s,
  matching the QuickAccess flyout's pattern:
    * Settings -> AnimatedSettingsVisualSource + Setting symbol
      fallback, tooltip and AutomationProperties.Name from
      AWAKE_FLYOUT_OPEN_SETTINGS.
    * Exit -> FontIcon glyph 0xE7E8 (PowerButton), tooltip and
      AutomationProperties.Name from AWAKE_EXIT, standalone-only.
  Buttons use a local FlyoutIconButtonStyle based on
  SubtleButtonStyle, copied from QuickAccess's LaunchPage.xaml.

Cleanup:

- Drop the `CommunityToolkit.WinUI.Controls.Segmented`
  PackageReference (saves a few hundred KB in WinUI3Apps\).
- Drop the now-unused Segmented.xaml resource dictionary merge from
  the root Grid.Resources.
- Rename `SyncSegmentedSelection` -> `SyncModeSelection` and
  `ModeSegmentedControl_SelectionChanged` -> `ModeComboBox_SelectionChanged`.

No string changes: reuses the existing `AWAKE_FLYOUT_OPEN_SETTINGS`
and `AWAKE_EXIT` resources (previously the button labels, now the
tooltips + accessible names).

Build green on arm64 Debug.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 19:24:53 +02:00
Niels Laute
a3c251b064 [Awake] Replace Win32 tray menu with WinUI 3 flyout
Convert Awake from a console-style WinExe into a WinUI 3 + WinAppSDK
self-contained app (mirroring PowerDisplay) and replace the legacy
HMENU tray context menu with a modern, dark-mode-aware flyout window.

Highlights
- Left- AND right-click on the tray icon both open the flyout.
- Mode picker (Off / Indefinite / Timed / Until expiration) using
  Segmented control, with live mode switching via existing Manager APIs.
- Expirable date/time picker exposed directly in the flyout (fixes the
  long-standing gap from issue #29276 where Expirable was unreachable
  from the tray).
- 'Keep screen on' toggle is persistently visible (no longer buried
  in a context menu).
- 'Open Settings' deep-links to the Awake settings page; 'Exit Awake'
  only shown when standalone (parity with the old TC_EXIT behavior).
- Tray icon swaps per-mode unchanged (5 existing icons retained).
- CLI behavior preserved (System.CommandLine, --time-limit,
  --expire-at, --pid, --use-pt-config).
- CmdPal AwakeService integration unchanged (talks to
  Awake.ModuleServices, untouched).

Project / installer changes
- Awake.csproj now outputs to WinUI3Apps\, mirroring PowerDisplay's
  self-contained WinAppSDK layout.
- Runner's AwakeModuleInterface/dllmain.cpp updated to launch
  WinUI3Apps\PowerToys.Awake.exe.
- Installer wxs files require no manual edits — the file lists are
  regenerated by generateAllFileComponents.ps1 at build time and will
  naturally discover Awake.exe in WinUI3Apps\.

Removed
- Core/TrayHelper.cs and all HMENU/window/message-loop models
  (TrayCommands, TrayIconAction, MSG, MenuInfo, NOTIFYICONDATA, POINT,
  WNDCLASSEX). Their replacements live in Core/TrayIconService.cs and
  AwakeXAML/.

Deferred to follow-up PRs (out of scope here)
- Sleep/shutdown at expiration (#12191)
- Schedule mode (#24846)
- Lid actions (#34479)
- Global hotkey (#35561)
- Disable on battery (#15137)
- Stop on lock (#15616)
- 12/24h tooltip format (#47359)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 19:11:39 +02:00
Niels Laute
e3e9d132fc Update Release-Banner.png 2026-06-08 23:18:03 +02:00
86 changed files with 7257 additions and 1230 deletions

View File

@@ -135,6 +135,7 @@ BITMAPINFO
BITMAPINFOHEADER
BITSPERPEL
BITSPIXEL
Blackmagic
bla
BLENDFUNCTION
blittable
@@ -539,6 +540,7 @@ EXTRINSICPROPERTIES
eyetracker
FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
Fairlight
FARPROC
fdw
fdx
@@ -2178,6 +2180,7 @@ xclip
xcopy
xdf
xfd
xhair
xmp
Xoshiro
xsi

View File

@@ -30,6 +30,12 @@ These are auto-applied based on file location:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
## Shortcut Guide V2 Manifests
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)

View File

@@ -73,6 +73,13 @@ jobs:
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
// Skip pull requests that already have labels applied.
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
const existingLabels = issue.labels.map(l => l.name).join(', ');
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';

View File

@@ -12,6 +12,7 @@ This software incorporates material from third parties.
- Peek
- PowerDisplay
- Registry Preview
- ZoomIt
## Utility: Color Picker
@@ -1549,6 +1550,69 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Utility: ZoomIt
### libwebp
ZoomIt uses libwebp to encode screenshots in the WebP image format.
**Source**: <https://github.com/webmproject/libwebp>
BSD-3-Clause License
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Additional IP Rights Grant (Patents)
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
## NuGet Packages used by PowerToys
- AdaptiveCards.ObjectModel.WinUI3

View File

@@ -29,8 +29,30 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
/// </remarks>
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
{
/// <summary>
/// Identifies the <see cref="Kind"/> dependency property.
/// </summary>
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
nameof(Kind),
typeof(DesktopAcrylicKind),
typeof(AlwaysActiveDesktopAcrylicBackdrop),
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
/// <summary>
/// Gets or sets the desktop acrylic material variant to render. Defaults to
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
/// material. Changing this updates any live backdrop targets immediately.
/// </summary>
public DesktopAcrylicKind Kind
{
get => (DesktopAcrylicKind)GetValue(KindProperty);
set => SetValue(KindProperty, value);
}
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
@@ -41,7 +63,10 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
Theme = ResolveTheme(xamlRoot),
};
var controller = new DesktopAcrylicController();
var controller = new DesktopAcrylicController
{
Kind = Kind,
};
controller.SetSystemBackdropConfiguration(configuration);
controller.AddSystemBackdropTarget(connectedTarget);
@@ -70,6 +95,17 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
}
}
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
var kind = (DesktopAcrylicKind)e.NewValue;
foreach (var target in self._targets.Values)
{
target.Controller.Kind = kind;
}
}
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
xamlRoot.Content is FrameworkElement rootElement
? rootElement.ActualTheme switch

View File

@@ -5,9 +5,9 @@
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
@@ -16,7 +16,7 @@
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:TransparentCard">
<ControlTemplate TargetType="local:TransientSurface">
<Grid
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
@@ -27,7 +27,7 @@
</Grid.Shadow>
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
<SystemBackdropElement.SystemBackdrop>
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
</SystemBackdropElement.SystemBackdrop>
</SystemBackdropElement>
<ContentPresenter
@@ -41,5 +41,4 @@
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,467 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating, self-animating "pseudo window" surface for transient PowerToys
/// overlays (toasts, banners, indicators). It looks like a control but behaves
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
/// its own show/hide animations.
/// </summary>
/// <remarks>
/// <para>Designed to be declared as the root content of a
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
/// animates itself in/out whenever the window is shown or hidden, and uses the
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
/// until its out-animation finishes.</para>
/// <para>The show transition comes from the window's
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
/// <see cref="ShowTransition"/> when shown without one); the hide transition
/// always comes from <see cref="HideTransition"/>. Animations target the
/// surface itself, so the entire surface (border, acrylic, shadow, inner
/// content) animates as one. Apps that want a different look supply their own
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
/// restyle path.</para>
/// </remarks>
public sealed partial class TransientSurface : ContentControl
{
private const float ShadowDepth = 32f;
private const double SlideInOffset = 24;
private const double SlideOutOffset = 12;
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
// fade is kept fast so the surface reads as an instant, light pop.
//
// The fade must run at least as long as the scale: if the scale outlasted the
// fade, the surface would reach full opacity while still visibly growing,
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
// hides the growth under the opacity ramp, so by the time it is fully opaque
// it is already at 100% size.
private const float PopScaleFrom = 0.96f;
private const double PopFadeShowMs = 180;
private const double PopScaleShowMs = 150;
private const double PopFadeHideMs = 120;
private const double PopScaleHideMs = 120;
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
nameof(ShowTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
nameof(HideTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
nameof(AcrylicKind),
typeof(DesktopAcrylicKind),
typeof(TransientSurface),
new PropertyMetadata(DesktopAcrylicKind.Thin));
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly ImplicitAnimationSet _noAnimations = new();
private ImplicitAnimationSet _showAnimations = new();
private ImplicitAnimationSet _hideAnimations = new();
private bool _hasCustomShowAnimations;
private bool _hasCustomHideAnimations;
private Action? _abandonPendingHide;
public TransientSurface()
{
DefaultStyleKey = typeof(TransientSurface);
RebuildDefaultAnimations();
// Pin the scale center to the surface's center so the "Pop" transition
// grows/shrinks from the middle, not the top-left corner. An expression
// animation bound to the visual's own size keeps the center correct from
// the very first frame (a SizeChanged handler would race the show
// animation and let the first pop scale from 0,0).
PinScaleCenter();
// Start hidden so the first Show() animates in from the configured pose.
Visibility = Visibility.Collapsed;
}
/// <summary>
/// Raised after <see cref="Hide"/> once the longest animation in
/// <see cref="HideAnimations"/> (delay + duration) has completed.
/// </summary>
public event EventHandler? HideCompleted;
/// <summary>
/// Gets or sets the transition played when the surface is shown without an
/// explicit one (see <see cref="Show()"/>). Defaults to
/// <see cref="Transition.None"/>, which plays no animation at all (the
/// surface appears instantly); a directional value adds a fade plus a slide
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
/// unless it has been set explicitly.
/// </summary>
public Transition ShowTransition
{
get => (Transition)GetValue(ShowTransitionProperty);
set => SetValue(ShowTransitionProperty, value);
}
/// <summary>
/// Gets or sets the transition played when the surface is hidden (see
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
/// plays no animation at all (the surface disappears instantly); a
/// directional value adds a fade plus a slide out toward that edge, and
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
/// this regenerates the default <see cref="HideAnimations"/> unless it has
/// been set explicitly.
/// </summary>
public Transition HideTransition
{
get => (Transition)GetValue(HideTransitionProperty);
set => SetValue(HideTransitionProperty, value);
}
/// <summary>
/// Gets or sets the desktop acrylic material variant painted behind the
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
/// for the base material. Has no effect when a custom template without the
/// default acrylic backdrop is applied.
/// </summary>
public DesktopAcrylicKind AcrylicKind
{
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
set => SetValue(AcrylicKindProperty, value);
}
/// <summary>
/// Gets or sets the animations played when <see cref="Show()"/> flips the
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set
{
_showAnimations = value ?? new ImplicitAnimationSet();
_hasCustomShowAnimations = true;
}
}
/// <summary>
/// Gets or sets the animations played when <see cref="Hide"/> flips the
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set
{
_hideAnimations = value ?? new ImplicitAnimationSet();
_hasCustomHideAnimations = true;
}
}
/// <summary>
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
/// animates itself in and out in response to the window's
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
/// events. Call this once after the surface has been set as (or placed within)
/// the window's content.
/// </summary>
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
public void SubscribeTo(TransparentWindow host)
{
ArgumentNullException.ThrowIfNull(host);
host.Showing += OnHostShowing;
host.Hiding += OnHostHiding;
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
/// using <paramref name="transition"/> as the show transition.
/// </summary>
/// <param name="transition">The transition to play when showing.</param>
public void Show(Transition transition)
{
ShowTransition = transition;
Show();
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls re-trigger the show animation cleanly and cancel any
/// pending <see cref="HideCompleted"/> notification.
/// </summary>
public void Show()
{
_hideCompletedTimer.Stop();
// If a hide from a previous cycle is still in flight, abandon it: drop its
// pending HideCompleted handler so the outstanding deferral is never
// completed. We are showing again, so the host must keep the window
// visible instead of later hiding it for this interrupted cycle.
_abandonPendingHide?.Invoke();
_abandonPendingHide = null;
// Attach the show animation and detach any hide animation: when Show() is
// called while the surface is still visible, the Collapsed -> Visible
// restart below would otherwise play the hide animation (a fade/scale out)
// immediately before the show, producing a visible flash. The real hide
// animation is re-attached just-in-time in Hide().
Implicit.SetShowAnimations(this, _showAnimations);
Implicit.SetHideAnimations(this, _noAnimations);
// Reset to the hidden pose so the show animation always animates from the
// configured starting frame.
Visibility = Visibility.Collapsed;
Visibility = Visibility.Visible;
}
/// <summary>
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
/// once the longest animation in <see cref="HideAnimations"/> (delay +
/// duration) has completed.
/// </summary>
public void Hide()
{
// Attach the hide animation just before collapsing (Show() detaches it to
// avoid a flash when re-showing an already-visible surface).
Implicit.SetHideAnimations(this, _hideAnimations);
Visibility = Visibility.Collapsed;
_hideCompletedTimer.Debounce(
() => HideCompleted?.Invoke(this, EventArgs.Empty),
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
}
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((TransientSurface)d).RebuildDefaultAnimations();
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
{
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
_ => (null, null),
};
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
{
if (e.Transition is Transition transition)
{
Show(transition);
}
else
{
Show();
}
}
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
{
// Take a deferral so the host keeps its window visible until our
// out-animation has finished, then complete it from HideCompleted.
var deferral = e.GetDeferral();
void OnHideCompleted(object? s, EventArgs args)
{
HideCompleted -= OnHideCompleted;
_abandonPendingHide = null;
deferral.Complete();
}
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
// handler so the deferral is never completed (the window stays visible)
// rather than firing AppWindow.Hide for an interrupted cycle.
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
HideCompleted += OnHideCompleted;
Hide();
}
private void RebuildDefaultAnimations()
{
if (!_hasCustomShowAnimations)
{
_showAnimations = BuildShowAnimations(ShowTransition);
}
if (!_hasCustomHideAnimations)
{
_hideAnimations = BuildHideAnimations(HideTransition);
}
}
private void PinScaleCenter()
{
var visual = ElementCompositionPreview.GetElementVisual(this);
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
visual.StartAnimation("CenterPoint", center);
}
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = $"{PopScaleFrom},{PopScaleFrom},1",
To = "1,1,1",
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
var (slideFrom, _) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = slideFrom,
To = $"0,0,{ShadowDepth}",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = "1,1,1",
To = $"{PopScaleFrom},{PopScaleFrom},1",
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
var (_, slideTo) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = $"0,0,{ShadowDepth}",
To = slideTo,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
/// plays when it is shown or hidden. The directional values describe an edge —
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
/// </summary>
public enum Transition
{
/// <summary>No animation; the surface appears and disappears instantly.</summary>
None,
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
Left,
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
Top,
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
Right,
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
Bottom,
/// <summary>
/// A subtle "pop": a quick fade combined with a small scale between 96% and
/// 100% from the surface's center. Stays in place — no slide.
/// </summary>
Pop,
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating "card" surface for transient PowerToys overlays (toasts,
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
/// </summary>
/// <remarks>
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
/// in resources — the standard WinUI restyle path.
/// </remarks>
public sealed partial class TransparentCard : ContentControl
{
public TransparentCard()
{
DefaultStyleKey = typeof(TransparentCard);
}
}

View File

@@ -4,6 +4,6 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
@@ -187,16 +187,13 @@ public static partial class FlyoutWindowHelper
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
/// target display (invisible at that size), then the real position+size while the window
/// is already on that monitor. Skips the teleport when already on the target display.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;

View File

@@ -1,290 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Markup;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// </list>
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
/// in a <see cref="TransparentCard"/> that the constructor assigns to
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
/// be written as <c>&lt;common:TransparentWindow&gt;&lt;TextBlock/&gt;&lt;/common:TransparentWindow&gt;</c>.</para>
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
/// fully see-through. That buffer area is NOT click-through, so consumers
/// should keep it as small as possible (just enough to give the card's
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
/// toggle on the card, and a debounced
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
/// animation in <see cref="HideAnimations"/>. Animations target the card so
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
/// </remarks>
[ContentProperty(Name = nameof(InnerContent))]
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly nint _hwnd;
private readonly TransparentCard _card;
private ImplicitAnimationSet _showAnimations;
private ImplicitAnimationSet _hideAnimations;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
_showAnimations = BuildDefaultShowAnimations();
_hideAnimations = BuildDefaultHideAnimations();
_card = new TransparentCard();
Content = _card;
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Gets the <see cref="TransparentCard"/> that provides the window's
/// visible chrome (acrylic + border + shadow). Consumers can configure
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
/// window, or apply a custom <c>Style</c> to change its look.
/// </summary>
public TransparentCard Card => _card;
/// <summary>
/// Gets or sets the visual hosted inside the window's
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
/// child elements declared between the opening and closing
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
/// </summary>
public object? InnerContent
{
get => _card.Content;
set => _card.Content = value;
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
/// fade-in plus a 250 ms slide-up of 24 px.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set => _showAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
/// fade-out plus a 180 ms slide-down of 12 px.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set => _hideAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls reset the content to its hidden pose first so the show
/// animation re-triggers cleanly. Any pending hide is cancelled.
/// </summary>
public void Show()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_hideCloseTimer.Stop();
if (Content is UIElement content)
{
// Re-apply each call so swapping animation collections at
// runtime takes effect on the next show/hide cycle.
Implicit.SetShowAnimations(content, _showAnimations);
Implicit.SetHideAnimations(content, _hideAnimations);
// Reset to the hidden pose so the show animation always
// animates from the configured starting frame.
content.Visibility = Visibility.Collapsed;
}
_ = ShowWindow(_hwnd, SwShowNa);
if (Content is UIElement c2)
{
c2.Visibility = Visibility.Visible;
}
});
}
/// <summary>
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
/// plays, then hides the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
/// animation in <see cref="HideAnimations"/> (delay + duration) has
/// completed.
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
if (Content is UIElement content)
{
content.Visibility = Visibility.Collapsed;
}
_hideCloseTimer.Debounce(
AppWindow.Hide,
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
});
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
{
new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,24,32",
To = "0,0,32",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
};
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
{
new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,0,32",
To = "0,12,32",
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
};
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using Deferral = global::Windows.Foundation.Deferral;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
/// animated surface can keep the window visible until its out-animation has
/// finished. If no handler takes a deferral, the window hides immediately.
/// </summary>
public sealed class HidingEventArgs : EventArgs
{
private int _outstanding;
private bool _raised;
private Action? _continuation;
/// <summary>
/// Requests that the window stay visible until the returned deferral is
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
/// has finished.
/// </summary>
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
public Deferral GetDeferral()
{
Interlocked.Increment(ref _outstanding);
return new Deferral(OnDeferralCompleted);
}
/// <summary>
/// Called by the window after raising the event to register what should run
/// once every outstanding deferral has completed (or immediately if none
/// were taken).
/// </summary>
internal void RunWhenComplete(Action continuation)
{
_continuation = continuation;
_raised = true;
TryComplete();
}
private void OnDeferralCompleted()
{
Interlocked.Decrement(ref _outstanding);
TryComplete();
}
private void TryComplete()
{
if (_raised && Volatile.Read(ref _outstanding) == 0)
{
var continuation = _continuation;
_continuation = null;
continuation?.Invoke();
}
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
/// content should play, or <see langword="null"/> to let the content use its own
/// configured show transition.
/// </summary>
public sealed class ShowingEventArgs : EventArgs
{
public ShowingEventArgs(Transition? transition)
{
Transition = transition;
}
/// <summary>
/// Gets the transition the content should play, or <see langword="null"/> to
/// use the content's own configured show transition.
/// </summary>
public Transition? Transition { get; }
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Windows.Foundation;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
/// see-through and the visible chrome can be drawn by the content.</item>
/// </list>
/// <para>This window is intentionally animation-agnostic: it does not own any
/// chrome or motion. Consumers supply their own content (typically a
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
/// deferrals, so the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
/// content has finished animating out. With no listener the window simply shows
/// or hides immediately.</para>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly nint _hwnd;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
/// using <see cref="ShowingEventArgs.Transition"/>.
/// </summary>
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
/// <summary>
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
/// surface subscribes to this to play its out-animation, taking a deferral
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
/// visible until the animation completes.
/// </summary>
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> without a transition, so subscribed content animates
/// in using its own configured show transition.
/// </summary>
public void Show() => RaiseShow(null);
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> so subscribed content animates in using
/// <paramref name="transition"/>, overriding its configured show transition.
/// </summary>
/// <param name="transition">The transition the content should play.</param>
public void Show(Transition transition) => RaiseShow(transition);
private void RaiseShow(Transition? transition)
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
}
/// <summary>
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
/// deferral taken by a handler has completed (immediately if none were taken).
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
var args = new HidingEventArgs();
Hiding?.Invoke(this, args);
args.RunWhenComplete(AppWindow.Hide);
});
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -6,6 +6,8 @@
#include "trace.h"
#include <cmath>
#include <algorithm>
#include <memory>
#include <vector>
#ifdef COMPOSITION
namespace winrt
@@ -48,6 +50,18 @@ private:
void ClearDrawingPoint();
void ClearDrawing();
void BringToFront();
// Ripple mode: spawn the press/hold ring + glow at the click point and
// continue the animation into a fade-out on release. The held ring may
// optionally follow the cursor while held (gated by m_rippleShowDragTrail).
void SpawnRippleHoldDot(MouseButton button);
void FadeRippleHoldDot(MouseButton button);
// Ripple mode: emit a single self-contained ripple (grow + fade) for a quick
// click, independent of any held indicator.
void EmitSingleRipple(MouseButton button);
// Spotlight mode: pressed-state animation that shrinks the mask while
// a mouse button is held and restores it on release.
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -71,6 +85,16 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
// Ellipse geometries kept alongside the pointer shapes so press-down /
// release animations can target the radius directly.
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
// Ripple-mode held glow (the soft halo behind the ring) — paired with
// m_left/rightPointer (which holds the ring shape) while a button is held.
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
// Spotlight overlay (mask with soft feathered edge)
winrt::SpriteVisual m_overlay{ nullptr };
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
@@ -84,9 +108,22 @@ private:
bool m_rightPointerEnabled = true;
bool m_alwaysPointerEnabled = true;
bool m_spotlightMode = false;
bool m_spotlightPressed = false;
bool m_rippleMode = true;
bool m_rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool m_rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
float m_rippleSize = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
float m_rippleIntensity = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
int m_rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool m_leftButtonPressed = false;
bool m_rightButtonPressed = false;
// Pending hold-detection timers. A ripple "held indicator" is only spawned
// once the button has been held past a short threshold; a quick click that
// releases before then emits a single self-contained ripple instead. This
// prevents a single click from rendering two ripples (press + release).
UINT_PTR m_leftHoldTimer = 0;
UINT_PTR m_rightHoldTimer = 0;
UINT_PTR m_timer_id = 0;
bool m_visible = false;
@@ -102,6 +139,11 @@ private:
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
};
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
static const uint32_t HOLD_RIPPLE_TIMER_RIGHT = 125;
// How long a ripple button must be held before the persistent "held indicator"
// is shown. Releasing before this is treated as a quick click (single ripple).
static const uint32_t HOLD_RIPPLE_THRESHOLD_MS = 180;
Highlighter* Highlighter::instance = nullptr;
bool Highlighter::CreateHighlighter()
@@ -194,11 +236,34 @@ void Highlighter::AddDrawingPoint(MouseButton button)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
m_leftPointer = circleShape;
m_leftGeometry = circleGeometry;
// Niels-style press-down shrink: holding the button squeezes the
// circle to 70% over 180ms after a 150ms delay so quick clicks skip
// it. StartDrawingPointFading stops this animation on release.
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else if (button == MouseButton::Right)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
m_rightPointer = circleShape;
m_rightGeometry = circleGeometry;
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else
{
@@ -238,17 +303,36 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
if (button == MouseButton::Left)
{
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_leftRippleGlow)
{
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else if (button == MouseButton::Right)
{
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_rightRippleGlow)
{
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
// always / spotlight idle
if (m_spotlightMode)
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
if (m_spotlightPressed)
{
// Only update position while pressed — radius is being animated
if (m_spotlightMaskGradient)
{
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
}
else if (m_alwaysPointer)
{
@@ -259,14 +343,24 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
void Highlighter::StartDrawingPointFading(MouseButton button)
{
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
if (button == MouseButton::Left)
{
circleShape = m_leftPointer;
geom = m_leftGeometry;
}
else
{
// right
circleShape = m_rightPointer;
geom = m_rightGeometry;
}
// Stop any in-flight press-down shrink so the geometry doesn't keep
// animating while the fill is being faded out.
if (geom && m_compositor)
{
geom.StopAnimation(L"Radius");
}
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
@@ -329,6 +423,30 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
switch (wParam)
{
case WM_LBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_leftPointerEnabled)
{
// Defer the held indicator: only spawn it if the button is
// still down after the hold threshold. A quick click handled
// on button-up emits a single ripple instead.
instance->m_leftButtonPressed = true;
if (instance->m_leftHoldTimer == 0)
{
instance->m_leftHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_LEFT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_leftPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
@@ -354,6 +472,28 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_rightPointerEnabled)
{
// Defer the held indicator (see WM_LBUTTONDOWN).
instance->m_rightButtonPressed = true;
if (instance->m_rightHoldTimer == 0)
{
instance->m_rightHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_RIGHT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_rightPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
@@ -376,6 +516,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_MOUSEMOVE:
if (instance->m_rippleMode)
{
// Held ripple ring follows the cursor while a button is down,
// gated by the "follow cursor while held" setting. When the
// setting is off, the ring stays anchored at the click point.
if (instance->m_rippleShowDragTrail)
{
if (instance->m_leftButtonPressed && instance->m_leftPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
}
if (instance->m_rightButtonPressed && instance->m_rightPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Right);
}
}
break;
}
if (instance->m_leftButtonPressed)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
@@ -390,11 +548,33 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_LBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_leftButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Left);
if (instance->m_rippleMode)
{
if (instance->m_leftHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Left);
}
else
{
// Held indicator was already shown; expand + fade it.
instance->FadeRippleHoldDot(MouseButton::Left);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Left);
}
instance->m_leftButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
{
// Add AlwaysPointer only when it's enabled and RightPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -402,11 +582,32 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_rightButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Right);
if (instance->m_rippleMode)
{
if (instance->m_rightHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Right);
}
else
{
instance->FadeRippleHoldDot(MouseButton::Right);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Right);
}
instance->m_rightButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
{
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -448,9 +649,16 @@ void Highlighter::StopDrawing()
m_visible = false;
m_leftButtonPressed = false;
m_rightButtonPressed = false;
m_spotlightPressed = false;
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
m_leftGeometry = nullptr;
m_rightGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_rightRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
m_rightGlowGeometry = nullptr;
if (m_overlay)
{
m_overlay.IsVisible(false);
@@ -478,6 +686,16 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
m_rippleMode = settings.rippleMode && !m_spotlightMode;
m_rippleSize = (settings.rippleSize > 0) ? static_cast<float>(settings.rippleSize) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
m_rippleIntensity = (settings.rippleIntensity > 0.0) ? static_cast<float>(settings.rippleIntensity) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
m_rippleDurationMs = (settings.rippleDurationMs > 0) ? settings.rippleDurationMs : MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
m_rippleShowDragTrail = settings.rippleShowDragTrail;
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
// Reset transient pressed-state flag so a settings change while a button
// happens to be down doesn't leave the spotlight stuck at a shrunken size.
m_spotlightPressed = false;
if (m_spotlightMode)
{
@@ -548,6 +766,7 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
// If we would use a timer with a 50 ms period, there would be a flickering on the UI, as in most of the cases
// the pinned window hides our window in a few milliseconds.
case BRING_TO_FRONT_TIMER_ID:
{
static int fireCount = 0;
if (fireCount++ >= 4)
{
@@ -558,6 +777,24 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->BringToFront();
break;
}
case HOLD_RIPPLE_TIMER_LEFT:
// Button held past the threshold: show the persistent held indicator.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
if (instance->m_leftButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Left);
}
break;
case HOLD_RIPPLE_TIMER_RIGHT:
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
if (instance->m_rightButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Right);
}
break;
}
break;
}
default:
@@ -643,6 +880,548 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
}
}
// Spotlight press-down: shrink the mask radius briefly while a button is held.
void Highlighter::SpotlightAnimatePress()
{
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
m_spotlightPressed = true;
const float pressedRadius = m_radius * 0.85f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(120));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spotlight release: animate the mask back to the configured radius.
void Highlighter::SpotlightAnimateRelease()
{
m_spotlightPressed = false;
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto current = m_spotlightMaskGradient.EllipseRadius();
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, current);
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
anim.Duration(std::chrono::milliseconds(200));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spawn the press/hold ring + glow at the click point. The shapes persist
// until FadeRippleHoldDot is called (button-up). While held they can be
// re-positioned to follow the cursor (UpdateDrawingPointPosition).
void Highlighter::SpawnRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
// Resolve sizing/intensity from the ripple-specific settings so they're
// independent of the legacy "always-on dot" controls.
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
const float ringHeld = baseSize * 0.55f;
const float glowHeld = baseSize * 0.65f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Held indicator: appears once the button has been held past the hold
// threshold and sits at the held radius until release. It must NOT expand
// outward on appearance — it only FADES IN at the held size. The single
// outward "ripple" expansion happens exclusively on release
// (FadeRippleHoldDot). If this grew outward, a slow single click (release
// shortly after the threshold) would show grow-to-held + release as two
// expansions — the double-ripple bug.
auto dur = std::chrono::milliseconds(120);
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
// Glow color is the click color, lower alpha (×0.30), scaled by intensity.
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
auto glowTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Ring color uses full base alpha (alphaMul like the press recipe).
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ringTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Clean up any stray "still held" shapes for this button — guards against
// stray button-down without matching button-up (e.g. focus loss).
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (m_shape && m_shape.Shapes())
{
auto shapes = m_shape.Shapes();
uint32_t idx = 0;
if (heldRing && shapes.IndexOf(heldRing, idx))
{
shapes.RemoveAt(idx);
}
if (heldGlow && shapes.IndexOf(heldGlow, idx))
{
shapes.RemoveAt(idx);
}
}
// Glow (filled) — added first so the ring renders on top. Sits at the held
// radius and fades its alpha in (no outward size growth).
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowHeld, glowHeld });
auto glowBrush = m_compositor.CreateColorBrush(glowTransparent);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
auto glowFadeIn = m_compositor.CreateColorKeyFrameAnimation();
glowFadeIn.InsertKeyFrame(0.0f, glowTransparent);
glowFadeIn.InsertKeyFrame(1.0f, glowColor, ease);
glowFadeIn.Duration(dur);
glowBrush.StartAnimation(L"Color", glowFadeIn);
// Ring (stroked) — same: fixed at held radius, alpha fade-in only.
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringHeld, ringHeld });
auto ringBrush = m_compositor.CreateColorBrush(ringTransparent);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
auto ringFadeIn = m_compositor.CreateColorKeyFrameAnimation();
ringFadeIn.InsertKeyFrame(0.0f, ringTransparent);
ringFadeIn.InsertKeyFrame(1.0f, ringColor, ease);
ringFadeIn.Duration(dur);
ringBrush.StartAnimation(L"Color", ringFadeIn);
heldRing = ringShape;
heldGlow = glowShape;
heldGeom = ringGeom;
heldGlowGeom = glowGeom;
}
// Continue the held-ring/glow animation outward and fade both to transparent.
// For right-click, optionally spawn the expanding crosshair lines.
void Highlighter::FadeRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (!heldRing && !heldGlow)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringHeld = baseSize * 0.55f;
const float ringEnd = baseSize * 1.05f;
const float glowHeld = baseSize * 0.65f;
const float glowEnd = baseSize * 1.40f;
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Track everything spawned by this fade (and the held shapes themselves)
// so the completion callback can remove them in one pass.
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
if (heldGlow && heldGlowGeom)
{
// The held indicator has settled at the held radius; expand it outward
// from there and fade it to transparent.
heldGlowGeom.StopAnimation(L"Radius");
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowHeld, glowHeld });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
heldGlowGeom.StartAnimation(L"Radius", glowAnim);
auto brush = heldGlow.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldGlow);
}
if (heldRing && heldGeom)
{
heldGeom.StopAnimation(L"Radius");
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringHeld, ringHeld });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
heldGeom.StartAnimation(L"Radius", ringAnim);
auto brush = heldRing.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldRing);
}
// Right-click only: spawn expanding crosshair lines centered on the ring.
// Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse && heldRing)
{
const float xhairAlphaMul = 0.18f + intensity * 0.78f;
auto xhairColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * xhairAlphaMul), color.R, color.G, color.B);
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
auto center = heldRing.Offset();
const float startSpan = ringHeld * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(center.x - startSpan, center.y, center.x - startSpan * 0.30f, center.y,
center.x - endSpan, center.y, center.x - endSpan * 0.30f, center.y);
makeLine(center.x + startSpan * 0.30f, center.y, center.x + startSpan, center.y,
center.x + endSpan * 0.30f, center.y, center.x + endSpan, center.y);
// Vertical line (top half, bottom half).
makeLine(center.x, center.y - startSpan, center.x, center.y - startSpan * 0.30f,
center.x, center.y - endSpan, center.x, center.y - endSpan * 0.30f);
makeLine(center.x, center.y + startSpan * 0.30f, center.x, center.y + startSpan,
center.x, center.y + endSpan * 0.30f, center.x, center.y + endSpan);
}
// Detach our member handles BEFORE the batch completes so subsequent
// press events on this button create fresh shapes rather than racing.
heldRing = nullptr;
heldGlow = nullptr;
heldGeom = nullptr;
heldGlowGeom = nullptr;
batch.End();
if (spawned->empty())
{
return;
}
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
// Self-contained single ripple for a quick click (press + release before the
// hold threshold). Spawns a fresh ring + glow that grow from the click point
// outward and fade to transparent in one continuous animation — no held
// indicator, so a single click produces exactly one ripple. For right-click,
// optionally spawns the expanding crosshair lines too.
void Highlighter::EmitSingleRipple(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringStart = baseSize * 0.20f;
const float ringEnd = baseSize * 1.05f;
const float glowStart = baseSize * 0.30f;
const float glowEnd = baseSize * 1.40f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
// Glow (filled) — added first so the ring renders on top.
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
spawned->push_back(glowShape);
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
glowGeom.StartAnimation(L"Radius", glowAnim);
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
glowColorAnim.InsertKeyFrame(1.0f, transparent, ease);
glowColorAnim.Duration(dur);
glowBrush.StartAnimation(L"Color", glowColorAnim);
// Ring (stroked).
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(ringColor);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
spawned->push_back(ringShape);
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
ringGeom.StartAnimation(L"Radius", ringAnim);
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
ringColorAnim.InsertKeyFrame(0.0f, ringColor);
ringColorAnim.InsertKeyFrame(1.0f, transparent, ease);
ringColorAnim.Duration(dur);
ringBrush.StartAnimation(L"Color", ringColorAnim);
// Right-click only: spawn expanding crosshair lines centered on the click
// point. Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse)
{
auto xhairColor = ringColor;
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
const float startSpan = (baseSize * 0.55f) * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(fx - startSpan, fy, fx - startSpan * 0.30f, fy,
fx - endSpan, fy, fx - endSpan * 0.30f, fy);
makeLine(fx + startSpan * 0.30f, fy, fx + startSpan, fy,
fx + endSpan * 0.30f, fy, fx + endSpan, fy);
// Vertical line (top half, bottom half).
makeLine(fx, fy - startSpan, fx, fy - startSpan * 0.30f,
fx, fy - endSpan, fx, fy - endSpan * 0.30f);
makeLine(fx, fy + startSpan * 0.30f, fx, fy + startSpan,
fx, fy + endSpan * 0.30f, fx, fy + endSpan);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)

View File

@@ -4,10 +4,16 @@
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
// Ripple-specific defaults (independent of the always-on circle settings above).
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE = 60;
constexpr double MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY = 0.7;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS = 480;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL = true;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE = true;
struct MouseHighlighterSettings
{
@@ -19,6 +25,12 @@ struct MouseHighlighterSettings
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
bool spotlightMode = false;
bool rippleMode = true;
int rippleSize = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE;
double rippleIntensity = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY;
int rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -21,6 +21,12 @@ namespace
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
const wchar_t JSON_KEY_RIPPLE_SIZE[] = L"ripple_size";
const wchar_t JSON_KEY_RIPPLE_INTENSITY[] = L"ripple_intensity";
const wchar_t JSON_KEY_RIPPLE_DURATION_MS[] = L"ripple_duration_ms";
const wchar_t JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL[] = L"ripple_show_drag_trail";
const wchar_t JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE[] = L"ripple_show_release_pulse";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -392,6 +398,90 @@ public:
{
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
}
try
{
// Parse ripple mode
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
}
try
{
// Parse ripple size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleSize = value;
}
else
{
throw std::runtime_error("Invalid ripple size value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple size from settings. Will use default value");
}
try
{
// Parse ripple intensity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_INTENSITY);
double value = jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE);
if (value > 0.0)
{
highlightSettings.rippleIntensity = value;
}
else
{
throw std::runtime_error("Invalid ripple intensity value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple intensity from settings. Will use default value");
}
try
{
// Parse ripple duration
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_DURATION_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleDurationMs = value;
}
else
{
throw std::runtime_error("Invalid ripple duration value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple duration from settings. Will use default value");
}
try
{
// Parse ripple show drag trail
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL);
highlightSettings.rippleShowDragTrail = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show drag trail from settings. Will use default value");
}
try
{
// Parse ripple show release pulse
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE);
highlightSettings.rippleShowReleasePulse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
}
}
else
{

View File

@@ -0,0 +1,821 @@
PackageName: BlackmagicDesign.DaVinciResolve
Name: DaVinci Resolve
WindowFilter: "Resolve.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Popular shortcuts
Properties:
- Name: Edit
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F5
- Name: Color
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Fairlight
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F7
- Name: Deliver
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F8
- Name: Play / Pause
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Space
- Name: Play Reverse
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- J
- Name: Stop
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Play Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- L
- Name: Import Media
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Export / Deliver
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- E
- Name: Save Project
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Cut Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Blade Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Ripple Delete
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Delete
- Name: Undo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Z
- Name: Mark In
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- I
- Name: Mark Out
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- O
- Name: Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Select All
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- A
- Name: Go to Beginning
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Home
- Name: Go to End
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Snapping
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- N
- Name: Selection Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- A
- Name: Trim Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- T
- Name: Change Clip Speed
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- SectionName: Timeline navigation
Properties:
- Name: Go to Next Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Right
- Name: Go to Previous Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Left
- Name: Jump Forward 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Right
- Name: Jump Back 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Left
- Name: Go to Next Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Up
- Name: Go to Previous Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Down
- Name: Go to Next Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Down
- Name: Go to Previous Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Up
- Name: Zoom In Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Equals
- Name: Zoom Out Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Minus
- Name: Full Screen Playback
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Space
- Name: Go to Previous Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: Go to Next Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Edit
Properties:
- Name: Delete
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Delete
- Name: Copy
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Paste
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- V
- Name: Cut
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Duplicate Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Render in Place
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- Name: Add Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Append to End of Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Replace Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Move Clip Up One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Up
- Name: Move Clip Down One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Down
- Name: Split Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Link Clips
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- L
- Name: Create Compound Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- G
- SectionName: Color
Properties:
- Name: Add Serial Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Add Parallel Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- P
- Name: Add Layer Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- L
- Name: Select Node 1
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Node 2
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Node 3
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Node 4
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Node 5
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Enable/Disable Current Grade
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- D
- Name: Preview Mode
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- W
- Name: Grade All Frames in Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Keyframe Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Select Color Wheels
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Curves
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Qualifier
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Power Window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Tracking
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Reset Color Grade
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- SectionName: Fairlight
Properties:
- Name: Mute Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Solo Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- S
- Name: Automation Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F
- Name: Record Arm Selected Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Headphones Solo
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- H
- Name: Add Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Insert
- Name: Add Audio Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- A
- Name: Bounce Mix
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- SectionName: Fusion
Properties:
- Name: Switch Between Spline and Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Add Keyframe
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Shift
- Name: View Current Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: View Node Flow
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: View Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: View Spline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Merge Selected Tools
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Bypass Selected Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- SectionName: Media
Properties:
- Name: Reveal in Explorer
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Smart Bin
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- S
- Name: Rename Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Import XML / AAF
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- I
- Name: Create New Bin
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Add Clip to Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Enter
- Name: Viewer Zoom In
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Equals
- Name: Viewer Zoom Out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Minus
- SectionName: Deliver
Properties:
- Name: Add to Render Queue
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Enter
- Name: Start Render
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Enter
- Name: Select Preset
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Render Settings
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Browse Output Location
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B

View File

@@ -5,19 +5,34 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
<Nullable>enable</Nullable>
<RootNamespace>Awake</RootNamespace>
<AssemblyName>PowerToys.Awake</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Awake\Awake.ico</ApplicationIcon>
<Platforms>x64;ARM64</Platforms>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>PowerToys.Awake.pri</ProjectPriFileName>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<UseWindowsForms>False</UseWindowsForms>
<!--Per documentation: https://learn.microsoft.com/dotnet/core/compatibility/windows-forms/5.0/automatically-infer-winexe-output-type#outputtype-set-to-winexe-for-wpf-and-winforms-apps -->
<DisableWinExeOutputInference>true</DisableWinExeOutputInference>
<AssemblyName>PowerToys.Awake</AssemblyName>
<ApplicationIcon>Assets\Awake\Awake.ico</ApplicationIcon>
<!-- Background tray app: workstation, non-concurrent GC keeps a smaller heap reserve. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
<PackageProjectUrl>https://awake.den.dev</PackageProjectUrl>
<RepositoryUrl>https://github.com/microsoft/powertoys</RepositoryUrl>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<!-- Awake provides its own Program.Main; suppress the XAML-generated entry point. -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
@@ -25,9 +40,27 @@
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<!-- Add WindowsDesktop.App framework reference to align Microsoft.VisualBasic.dll version
with other projects that use UseWPF/UseWindowsForms. This does NOT enable WPF/WinForms,
it only ensures consistent runtime DLL versions across all WinUI3Apps. -->
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup>
<Page Remove="AwakeXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="AwakeXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<Folder Include="AwakeXAML\" />
</ItemGroup>
<ItemGroup>
<None Remove="Assets\Awake\Awake.ico" />
<None Remove="Assets\Awake\disabled.ico" />
@@ -39,15 +72,39 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.CommandLine" />
<PackageReference Include="System.Reactive" />
<PackageReference Include="System.Runtime.Caching" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI.Controls\Common.UI.Controls.csproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
</ItemGroup>
@@ -93,6 +150,15 @@
</EmbeddedResource>
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<ItemGroup>
<PackageReference Update="Microsoft.CodeAnalysis.NetAnalyzers">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="Awake.AwakeApp"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Awake.Core;
using ManagedCommon;
using Microsoft.UI.Xaml;
namespace Awake
{
/// <summary>
/// WinUI application host for Awake. Owns the (initially hidden) flyout window
/// and the tray icon service that toggles its visibility.
/// </summary>
public partial class AwakeApp : Application, IDisposable
{
private readonly bool _startedFromPowerToys;
private MainWindow? _mainWindow;
private TrayIconService? _trayIconService;
private bool _disposed;
public static new AwakeApp? Current { get; private set; }
public MainWindow? MainWindow => _mainWindow;
public AwakeApp(bool startedFromPowerToys)
{
_startedFromPowerToys = startedFromPowerToys;
Current = this;
this.InitializeComponent();
this.UnhandledException += OnUnhandledException;
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
try
{
Logger.LogInfo("AwakeApp.OnLaunched: creating MainWindow");
_mainWindow = new MainWindow(_startedFromPowerToys);
Logger.LogInfo("AwakeApp.OnLaunched: creating TrayIconService");
_trayIconService = new TrayIconService(toggleWindow: () => _mainWindow?.ToggleWindow());
_trayIconService.SetupTrayIcon(Constants.FullAppName, TrayIconService.DefaultIcon);
// Apply the current Awake mode (this also updates the tray icon to match).
Manager.SetModeShellIcon(forceAdd: true);
}
catch (Exception ex)
{
Logger.LogError($"AwakeApp.OnLaunched failed: {ex}");
}
}
public void UpdateTrayIcon(System.Drawing.Icon icon, string tooltip)
{
_trayIconService?.UpdateIcon(icon, tooltip);
}
public void Shutdown()
{
Logger.LogInfo("AwakeApp.Shutdown");
_trayIconService?.Destroy();
_trayIconService = null;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_mainWindow?.Dispose();
_mainWindow = null;
_trayIconService?.Destroy();
_trayIconService = null;
GC.SuppressFinalize(this);
}
private static void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
Logger.LogError($"AwakeApp unhandled exception: {e.Exception}");
}
}
}

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Awake.AwakeAppPickerPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:Awake.Core.Models"
KeyDown="OnKeyDown">
<Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header with back button -->
<Grid Padding="4,4,16,8" ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Name="BackButton"
x:Uid="AwakeBackButton"
Width="40"
Height="40"
VerticalAlignment="Center"
Click="OnBackClick"
Style="{StaticResource SubtleButtonStyle}">
<FontIcon FontSize="14" Glyph="&#xE72B;" />
</Button>
<TextBlock
x:Uid="AppPickerTitle"
Grid.Column="1"
Margin="0,-1,0,0"
VerticalAlignment="Center" />
</Grid>
<AutoSuggestBox
x:Name="AppSearchBox"
x:Uid="AppSearchBox"
Grid.Row="1"
Margin="16,0,16,8"
QueryIcon="Find"
TextChanged="OnAppSearchTextChanged" />
<Grid Grid.Row="2">
<ListView
x:Name="AppListView"
Padding="8,0,8,12"
IsItemClickEnabled="True"
ItemClick="OnAppSelected"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:RunningAppInfo">
<Grid Padding="0,4" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Image
Grid.Column="0"
Width="16"
Height="16"
VerticalAlignment="Center"
Source="{x:Bind Icon}" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{x:Bind DisplayName}" TextTrimming="CharacterEllipsis" />
<!--<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind WindowTitle}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />-->
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<ProgressRing
x:Name="AppLoadingRing"
Width="32"
Height="32"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsActive="False"
Visibility="Collapsed" />
<TextBlock
x:Name="AppEmptyText"
x:Uid="AppEmptyText"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed" />
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,157 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Awake.Core;
using Awake.Core.Models;
using Awake.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
namespace Awake
{
/// <summary>
/// Lets the user pick a running app to keep the system awake while it runs. Selecting an app
/// records it as the pending selection and returns to the launch page; it starts when the user
/// presses Start (or live-rebinds if a session is already running).
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public sealed partial class AwakeAppPickerPage : Page
{
private AwakeFlyoutNavigationContext? _context;
private List<RunningAppInfo> _windowedApps = new();
private List<RunningAppInfo> _allProcesses = new();
private bool _allProcessesLoaded;
private const int MaxResults = 100;
public AwakeAppPickerPage()
{
InitializeComponent();
}
public AwakeFlyoutViewModel ViewModel { get; private set; } = default!;
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is AwakeFlyoutNavigationContext context)
{
_context = context;
ViewModel = context.ViewModel;
}
_ = LoadRunningAppsAsync();
}
private async Task LoadRunningAppsAsync()
{
AppSearchBox.Text = string.Empty;
AppListView.ItemsSource = null;
AppEmptyText.Visibility = Visibility.Collapsed;
AppLoadingRing.IsActive = true;
AppLoadingRing.Visibility = Visibility.Visible;
_windowedApps = await RunningAppsProvider.GetRunningAppsAsync();
AppLoadingRing.IsActive = false;
AppLoadingRing.Visibility = Visibility.Collapsed;
await ApplyAppFilterAsync(string.Empty);
// Load the full process list in the background so the first search is responsive.
_ = LoadAllProcessesAsync();
}
private async Task LoadAllProcessesAsync()
{
if (_allProcessesLoaded)
{
return;
}
_allProcesses = await RunningAppsProvider.GetAllProcessesAsync();
_allProcessesLoaded = true;
}
private async Task ApplyAppFilterAsync(string query)
{
// Empty query shows the curated windowed-app list; typing searches every process.
bool searching = !string.IsNullOrWhiteSpace(query);
List<RunningAppInfo> source = searching && _allProcessesLoaded ? _allProcesses : _windowedApps;
IEnumerable<RunningAppInfo> filtered = source;
if (searching)
{
filtered = source.Where(a =>
a.DisplayName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|| a.WindowTitle.Contains(query, StringComparison.CurrentCultureIgnoreCase));
}
List<RunningAppInfo> list = filtered.Take(MaxResults).ToList();
// Build icons lazily for only the items about to be shown (the full process list can be
// large, so we avoid materializing hundreds of bitmaps up front).
foreach (RunningAppInfo app in list)
{
if (app.Icon is null && app.IconBytes is not null)
{
app.Icon = await RunningAppsProvider.BuildIconAsync(app.IconBytes);
}
}
AppListView.ItemsSource = list;
AppEmptyText.Visibility = list.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private async void OnAppSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput)
{
// If the user starts typing before the full list finished loading, fetch it now.
if (!string.IsNullOrWhiteSpace(sender.Text) && !_allProcessesLoaded)
{
await LoadAllProcessesAsync();
}
await ApplyAppFilterAsync(sender.Text);
}
}
private void OnAppSelected(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is RunningAppInfo app)
{
ViewModel.SetPendingApp(app.ProcessId, app.DisplayName, app.Icon);
GoBack();
}
}
private void OnBackClick(object sender, RoutedEventArgs e) => GoBack();
private void OnKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Escape)
{
GoBack();
e.Handled = true;
}
}
private void GoBack()
{
if (Frame != null && Frame.CanGoBack)
{
Frame.GoBack();
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Awake.AwakeCustomTimePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
KeyDown="OnKeyDown">
<Page.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="ms-appx:///CommunityToolkit.WinUI.Controls.Segmented/Segmented/Segmented.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Page.Resources>
<Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header with back button -->
<Grid Padding="4,4,16,8" ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Name="BackButton"
x:Uid="AwakeBackButton"
Width="40"
Height="40"
VerticalAlignment="Center"
Click="OnBackClick"
Style="{StaticResource SubtleButtonStyle}">
<FontIcon FontSize="14" Glyph="&#xE72B;" />
</Button>
<TextBlock
x:Uid="CustomTimeTitle"
Grid.Column="1"
Margin="0,-1,0,0"
VerticalAlignment="Center" />
</Grid>
<StackPanel
Grid.Row="1"
Padding="16,8,16,16"
Spacing="12">
<tkcontrols:Segmented
x:Name="CustomTypeSelector"
HorizontalAlignment="Stretch"
SelectedIndex="0">
<tkcontrols:SegmentedItem x:Name="CustomDurationSegment" x:Uid="CustomDurationRadio" />
<tkcontrols:SegmentedItem x:Name="CustomUntilSegment" x:Uid="CustomUntilRadio" />
</tkcontrols:Segmented>
<tkcontrols:SwitchPresenter TargetType="x:Int32" Value="{x:Bind CustomTypeSelector.SelectedIndex, Mode=OneWay}">
<!-- Case 0 = duration (hours/minutes) -->
<tkcontrols:Case Value="0">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<NumberBox
x:Name="IntervalHoursInput"
x:Uid="IntervalHoursInput"
Grid.Column="0"
LargeChange="5"
Maximum="23"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.IntervalHours, Mode=TwoWay}" />
<NumberBox
x:Name="IntervalMinutesInput"
x:Uid="IntervalMinutesInput"
Grid.Column="1"
LargeChange="5"
Maximum="59"
Minimum="0"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.IntervalMinutes, Mode=TwoWay}" />
</Grid>
</tkcontrols:Case>
<!-- Case 1 = until a specific date/time -->
<tkcontrols:Case Value="1">
<StackPanel Spacing="8">
<DatePicker
x:Name="ExpirationDatePicker"
x:Uid="ExpirationDatePicker"
HorizontalAlignment="Stretch"
Date="{x:Bind ViewModel.ExpirationDate, Mode=TwoWay}" />
<TimePicker
x:Name="ExpirationTimePicker"
x:Uid="ExpirationTimePicker"
HorizontalAlignment="Stretch"
ClockIdentifier="24HourClock"
MinuteIncrement="5"
Time="{x:Bind ViewModel.ExpirationTime, Mode=TwoWay}" />
</StackPanel>
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
<Button
x:Name="CustomApplyButton"
x:Uid="CustomApplyButton"
HorizontalAlignment="Stretch"
Click="OnApplyClick" />
</StackPanel>
</Grid>
</Page>

View File

@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using Awake.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
namespace Awake
{
/// <summary>
/// Lets the user configure a custom keep-awake duration (hours/minutes) or an "until a
/// specific date and time" expiration, then applies it and returns to the launch page.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public sealed partial class AwakeCustomTimePage : Page
{
private AwakeFlyoutNavigationContext? _context;
public AwakeCustomTimePage()
{
InitializeComponent();
}
public AwakeFlyoutViewModel ViewModel { get; private set; } = default!;
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is AwakeFlyoutNavigationContext context)
{
_context = context;
ViewModel = context.ViewModel;
this.Bindings.Update();
// Reflect the current pending selection so reopening keeps the chosen sub-mode.
// The SwitchPresenter swaps the duration/until panels off this selection in XAML.
CustomTypeSelector.SelectedIndex = ViewModel.PendingCustomIsUntil ? 1 : 0;
}
}
private void OnApplyClick(object sender, RoutedEventArgs e)
{
ViewModel.SetPendingCustom(CustomTypeSelector.SelectedIndex == 1);
GoBack();
}
private void OnBackClick(object sender, RoutedEventArgs e) => GoBack();
private void OnKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Escape)
{
GoBack();
e.Handled = true;
}
}
private void GoBack()
{
if (Frame != null && Frame.CanGoBack)
{
Frame.GoBack();
}
}
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Awake.ViewModels;
namespace Awake
{
/// <summary>
/// Carries the shared <see cref="AwakeFlyoutViewModel"/> (and a request-to-close callback)
/// between the flyout pages hosted in <see cref="AwakeShellPage"/>'s navigation frame.
/// </summary>
internal sealed class AwakeFlyoutNavigationContext
{
public AwakeFlyoutNavigationContext(AwakeFlyoutViewModel viewModel, Action requestClose)
{
ViewModel = viewModel;
RequestClose = requestClose;
}
public AwakeFlyoutViewModel ViewModel { get; }
public Action RequestClose { get; }
}
}

View File

@@ -0,0 +1,537 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Awake.AwakeLaunchPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
xmlns:animations="using:CommunityToolkit.WinUI.Animations"
KeyDown="OnKeyDown">
<Page.Resources>
<Style
x:Key="FlyoutIconButtonStyle"
BasedOn="{StaticResource SubtleButtonStyle}"
TargetType="Button">
<Setter Property="Padding" Value="6" />
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
</Style>
<Style x:Key="DurationCardStyle" TargetType="ToggleButton">
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="48" />
<Setter Property="Padding" Value="4,8" />
<Setter Property="FontSize" Value="12" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource ControlElevationBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontSize" Value="12" />
<Setter Property="CornerRadius" Value="{StaticResource ControlCornerRadius}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid
x:Name="RootGrid"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter
x:Name="ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
AutomationProperties.AccessibilityView="Raw"
Content="{TemplateBinding Content}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Foreground="{TemplateBinding Foreground}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource CardBackgroundFillColorSecondaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource CardBackgroundFillColorSecondaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource ControlFillColorDisabledBrush}" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Checked">
<VisualState.Setters>
<Setter Target="RootGrid.BorderBrush" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Target="RootGrid.BorderThickness" Value="2" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="CheckedPointerOver">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource CardBackgroundFillColorSecondaryBrush}" />
<Setter Target="RootGrid.BorderBrush" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Target="RootGrid.BorderThickness" Value="2" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="CheckedPressed">
<VisualState.Setters>
<Setter Target="RootGrid.Background" Value="{ThemeResource CardBackgroundFillColorSecondaryBrush}" />
<Setter Target="RootGrid.BorderBrush" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Target="RootGrid.BorderThickness" Value="2" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="CustomCardStyle"
BasedOn="{StaticResource DefaultButtonStyle}"
TargetType="Button">
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="48" />
<Setter Property="Padding" Value="4,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="6" />
</Style>
</Page.Resources>
<Grid
x:Name="RootGrid"
IsTabStop="True"
TabFocusNavigation="Local">
<Grid.RowDefinitions>
<RowDefinition Height="120" />
<RowDefinition Height="*" />
<RowDefinition Height="48" />
</Grid.RowDefinitions>
<!-- Idle header: neutral status ring + title/subtitle -->
<Grid
x:Name="IdleHeader"
Padding="16"
ColumnSpacing="16"
RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" MinHeight="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation
From="0.0"
To="1.0"
Duration="0:0:0.4" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation
From="1.0"
To="0.0"
Duration="0:0:0.25" />
</animations:Implicit.HideAnimations>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
x:Uid="HeaderTitle"
Grid.Row="1"
FontSize="16"
FontWeight="SemiBold"
TextWrapping="Wrap" />
<TextBlock
x:Uid="HeaderSubtitle"
Grid.Row="2"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<Button
x:Name="StartButton"
Grid.RowSpan="3"
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Click="OnStartButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE768;" />
<TextBlock x:Uid="ActionStartText" />
</StackPanel>
</Button>
</Grid>
<!-- Active header: translucent accent base + animated accent glow + countdown -->
<Border
x:Name="ActiveHeader"
Grid.Row="0"
Visibility="Collapsed">
<Border.Background>
<LinearGradientBrush Opacity="0.6" StartPoint="0.5,0" EndPoint="0.5,1">
<GradientStop Offset="0.0" Color="{ThemeResource SystemAccentColorLight3}" />
<GradientStop Offset="1.0" Color="Transparent" />
</LinearGradientBrush>
</Border.Background>
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation
From="0.0"
To="1.0"
Duration="0:0:0.4" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation
From="1.0"
To="0.0"
Duration="0:0:0.25" />
</animations:Implicit.HideAnimations>
<Grid>
<!-- Subtle, organic accent glow (Composition-animated) — fills the whole header -->
<Grid
x:Name="HeaderGlowHost"
IsHitTestVisible="False" />
<Grid
Padding="16"
ColumnSpacing="16"
RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" MinHeight="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
Padding="8,2"
HorizontalAlignment="Left"
CornerRadius="4">
<Border.Background>
<SolidColorBrush Opacity="0.22" Color="{ThemeResource SystemAccentColor}" />
</Border.Background>
<TextBlock
x:Uid="ActiveBadgeText"
FontSize="10"
FontWeight="SemiBold"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}" />
</Border>
<StackPanel
Grid.Row="1"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Orientation="Horizontal"
Spacing="8">
<Image
Width="24"
Height="24"
VerticalAlignment="Center"
Source="{x:Bind ViewModel.WhileAppCardIcon, Mode=OneWay}"
Visibility="{x:Bind ViewModel.ActiveAppIconVisibility, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Bottom"
FontSize="20"
FontWeight="SemiBold"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Text="{x:Bind ViewModel.CountdownTime, Mode=OneWay}"
Visibility="{x:Bind ViewModel.ActiveCountdownVisibility, Mode=OneWay}" />
</StackPanel>
<TextBlock
Grid.Row="2"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.OffAtText, Mode=OneWay}" />
<Button
x:Name="ActionButton"
Grid.RowSpan="3"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Click="OnStopButtonClick"
Style="{StaticResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE71A;" />
<TextBlock x:Uid="ActionStopText" />
</StackPanel>
</Button>
</Grid>
</Grid>
</Border>
<Grid
Grid.Row="1"
Padding="16"
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="0,1,0,1"
RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ToggleButton
x:Name="Card30"
Click="OnDurationCardClick"
Style="{StaticResource DurationCardStyle}"
Tag="30">
<StackPanel HorizontalAlignment="Center" Spacing="0">
<TextBlock
HorizontalAlignment="Center"
FontSize="14"
FontWeight="SemiBold"
Text="30" />
<TextBlock
x:Uid="Card30Unit"
HorizontalAlignment="Center"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</ToggleButton>
<ToggleButton
x:Name="Card60"
Grid.Column="1"
Click="OnDurationCardClick"
Style="{StaticResource DurationCardStyle}"
Tag="60">
<StackPanel HorizontalAlignment="Center" Spacing="0">
<TextBlock
HorizontalAlignment="Center"
FontSize="14"
FontWeight="SemiBold"
Text="1" />
<TextBlock
x:Uid="Card60Unit"
HorizontalAlignment="Center"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</ToggleButton>
<ToggleButton
x:Name="Card120"
Grid.Column="2"
Click="OnDurationCardClick"
Style="{StaticResource DurationCardStyle}"
Tag="120">
<StackPanel HorizontalAlignment="Center" Spacing="0">
<TextBlock
HorizontalAlignment="Center"
FontSize="14"
FontWeight="SemiBold"
Text="2" />
<TextBlock
x:Uid="Card120Unit"
HorizontalAlignment="Center"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</ToggleButton>
<ToggleButton
x:Name="CardForever"
Grid.Column="3"
Click="OnForeverCardClick"
FontSize="16"
Style="{StaticResource DurationCardStyle}">
<StackPanel HorizontalAlignment="Center" Spacing="0">
<TextBlock
HorizontalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Text="∞" />
<TextBlock
x:Uid="CardForeverText"
HorizontalAlignment="Center"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</ToggleButton>
</Grid>
<!-- Custom: left toggle selects, right chevron navigates to the picker page -->
<Grid Grid.Row="1">
<ToggleButton
x:Name="CardCustom"
MinHeight="48"
Padding="8,4,40,4"
HorizontalAlignment="Stretch"
Click="OnCustomToggleClick"
Style="{StaticResource DurationCardStyle}">
<Grid HorizontalAlignment="Stretch" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
VerticalAlignment="Center"
FontSize="16"
Glyph="&#xE823;" />
<TextBlock
x:Name="CardCustomText"
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Bind ViewModel.CustomCardText, Mode=OneWay}"
TextWrapping="Wrap" />
</Grid>
</ToggleButton>
<Rectangle
Width="1"
Margin="0,8,36,8"
HorizontalAlignment="Right"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
IsHitTestVisible="False" />
<Button
x:Name="CardCustomNav"
x:Uid="CardCustomNav"
Width="36"
Margin="2"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
Click="OnCustomNavClick"
CornerRadius="0,4,4,0"
Style="{StaticResource SubtleButtonStyle}">
<FontIcon FontSize="12" Glyph="&#xE76C;" />
</Button>
</Grid>
<!-- While app runs: left toggle selects, right chevron navigates to the app picker -->
<Grid Grid.Row="2">
<ToggleButton
x:Name="CardWhileApp"
MinHeight="48"
Padding="8,4,40,4"
HorizontalAlignment="Stretch"
Click="OnWhileAppToggleClick"
Style="{StaticResource DurationCardStyle}">
<Grid HorizontalAlignment="Stretch" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Grid.Column="0"
Width="20"
Height="20"
VerticalAlignment="Center">
<Image
x:Name="CardWhileAppIcon"
Width="20"
Height="20"
Source="{x:Bind ViewModel.WhileAppCardIcon, Mode=OneWay}"
Visibility="Collapsed" />
<FontIcon
x:Name="CardWhileAppGlyph"
FontSize="16"
Glyph="&#xE7F4;" />
</Grid>
<TextBlock
x:Name="CardWhileAppText"
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Bind ViewModel.WhileAppCardText, Mode=OneWay}"
TextWrapping="Wrap" />
</Grid>
</ToggleButton>
<Rectangle
Width="1"
Margin="0,8,36,8"
HorizontalAlignment="Right"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
IsHitTestVisible="False" />
<Button
x:Name="CardWhileAppNav"
x:Uid="CardWhileAppNav"
Width="36"
Margin="2"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
Click="OnWhileAppNavClick"
CornerRadius="0,4,4,0"
Style="{StaticResource SubtleButtonStyle}">
<FontIcon FontSize="12" Glyph="&#xE76C;" />
</Button>
</Grid>
<!-- Keep screen on -->
<CheckBox
x:Name="KeepDisplayOnToggle"
x:Uid="KeepDisplayOnToggle"
Grid.Row="3"
Margin="0,4,0,0"
IsChecked="{x:Bind ViewModel.KeepDisplayOn, Mode=TwoWay}"
IsEnabled="{x:Bind ViewModel.KeepDisplayOnEnabled, Mode=OneWay}" />
<!-- Keep awake with lid closed -->
<CheckBox
Grid.Row="4"
VerticalAlignment="Top"
Content="Keep awake when lid is closed" />
</Grid>
<Grid Grid.Row="2">
<Button
x:Name="OpenSettingsButton"
x:Uid="OpenSettingsButton"
Margin="0,0,12,0"
HorizontalAlignment="Right"
Click="OnOpenSettingsClick"
Style="{StaticResource FlyoutIconButtonStyle}">
<AnimatedIcon x:Name="SettingsAnimatedIcon">
<AnimatedIcon.Source>
<animatedVisuals:AnimatedSettingsVisualSource />
</AnimatedIcon.Source>
<AnimatedIcon.FallbackIconSource>
<SymbolIconSource Symbol="Setting" />
</AnimatedIcon.FallbackIconSource>
</AnimatedIcon>
</Button>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ActivationStates">
<VisualState x:Name="IdleState" />
<VisualState x:Name="ActiveState">
<VisualState.StateTriggers>
<StateTrigger IsActive="{x:Bind ViewModel.IsActive, Mode=OneWay}" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="IdleHeader.Visibility" Value="Collapsed" />
<Setter Target="ActiveHeader.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</Page>

View File

@@ -0,0 +1,557 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Numerics;
using Awake.ViewModels;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI;
using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
using Windows.UI;
namespace Awake
{
/// <summary>
/// The flyout's main page: the idle/active header (with the composition glow) and the
/// duration selection. The "Custom" and "While app runs" cards navigate the shell frame to
/// their own pages instead of opening flyouts.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public sealed partial class AwakeLaunchPage : Page
{
private ContainerVisual? _glowRoot;
private AwakeFlyoutNavigationContext? _context;
private bool _subscribed;
public AwakeLaunchPage()
{
InitializeComponent();
HeaderGlowHost.Loaded += OnHeaderGlowLoaded;
HeaderGlowHost.SizeChanged += OnHeaderGlowSizeChanged;
}
public AwakeFlyoutViewModel ViewModel { get; private set; } = default!;
/// <summary>
/// Moves keyboard focus into the page so Escape and tab navigation work.
/// </summary>
public void FocusContent() => RootGrid.Focus(FocusState.Programmatic);
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is AwakeFlyoutNavigationContext context)
{
_context = context;
ViewModel = context.ViewModel;
this.Bindings.Update();
if (!_subscribed)
{
ViewModel.PropertyChanged += OnViewModelPropertyChanged;
_subscribed = true;
}
}
// Only realign the pending selection with the running mode on a fresh open / forward
// navigation. On Back navigation the user may have just chosen a custom duration or an
// app on a sub-page; re-syncing here would clobber that pending selection.
if (e.NavigationMode != NavigationMode.Back)
{
ViewModel?.SyncPendingFromMode();
}
HighlightSelectedCard();
RefreshWhileAppVisuals();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedFrom(e);
if (_subscribed && ViewModel is not null)
{
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
_subscribed = false;
}
}
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AwakeFlyoutViewModel.Mode))
{
ViewModel?.SyncPendingFromMode();
HighlightSelectedCard();
RefreshWhileAppVisuals();
}
else if (e.PropertyName == nameof(AwakeFlyoutViewModel.WhileAppCardIcon))
{
RefreshWhileAppVisuals();
}
}
// Highlights the card matching the current pending selection so the buttons and the header
// state stay in lockstep.
private void HighlightSelectedCard()
{
if (ViewModel is null)
{
return;
}
ToggleButton? selected = ViewModel.PendingSelection switch
{
FlyoutSelectionKind.Forever => CardForever,
FlyoutSelectionKind.Custom => CardCustom,
FlyoutSelectionKind.WhileApp => CardWhileApp,
_ => CardForMinutes(ViewModel.PendingMinutes),
};
SetSelectedCard(selected);
}
// Shows the captured app icon on the While-app card when one is available, otherwise falls
// back to the generic glyph.
private void RefreshWhileAppVisuals()
{
bool hasIcon = ViewModel?.WhileAppCardIcon is not null;
CardWhileAppIcon.Visibility = hasIcon ? Visibility.Visible : Visibility.Collapsed;
CardWhileAppGlyph.Visibility = hasIcon ? Visibility.Collapsed : Visibility.Visible;
}
private ToggleButton? CardForMinutes(uint minutes) => minutes switch
{
30 => Card30,
60 => Card60,
120 => Card120,
_ => null,
};
private void SetSelectedCard(ToggleButton? selected)
{
foreach (ToggleButton card in new[] { Card30, Card60, Card120, CardForever, CardCustom, CardWhileApp })
{
card.IsChecked = ReferenceEquals(card, selected);
}
}
private void OnDurationCardClick(object sender, RoutedEventArgs e)
{
if (sender is ToggleButton button
&& button.Tag is string tag
&& uint.TryParse(tag, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint minutes))
{
ViewModel.PendingSelection = FlyoutSelectionKind.Timed;
ViewModel.PendingMinutes = minutes;
SetSelectedCard(button);
ViewModel.ApplyPendingIfActive();
}
}
private void OnForeverCardClick(object sender, RoutedEventArgs e)
{
ViewModel.PendingSelection = FlyoutSelectionKind.Forever;
SetSelectedCard(CardForever);
ViewModel.ApplyPendingIfActive();
}
// Left segment of the Custom split tile: select the custom duration (using whatever value
// was last configured) without leaving the launch page.
private void OnCustomToggleClick(object sender, RoutedEventArgs e)
{
ViewModel.PendingSelection = FlyoutSelectionKind.Custom;
HighlightSelectedCard();
ViewModel.ApplyPendingIfActive();
}
// Right chevron of the Custom split tile: navigate to the custom-time picker.
private void OnCustomNavClick(object sender, RoutedEventArgs e)
{
// Keep the custom page's tab in sync with the running session (duration vs. until-date).
ViewModel.RefreshPendingCustomSubMode();
if (_context != null && Frame != null)
{
Frame.Navigate(typeof(AwakeCustomTimePage), _context, new SlideNavigationTransitionInfo { Effect = SlideNavigationTransitionEffect.FromRight });
}
}
// Left segment of the While-app split tile: select the existing app binding if one was
// already chosen; otherwise jump straight to the picker since there is nothing to select.
private void OnWhileAppToggleClick(object sender, RoutedEventArgs e)
{
if (ViewModel.PendingProcessId != 0)
{
ViewModel.PendingSelection = FlyoutSelectionKind.WhileApp;
HighlightSelectedCard();
ViewModel.ApplyPendingIfActive();
}
else
{
HighlightSelectedCard();
NavigateToAppPicker();
}
}
// Right chevron of the While-app split tile: navigate to the app picker.
private void OnWhileAppNavClick(object sender, RoutedEventArgs e) => NavigateToAppPicker();
private void NavigateToAppPicker()
{
if (_context != null && Frame != null)
{
Frame.Navigate(typeof(AwakeAppPickerPage), _context, new SlideNavigationTransitionInfo { Effect = SlideNavigationTransitionEffect.FromRight });
}
}
private void OnStartButtonClick(object sender, RoutedEventArgs e)
{
ViewModel.ApplyPendingSelection();
}
private void OnStopButtonClick(object sender, RoutedEventArgs e)
{
ViewModel.Mode = AwakeMode.PASSIVE;
}
private void OnOpenSettingsClick(object sender, RoutedEventArgs e)
{
ViewModel.OpenSettingsCommand.Execute(null);
_context?.RequestClose();
}
private void OnKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Escape)
{
_context?.RequestClose();
e.Handled = true;
}
}
private void OnHeaderGlowLoaded(object sender, RoutedEventArgs e) => BuildGlow();
private void OnHeaderGlowSizeChanged(object sender, SizeChangedEventArgs e) => LayoutGlow();
// Builds a couple of soft accent-tinted radial "blobs" that slowly drift and pulse behind
// the active header, giving a subtle organic glow without a flat colored fill.
private void BuildGlow()
{
if (_glowRoot != null)
{
return;
}
Compositor compositor = ElementCompositionPreview.GetElementVisual(HeaderGlowHost).Compositor;
_glowRoot = compositor.CreateContainerVisual();
ElementCompositionPreview.SetElementChildVisual(HeaderGlowHost, _glowRoot);
PopulateGlowBlobs(compositor);
LayoutGlow();
}
// Clears and re-creates the glow blobs with the current accent color. Called when the
// flyout opens so the glow picks up any accent/theme change that happened while hidden.
public void RefreshGlow()
{
if (_glowRoot == null)
{
return;
}
_glowRoot.Children.RemoveAll();
PopulateGlowBlobs(_glowRoot.Compositor);
LayoutGlow();
}
// Builds a layered, animated Fluent "aurora" behind the active header: drifting/breathing
// accent light clouds, brighter drifting light streaks, twinkling sparkles, and periodic
// bright shine sweeps that glint diagonally across for a lively, premium feel.
private void PopulateGlowBlobs(Compositor compositor)
{
Color accent = (Application.Current.Resources["SystemAccentColor"] is Color a) ? a : Colors.White;
Color accentLight1 = (Application.Current.Resources["SystemAccentColorLight1"] is Color l1) ? l1 : accent;
Color accentLight2 = (Application.Current.Resources["SystemAccentColorLight2"] is Color l2) ? l2 : accent;
Color white = Colors.White;
// Aurora clouds (bottom layer): (color, sizeRel, startRel, endRel, maxOpacity, seconds, delay)
AddAurora(compositor, accentLight1, new Vector2(0.95f, 1.30f), new Vector2(0.15f, 0.30f), new Vector2(0.52f, 0.20f), 0.60f, 9, 0f);
AddAurora(compositor, accentLight2, new Vector2(0.85f, 1.15f), new Vector2(0.82f, 0.42f), new Vector2(0.44f, 0.30f), 0.52f, 11, 2f);
AddAurora(compositor, accent, new Vector2(0.70f, 1.00f), new Vector2(0.52f, 0.16f), new Vector2(0.74f, 0.34f), 0.34f, 13, 4f);
// Soft, blurred drifting light streaks: (color, sizeRel, startRel, endRel, maxOpacity, minOpacity, seconds, delay, angle)
AddStreak(compositor, white, new Vector2(1.60f, 1.05f), new Vector2(0.10f, 0.20f), new Vector2(0.55f, 0.12f), 0.42f, 0.14f, 16, 0f, -18f);
AddStreak(compositor, accentLight2, new Vector2(1.75f, 1.20f), new Vector2(0.60f, 0.46f), new Vector2(0.98f, 0.32f), 0.40f, 0.12f, 20, 2f, -24f);
// Shine sweeps (the "pop"): bright bands that glint across, then pause. (color, maxOpacity, seconds, delay, angle)
AddSweep(compositor, white, 0.85f, 4.2f, 0.4f, -20f);
AddSweep(compositor, accentLight1, 0.60f, 5.3f, 2.1f, -14f);
AddSweep(compositor, white, 0.70f, 6.1f, 3.6f, -26f);
// Sparkles (top layer): (color, posRel, maxOpacity, diameter, delay)
AddSparkle(compositor, white, new Vector2(0.70f, 0.20f), 1.0f, 3.6f, 0f);
AddSparkle(compositor, white, new Vector2(0.86f, 0.38f), 0.85f, 3.0f, 1.1f);
AddSparkle(compositor, white, new Vector2(0.58f, 0.30f), 0.75f, 2.6f, 0.6f);
AddSparkle(compositor, accentLight1, new Vector2(0.78f, 0.52f), 0.70f, 2.4f, 1.7f);
}
// A soft vertical fade (transparent top/bottom, opaque middle) used to give layers soft
// edges and keep the glow concentrated away from the header's bottom text.
private CompositionLinearGradientBrush VerticalFade(Compositor compositor)
{
var fade = compositor.CreateLinearGradientBrush();
fade.StartPoint = new Vector2(0.5f, 0f);
fade.EndPoint = new Vector2(0.5f, 1f);
fade.ColorStops.Add(compositor.CreateColorGradientStop(0f, Color.FromArgb(0, 255, 255, 255)));
fade.ColorStops.Add(compositor.CreateColorGradientStop(0.18f, Colors.White));
fade.ColorStops.Add(compositor.CreateColorGradientStop(0.6f, Colors.White));
fade.ColorStops.Add(compositor.CreateColorGradientStop(1f, Color.FromArgb(0, 255, 255, 255)));
return fade;
}
// A large, soft accent cloud that slowly drifts (LayoutGlow) and gently breathes (scale).
private void AddAurora(Compositor compositor, Color color, Vector2 sizeRel, Vector2 startRel, Vector2 endRel, float maxOpacity, int seconds, float delay)
{
var radial = compositor.CreateRadialGradientBrush();
radial.ColorStops.Add(compositor.CreateColorGradientStop(0f, Color.FromArgb((byte)(maxOpacity * 255), color.R, color.G, color.B)));
radial.ColorStops.Add(compositor.CreateColorGradientStop(0.55f, Color.FromArgb((byte)(maxOpacity * 0.45f * 255), color.R, color.G, color.B)));
radial.ColorStops.Add(compositor.CreateColorGradientStop(1f, Color.FromArgb(0, color.R, color.G, color.B)));
var mask = compositor.CreateMaskBrush();
mask.Source = radial;
mask.Mask = VerticalFade(compositor);
var blob = compositor.CreateSpriteVisual();
blob.Brush = mask;
blob.AnchorPoint = new Vector2(0.5f, 0.5f);
blob.Properties.InsertVector2("Start", startRel);
blob.Properties.InsertVector2("End", endRel);
blob.Properties.InsertVector2("SizeRel", sizeRel);
blob.Properties.InsertScalar("Seconds", seconds);
blob.Properties.InsertScalar("Delay", delay);
_glowRoot!.Children.InsertAtTop(blob);
var breathe = compositor.CreateVector3KeyFrameAnimation();
breathe.InsertKeyFrame(0f, new Vector3(0.9f, 0.9f, 1f));
breathe.InsertKeyFrame(0.5f, new Vector3(1.15f, 1.15f, 1f));
breathe.InsertKeyFrame(1f, new Vector3(0.9f, 0.9f, 1f));
breathe.Duration = TimeSpan.FromSeconds(seconds);
breathe.IterationBehavior = AnimationIterationBehavior.Forever;
breathe.DelayTime = TimeSpan.FromSeconds(delay);
blob.StartAnimation("Scale", breathe);
}
// A bright, narrow diagonal band that periodically sweeps across the header and then pauses,
// producing a Fluent "reveal" shimmer. Offset is driven in LayoutGlow (needs host size).
private void AddSweep(Compositor compositor, Color color, float maxOpacity, float seconds, float delay, float angle)
{
byte core = (byte)(maxOpacity * 255);
byte soft = (byte)(maxOpacity * 0.5f * 255);
var band = compositor.CreateLinearGradientBrush();
band.StartPoint = new Vector2(0f, 0.5f);
band.EndPoint = new Vector2(1f, 0.5f);
band.ColorStops.Add(compositor.CreateColorGradientStop(0f, Color.FromArgb(0, color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.42f, Color.FromArgb(soft, color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.5f, Color.FromArgb(core, 255, 255, 255)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.58f, Color.FromArgb(soft, color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(1f, Color.FromArgb(0, color.R, color.G, color.B)));
var mask = compositor.CreateMaskBrush();
mask.Source = band;
mask.Mask = VerticalFade(compositor);
var sweep = compositor.CreateSpriteVisual();
sweep.Brush = mask;
sweep.AnchorPoint = new Vector2(0.5f, 0.5f);
sweep.RotationAngleInDegrees = angle;
sweep.Opacity = 0f;
sweep.Properties.InsertScalar("Sweep", 1f);
sweep.Properties.InsertScalar("Seconds", seconds);
sweep.Properties.InsertScalar("Delay", delay);
_glowRoot!.Children.InsertAtTop(sweep);
// Flash the band on only while it crosses, then stay dark, with soft eased fade in/out.
var flashEase = compositor.CreateCubicBezierEasingFunction(new Vector2(0.33f, 0f), new Vector2(0.67f, 1f));
var flash = compositor.CreateScalarKeyFrameAnimation();
flash.InsertKeyFrame(0f, 0f);
flash.InsertKeyFrame(0.05f, 0f);
flash.InsertKeyFrame(0.18f, maxOpacity, flashEase);
flash.InsertKeyFrame(0.30f, maxOpacity);
flash.InsertKeyFrame(0.42f, 0f, flashEase);
flash.InsertKeyFrame(1f, 0f);
flash.Duration = TimeSpan.FromSeconds(seconds);
flash.IterationBehavior = AnimationIterationBehavior.Forever;
flash.DelayTime = TimeSpan.FromSeconds(delay);
sweep.StartAnimation("Opacity", flash);
}
// A soft, rotated band of light (transparent -> color -> transparent) that slowly drifts
// and twinkles. Size/position are resolved against the host in LayoutGlow.
private void AddStreak(Compositor compositor, Color color, Vector2 sizeRel, Vector2 startRel, Vector2 endRel, float maxOpacity, float minOpacity, int seconds, float delay, float angle)
{
var band = compositor.CreateLinearGradientBrush();
band.StartPoint = new Vector2(0f, 0.5f);
band.EndPoint = new Vector2(1f, 0.5f);
band.ColorStops.Add(compositor.CreateColorGradientStop(0f, Color.FromArgb(0, color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.25f, Color.FromArgb((byte)(maxOpacity * 0.35f * 255), color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.5f, Color.FromArgb((byte)(maxOpacity * 255), color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(0.75f, Color.FromArgb((byte)(maxOpacity * 0.35f * 255), color.R, color.G, color.B)));
band.ColorStops.Add(compositor.CreateColorGradientStop(1f, Color.FromArgb(0, color.R, color.G, color.B)));
var mask = compositor.CreateMaskBrush();
mask.Source = band;
mask.Mask = VerticalFade(compositor);
var streak = compositor.CreateSpriteVisual();
streak.Brush = mask;
streak.AnchorPoint = new Vector2(0.5f, 0.5f);
streak.RotationAngleInDegrees = angle;
streak.Opacity = minOpacity;
streak.Properties.InsertVector2("Start", startRel);
streak.Properties.InsertVector2("End", endRel);
streak.Properties.InsertVector2("SizeRel", sizeRel);
streak.Properties.InsertScalar("Seconds", seconds);
streak.Properties.InsertScalar("Delay", delay);
_glowRoot!.Children.InsertAtTop(streak);
var twinkleEase = compositor.CreateCubicBezierEasingFunction(new Vector2(0.42f, 0f), new Vector2(0.58f, 1f));
var twinkle = compositor.CreateScalarKeyFrameAnimation();
twinkle.InsertKeyFrame(0f, minOpacity);
twinkle.InsertKeyFrame(0.5f, maxOpacity, twinkleEase);
twinkle.InsertKeyFrame(1f, minOpacity, twinkleEase);
twinkle.Duration = TimeSpan.FromSeconds(seconds * 0.6);
twinkle.IterationBehavior = AnimationIterationBehavior.Forever;
twinkle.DelayTime = TimeSpan.FromSeconds(delay);
streak.StartAnimation("Opacity", twinkle);
}
// A tiny radial highlight that fades and pulses in place to read as a shimmer/sparkle.
private void AddSparkle(Compositor compositor, Color color, Vector2 posRel, float maxOpacity, float diameter, float delay)
{
var radial = compositor.CreateRadialGradientBrush();
radial.ColorStops.Add(compositor.CreateColorGradientStop(0f, Color.FromArgb(255, color.R, color.G, color.B)));
radial.ColorStops.Add(compositor.CreateColorGradientStop(1f, Color.FromArgb(0, color.R, color.G, color.B)));
var sparkle = compositor.CreateSpriteVisual();
sparkle.Brush = radial;
sparkle.Size = new Vector2(diameter, diameter);
sparkle.AnchorPoint = new Vector2(0.5f, 0.5f);
sparkle.CenterPoint = new Vector3(diameter / 2f, diameter / 2f, 0);
sparkle.Opacity = 0f;
sparkle.Properties.InsertVector2("Pos", posRel);
_glowRoot!.Children.InsertAtTop(sparkle);
var twinkle = compositor.CreateScalarKeyFrameAnimation();
twinkle.InsertKeyFrame(0f, 0f);
twinkle.InsertKeyFrame(0.5f, maxOpacity);
twinkle.InsertKeyFrame(1f, 0f);
twinkle.Duration = TimeSpan.FromSeconds(2.6);
twinkle.IterationBehavior = AnimationIterationBehavior.Forever;
twinkle.DelayTime = TimeSpan.FromSeconds(delay);
sparkle.StartAnimation("Opacity", twinkle);
var pulse = compositor.CreateVector3KeyFrameAnimation();
pulse.InsertKeyFrame(0f, new Vector3(0.4f, 0.4f, 1f));
pulse.InsertKeyFrame(0.5f, new Vector3(1f, 1f, 1f));
pulse.InsertKeyFrame(1f, new Vector3(0.4f, 0.4f, 1f));
pulse.Duration = TimeSpan.FromSeconds(2.6);
pulse.IterationBehavior = AnimationIterationBehavior.Forever;
pulse.DelayTime = TimeSpan.FromSeconds(delay);
sparkle.StartAnimation("Scale", pulse);
}
private void LayoutGlow()
{
if (_glowRoot == null)
{
return;
}
var size = new Vector2((float)HeaderGlowHost.ActualWidth, (float)HeaderGlowHost.ActualHeight);
if (size.X <= 0 || size.Y <= 0)
{
return;
}
Compositor compositor = _glowRoot.Compositor;
_glowRoot.Size = size;
_glowRoot.Clip = compositor.CreateInsetClip();
foreach (var child in _glowRoot.Children)
{
if (child is not SpriteVisual visual)
{
continue;
}
// Sparkles: fixed-size dots anchored at a relative point.
if (visual.Properties.TryGetVector2("Pos", out Vector2 pos) == CompositionGetValueStatus.Succeeded)
{
visual.Offset = new Vector3(pos.X * size.X, pos.Y * size.Y, 0);
continue;
}
// Shine sweep: narrow band that races across in the first third of the cycle, then holds.
if (visual.Properties.TryGetScalar("Sweep", out float sweepFlag) == CompositionGetValueStatus.Succeeded && sweepFlag > 0f)
{
visual.Size = new Vector2(size.X * 0.5f, size.Y * 1.9f);
visual.CenterPoint = new Vector3(visual.Size.X / 2f, visual.Size.Y / 2f, 0);
visual.Properties.TryGetScalar("Seconds", out float sweepSecs);
visual.Properties.TryGetScalar("Delay", out float sweepDelay);
float midY = size.Y * 0.5f;
var ease = compositor.CreateCubicBezierEasingFunction(new Vector2(0.45f, 0f), new Vector2(0.35f, 1f));
var run = compositor.CreateVector3KeyFrameAnimation();
run.InsertKeyFrame(0f, new Vector3(-0.45f * size.X, midY, 0));
run.InsertKeyFrame(0.42f, new Vector3(1.45f * size.X, midY, 0), ease);
run.InsertKeyFrame(1f, new Vector3(1.45f * size.X, midY, 0));
run.Duration = TimeSpan.FromSeconds(sweepSecs > 0 ? sweepSecs : 5);
run.DelayTime = TimeSpan.FromSeconds(sweepDelay);
run.IterationBehavior = AnimationIterationBehavior.Forever;
visual.Offset = new Vector3(-0.45f * size.X, midY, 0);
visual.StartAnimation("Offset", run);
continue;
}
// Aurora clouds & streaks: size relative to the host, rotate about their center,
// and gently drift back and forth between two points.
if (visual.Properties.TryGetVector2("SizeRel", out Vector2 sizeRel) == CompositionGetValueStatus.Succeeded
&& visual.Properties.TryGetVector2("Start", out Vector2 start) == CompositionGetValueStatus.Succeeded
&& visual.Properties.TryGetVector2("End", out Vector2 end) == CompositionGetValueStatus.Succeeded)
{
visual.Size = new Vector2(sizeRel.X * size.X, sizeRel.Y * size.Y);
visual.CenterPoint = new Vector3(visual.Size.X / 2f, visual.Size.Y / 2f, 0);
visual.Properties.TryGetScalar("Seconds", out float secs);
visual.Properties.TryGetScalar("Delay", out float delay);
var driftEase = compositor.CreateCubicBezierEasingFunction(new Vector2(0.42f, 0f), new Vector2(0.58f, 1f));
var drift = compositor.CreateVector3KeyFrameAnimation();
drift.InsertKeyFrame(0f, new Vector3(start.X * size.X, start.Y * size.Y, 0));
drift.InsertKeyFrame(1f, new Vector3(end.X * size.X, end.Y * size.Y, 0), driftEase);
drift.Duration = TimeSpan.FromSeconds(secs > 0 ? secs : 20);
drift.DelayTime = TimeSpan.FromSeconds(delay);
drift.IterationBehavior = AnimationIterationBehavior.Forever;
drift.Direction = Microsoft.UI.Composition.AnimationDirection.Alternate;
visual.Offset = new Vector3(start.X * size.X, start.Y * size.Y, 0);
visual.StartAnimation("Offset", drift);
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Awake.AwakeShellPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Loaded="OnPageLoaded">
<Grid>
<Frame x:Name="ContentFrame" />
</Grid>
</Page>

View File

@@ -0,0 +1,102 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using Awake.ViewModels;
using ManagedCommon;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;
using Microsoft.UI.Xaml.Navigation;
namespace Awake
{
/// <summary>
/// Hosts the flyout's navigation frame. The launch page is the root; the custom-time and
/// app-picker pages slide in over it with a back button (same pattern as the QuickAccess
/// flyout shell).
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public sealed partial class AwakeShellPage : Page
{
private AwakeFlyoutNavigationContext? _context;
public AwakeShellPage()
{
InitializeComponent();
ContentFrame.NavigationFailed += OnNavigationFailed;
}
/// <summary>
/// Raised when a hosted page asks the window to dismiss the flyout (Escape on the launch
/// page, or opening Settings).
/// </summary>
public event EventHandler? CloseRequested;
public void Initialize(AwakeFlyoutViewModel viewModel)
{
_context = new AwakeFlyoutNavigationContext(
viewModel,
() => CloseRequested?.Invoke(this, EventArgs.Empty));
}
/// <summary>
/// Resets the frame to the launch page (clearing any sub-page and the back stack) so each
/// time the flyout is summoned it opens on the main view. A fresh navigation also rebuilds
/// the header glow with the current accent color.
/// </summary>
public void NavigateToLaunch()
{
if (_context == null)
{
return;
}
ContentFrame.Navigate(typeof(AwakeLaunchPage), _context, new SuppressNavigationTransitionInfo());
ContentFrame.BackStack.Clear();
}
public void FocusContent()
{
if (ContentFrame.Content is AwakeLaunchPage launchPage)
{
launchPage.FocusContent();
}
else
{
(ContentFrame.Content as Control)?.Focus(FocusState.Programmatic);
}
}
/// <summary>
/// Forwards a glow rebuild to the launch page when it is the active content.
/// </summary>
public void RefreshGlow()
{
if (ContentFrame.Content is AwakeLaunchPage launchPage)
{
launchPage.RefreshGlow();
}
}
private void OnPageLoaded(object sender, RoutedEventArgs e)
{
if (_context == null || ContentFrame.Content is AwakeLaunchPage)
{
return;
}
ContentFrame.Navigate(typeof(AwakeLaunchPage), _context, new SuppressNavigationTransitionInfo());
}
private static void OnNavigationFailed(object sender, NavigationFailedEventArgs e)
{
// A page constructor or XAML load failure here would otherwise crash the flyout.
// Log and mark handled so the flyout stays available; the next summon retries.
Logger.LogError($"Awake: navigation to '{e.SourcePageType?.FullName}' failed.", e.Exception);
e.Handled = true;
}
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="Awake.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
xmlns:local="using:Awake"
xmlns:winuiex="using:WinUIEx"
Width="364"
Height="486"
MinWidth="364"
MinHeight="486"
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
IsTitleBarVisible="False">
<winuiex:WindowEx.SystemBackdrop>
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="Default" />
</winuiex:WindowEx.SystemBackdrop>
<Grid x:Name="RootHost">
<local:AwakeShellPage x:Name="ShellHost" />
</Grid>
</winuiex:WindowEx>

View File

@@ -0,0 +1,269 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Awake.Properties;
using Awake.ViewModels;
using ManagedCommon;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinUIEx;
namespace Awake
{
/// <summary>
/// The Awake tray flyout window. Hidden at startup; shown when the user clicks the tray icon.
/// Auto-hides when it loses activation (same behavior as the PowerDisplay flyout).
/// The flyout body lives in <see cref="AwakeShellPage"/> (a navigation frame whose root
/// is <see cref="AwakeLaunchPage"/>); this window owns the
/// window lifecycle, positioning, and the countdown timer.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public sealed partial class MainWindow : WindowEx, IDisposable
{
// Flyout size is declared in XAML (Width/Height). We capture those values at
// construction time so later DPI transitions don't perturb WindowEx.Width/Height
// and our positioning math stays stable across multiple Show/Hide cycles
// (same pattern as QuickAccess.UI MainWindow).
private const int FlyoutRightMarginDip = 12;
private const int FlyoutBottomMarginDip = 12;
// Delay the working-set trim after hide so quick toggles don't trigger aggressive
// GC; cancel it on re-show. The trim only releases idle UI/heap pages back to the OS —
// it has no effect on the keep-awake state (driven by SetThreadExecutionState in Manager).
private const int MemoryTrimDelayMs = 2000;
private readonly AwakeFlyoutViewModel _viewModel;
private readonly int _designWidthDip;
private readonly int _designHeightDip;
private readonly DispatcherTimer _countdownTimer;
private CancellationTokenSource? _trimCts;
private bool _isShowingWindow;
private bool _disposed;
public AwakeFlyoutViewModel ViewModel => _viewModel;
public MainWindow(bool startedFromPowerToys)
{
try
{
_viewModel = new AwakeFlyoutViewModel(SettingsUtils.Default, startedFromPowerToys);
this.InitializeComponent();
// Snapshot the XAML-declared design size BEFORE anything else touches
// the window — see comment above on _designWidthDip.
_designWidthDip = (int)Math.Ceiling(this.Width);
_designHeightDip = (int)Math.Ceiling(this.Height);
ShellHost.Initialize(_viewModel);
ShellHost.CloseRequested += OnFlyoutCloseRequested;
// The window title isn't a XAML element, so it can't use x:Uid; set it here.
// All other UI strings are localized via x:Uid against Strings\<lang>\Resources.resw.
this.AppWindow.Title = Resources.AWAKE_FLYOUT_TITLE;
ConfigureWindow();
RegisterEventHandlers();
_countdownTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1),
};
_countdownTimer.Tick += OnCountdownTick;
this.SetIsShownInSwitchers(false);
// Window starts hidden at launch; trim the initial working set so the idle
// background footprint drops without waiting for a first show/hide cycle.
ScheduleMemoryTrim();
}
catch (Exception ex)
{
Logger.LogError($"MainWindow constructor failed: {ex}");
throw;
}
}
private void OnCountdownTick(object? sender, object e)
{
_viewModel.UpdateCountdown();
}
private void ConfigureWindow()
{
try
{
PositionFlyout();
var titleBar = this.AppWindow.TitleBar;
if (titleBar != null)
{
titleBar.ExtendsContentIntoTitleBar = true;
titleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
titleBar.SetDragRectangles(Array.Empty<Windows.Graphics.RectInt32>());
}
}
catch (Exception ex)
{
Logger.LogWarning($"ConfigureWindow: {ex.Message}");
}
}
private void RegisterEventHandlers()
{
this.Closed += OnWindowClosed;
this.Activated += OnWindowActivated;
}
private void OnFlyoutCloseRequested(object? sender, EventArgs e)
{
HideWindow();
}
private void OnWindowActivated(object sender, WindowActivatedEventArgs args)
{
if (args.WindowActivationState == WindowActivationState.Deactivated && !_isShowingWindow)
{
HideWindow();
}
}
private void OnWindowClosed(object sender, WindowEventArgs args)
{
args.Handled = true;
HideWindow();
}
public void ShowWindow()
{
_isShowingWindow = true;
try
{
CancelMemoryTrim();
_viewModel.Refresh();
ShellHost.NavigateToLaunch();
ShellHost.RefreshGlow();
PositionFlyout();
this.Activate();
this.Show();
this.IsAlwaysOnTop = true;
this.BringToFront();
ShellHost.FocusContent();
_countdownTimer.Start();
}
catch (Exception ex)
{
Logger.LogError($"ShowWindow failed: {ex}");
}
finally
{
_isShowingWindow = false;
}
}
public void HideWindow()
{
try
{
_countdownTimer.Stop();
this.Hide();
ScheduleMemoryTrim();
}
catch (Exception ex)
{
Logger.LogError($"HideWindow failed: {ex}");
}
}
// Releases idle pages back to the OS ~2s after the flyout is hidden so the background
// working set drops. Cancelled on re-show to avoid GC churn during quick toggles.
private void ScheduleMemoryTrim()
{
CancelMemoryTrim();
_trimCts = new CancellationTokenSource();
var token = _trimCts.Token;
Task.Delay(MemoryTrimDelayMs, token).ContinueWith(
_ =>
{
if (token.IsCancellationRequested)
{
return;
}
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
SetProcessWorkingSetSize(System.Diagnostics.Process.GetCurrentProcess().Handle, -1, -1);
},
token,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default);
}
private void CancelMemoryTrim()
{
_trimCts?.Cancel();
_trimCts?.Dispose();
_trimCts = null;
}
public void ToggleWindow()
{
if (this.Visible)
{
HideWindow();
}
else
{
ShowWindow();
}
}
private void PositionFlyout()
{
try
{
// Use the cached XAML design size — this.Width/Height are runtime values
// that can drift across DPI transitions; reusing them in PositionWindowBottomRight
// would slowly walk the flyout off-screen over multiple Show/Hide cycles.
FlyoutWindowHelper.PositionWindowBottomRight(
this,
_designWidthDip,
_designHeightDip,
FlyoutRightMarginDip,
FlyoutBottomMarginDip);
}
catch
{
// Non-critical: window positioning failures fall back to OS-default placement.
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
CancelMemoryTrim();
_countdownTimer.Stop();
_countdownTimer.Tick -= OnCountdownTick;
ShellHost.CloseRequested -= OnFlyoutCloseRequested;
_viewModel.Dispose();
}
[DllImport("kernel32.dll")]
private static extern bool SetProcessWorkingSetSize(IntPtr hProcess, int dwMinimumWorkingSetSize, int dwMaximumWorkingSetSize);
}
}

View File

@@ -39,15 +39,42 @@ namespace Awake.Core
internal static AwakeMode CurrentOperatingMode { get; private set; }
private static bool IsDisplayOn { get; set; }
internal static bool IsDisplayOn { get; private set; }
private static uint TimeRemaining { get; set; }
internal static uint TimeRemaining { get; private set; }
private static string ScreenStateString => IsDisplayOn ? Resources.AWAKE_SCREEN_ON : Resources.AWAKE_SCREEN_OFF;
private static int ProcessId { get; set; }
internal static int ProcessId { get; private set; }
private static DateTimeOffset ExpireAt { get; set; }
/// <summary>
/// Gets a value indicating whether the current keep-awake session is bound to a process
/// the user picked from the flyout (the "While app runs" mode). When true the session ends
/// automatically once <see cref="ProcessId"/> exits, reverting Awake to passive.
/// </summary>
internal static bool IsProcessBound { get; private set; }
/// <summary>
/// Gets the friendly name of the process the current session is bound to (for display in
/// the flyout/tray). Empty when not process-bound.
/// </summary>
internal static string BoundProcessName { get; private set; } = string.Empty;
internal static DateTimeOffset ExpireAt { get; private set; }
/// <summary>
/// Gets the timestamp at which the current timed/expirable keep-awake session began.
/// Together with <see cref="ExpireAt"/> this lets the flyout render a determinate
/// countdown progress bar (elapsed = now - start, total = ExpireAt - start).
/// </summary>
internal static DateTimeOffset ModeStartedAt { get; private set; }
/// <summary>
/// Raised whenever the operating mode, screen state, or expiration target changes
/// so the WinUI flyout can refresh its state. Always raised on the thread that
/// initiated the change; subscribers must marshal to the UI dispatcher themselves.
/// </summary>
internal static event EventHandler? ModeChanged;
private static readonly CompositeFormat AwakeMinute = CompositeFormat.Parse(Resources.AWAKE_MINUTE);
private static readonly CompositeFormat AwakeMinutes = CompositeFormat.Parse(Resources.AWAKE_MINUTES);
@@ -170,6 +197,13 @@ namespace Awake.Core
_timerSubscription?.Dispose();
_timerSubscription = null;
// Clear any process binding. Callers that establish a new binding (the CLI --pid path
// and SetProcessBoundKeepAwake) re-set ProcessId afterwards, so this only clears stale
// bindings when switching to a non-process mode.
ProcessId = 0;
IsProcessBound = false;
BoundProcessName = string.Empty;
Logger.LogInfo("Timer subscription disposed.");
}
@@ -183,32 +217,42 @@ namespace Awake.Core
case AwakeMode.INDEFINITE:
string pidLine = ProcessId == 0
? string.Empty
: $"\nPID: {ProcessId}";
: (IsProcessBound && BoundProcessName.Length > 0
? $"\n{BoundProcessName} (PID: {ProcessId})"
: $"\nPID: {ProcessId}");
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_INDEFINITE}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}{pidLine}";
icon = TrayHelper.IndefiniteIcon;
icon = TrayIconService.IndefiniteIcon;
break;
case AwakeMode.PASSIVE:
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_SCREEN_OFF}";
icon = TrayHelper.DisabledIcon;
icon = TrayIconService.DisabledIcon;
break;
case AwakeMode.EXPIRABLE:
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_UNTIL} {ExpireAt:MMM d, h:mm tt}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}";
icon = TrayHelper.ExpirableIcon;
icon = TrayIconService.ExpirableIcon;
break;
case AwakeMode.TIMED:
iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_TIMED}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}";
icon = TrayHelper.TimedIcon;
icon = TrayIconService.TimedIcon;
break;
}
TrayHelper.SetShellIcon(
TrayHelper.WindowHandle,
iconText,
icon,
forceAdd ? TrayIconAction.Add : TrayIconAction.Update);
if (icon is not null)
{
AwakeApp.Current?.UpdateTrayIcon(icon, iconText);
}
try
{
ModeChanged?.Invoke(null, EventArgs.Empty);
}
catch (Exception ex)
{
Logger.LogError($"Awake ModeChanged subscriber threw: {ex.Message}");
}
}
internal static void SetIndefiniteKeepAwake(bool keepDisplayOn = false, int processId = 0, [CallerMemberName] string callerName = "")
@@ -254,6 +298,46 @@ namespace Awake.Core
SetModeShellIcon();
}
/// <summary>
/// Keeps the system awake indefinitely while the process identified by <paramref name="processId"/>
/// is running, automatically reverting to passive once it exits. This is the in-flyout
/// counterpart of the CLI <c>--pid</c> path: it reuses the same indefinite keep-awake plus
/// <see cref="RunnerHelper.WaitForPowerToysRunner"/> process-watch primitives, but (1) the exit
/// callback reverts to passive instead of terminating Awake (the process must stay alive to host
/// the tray icon and flyout), and (2) it does not persist the mode to settings because a PID is
/// not stable across restarts.
/// </summary>
internal static void SetProcessBoundKeepAwake(int processId, string processName, bool keepDisplayOn = false, [CallerMemberName] string callerName = "")
{
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AwakeIndefinitelyKeepAwakeEvent());
Logger.LogInfo($"Process-bound keep-awake starting for PID {processId} ({processName}), invoked by {callerName}...");
CancelExistingThread();
_stateQueue.Add(ComputeAwakeState(keepDisplayOn));
IsDisplayOn = keepDisplayOn;
CurrentOperatingMode = AwakeMode.INDEFINITE;
ProcessId = processId;
IsProcessBound = true;
BoundProcessName = processName ?? string.Empty;
SetModeShellIcon();
// Watch the bound process; when it exits, revert to passive on this (long-lived) process.
// The callback runs on a background thread (same as the timed/expirable completion paths),
// and guards against stale watchers by checking we are still bound to this exact PID.
RunnerHelper.WaitForPowerToysRunner(processId, () =>
{
if (CurrentOperatingMode == AwakeMode.INDEFINITE && IsProcessBound && ProcessId == processId)
{
Logger.LogInfo($"Bound process {processId} exited; reverting Awake to passive.");
SetPassiveKeepAwake(updateSettings: false);
}
});
}
internal static void SetExpirableKeepAwake(DateTimeOffset expireAt, bool keepDisplayOn = true, [CallerMemberName] string callerName = "")
{
Logger.LogInfo($"Expirable keep-awake invoked by {callerName}. Expected expiration date/time: {expireAt} with display on setting set to {keepDisplayOn}.");
@@ -303,13 +387,38 @@ namespace Awake.Core
IsDisplayOn = keepDisplayOn;
CurrentOperatingMode = AwakeMode.EXPIRABLE;
ExpireAt = expireAt;
ModeStartedAt = DateTimeOffset.Now;
SetModeShellIcon();
TimeSpan remainingTime = expireAt - DateTimeOffset.Now;
// Use a 1s interval that completes once the expiry time passes, rather than a single
// Observable.Timer(remainingTime): a one-shot timer overflows for spans beyond ~49.7
// days (System.Threading.Timer dueTime is capped at uint.MaxValue ms). This also keeps
// the tray tooltip and flyout countdown ticking down.
var targetExpiryTime = expireAt;
_timerSubscription = Observable.Timer(remainingTime).Subscribe(
_ => HandleTimerCompletion("expirable"));
_timerSubscription = Observable.Interval(TimeSpan.FromSeconds(1))
.Select(_ => targetExpiryTime - DateTimeOffset.Now)
.TakeWhile(remaining => remaining.TotalSeconds > 0)
.Subscribe(
remainingTimeSpan =>
{
TimeRemaining = (uint)remainingTimeSpan.TotalSeconds;
AwakeApp.Current?.UpdateTrayIcon(
TrayIconService.TimedIcon,
$"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}");
try
{
ModeChanged?.Invoke(null, EventArgs.Empty);
}
catch (Exception ex)
{
Logger.LogError($"Awake ModeChanged subscriber threw: {ex.Message}");
}
},
() => HandleTimerCompletion("expirable"));
}
internal static void SetTimedKeepAwake(uint seconds, bool keepDisplayOn = true, [CallerMemberName] string callerName = "")
@@ -365,6 +474,10 @@ namespace Awake.Core
var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds);
// Expose the session bounds so the flyout can render a determinate countdown.
ModeStartedAt = DateTimeOffset.Now;
ExpireAt = targetExpiryTime;
_timerSubscription = Observable.Interval(TimeSpan.FromSeconds(1))
.Select(_ => targetExpiryTime - DateTimeOffset.Now)
.TakeWhile(remaining => remaining.TotalSeconds > 0)
@@ -373,11 +486,18 @@ namespace Awake.Core
{
TimeRemaining = (uint)remainingTimeSpan.TotalSeconds;
TrayHelper.SetShellIcon(
TrayHelper.WindowHandle,
$"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}",
TrayHelper.TimedIcon,
TrayIconAction.Update);
AwakeApp.Current?.UpdateTrayIcon(
TrayIconService.TimedIcon,
$"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}");
try
{
ModeChanged?.Invoke(null, EventArgs.Empty);
}
catch (Exception ex)
{
Logger.LogError($"Awake ModeChanged subscriber threw: {ex.Message}");
}
},
() => HandleTimerCompletion("timed"));
}
@@ -419,21 +539,17 @@ namespace Awake.Core
_timerSubscription?.Dispose();
_timerSubscription = null;
// Dispose tray icons
TrayHelper.DisposeIcons();
if (TrayHelper.WindowHandle != IntPtr.Zero)
// Shut down the WinUI app: this removes the tray icon and closes the hidden flyout
// window so the message pump can exit cleanly.
try
{
// Delete the icon.
TrayHelper.SetShellIcon(TrayHelper.WindowHandle, string.Empty, null, TrayIconAction.Delete);
// Close the message window that we used for the tray.
Bridge.SendMessage(TrayHelper.WindowHandle, Native.Constants.WM_CLOSE, 0, 0);
Bridge.DestroyWindow(TrayHelper.WindowHandle);
AwakeApp.Current?.Shutdown();
}
catch (Exception ex)
{
Logger.LogError($"Failed to shut down AwakeApp cleanly: {ex.Message}");
}
Bridge.PostQuitMessage(exitCode);
Environment.Exit(exitCode);
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Awake.Core.Models
{
internal struct Msg
{
public IntPtr HWnd;
public uint Message;
public IntPtr WParam;
public IntPtr LParam;
public uint Time;
public Point Pt;
}
}

View File

@@ -1,21 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
namespace Awake.Core.Models
{
[StructLayout(LayoutKind.Sequential)]
public struct MenuInfo
{
public uint CbSize; // Size of the structure, in bytes
public uint FMask; // Specifies which members of the structure are valid
public uint DwStyle; // Style of the menu
public uint CyMax; // Maximum height of the menu, in pixels
public IntPtr HbrBack; // Handle to the brush used for the menu's background
public uint DwContextHelpID; // Context help ID
public IntPtr DwMenuData; // Pointer to the menu's user data
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
namespace Awake.Core.Models
{
[StructLayout(LayoutKind.Sequential)]
public struct NotifyIconData
{
public int CbSize;
public IntPtr HWnd;
public int UId;
public int UFlags;
public int UCallbackMessage;
public IntPtr HIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string SzTip;
}
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
namespace Awake.Core.Models
{
[StructLayout(LayoutKind.Sequential)]
public struct Point
{
public int X;
public int Y;
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Media;
namespace Awake.Core.Models
{
/// <summary>
/// A running application with a visible window, surfaced in the flyout's "While app runs"
/// picker. <see cref="IconBytes"/> is captured off the UI thread during enumeration; the
/// XAML <see cref="Icon"/> is built from it on the UI thread before binding.
/// </summary>
public sealed class RunningAppInfo
{
public int ProcessId { get; init; }
public string DisplayName { get; init; } = string.Empty;
public string WindowTitle { get; init; } = string.Empty;
public byte[]? IconBytes { get; init; }
public ImageSource? Icon { get; set; }
}
}

View File

@@ -1,16 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Awake.Core.Models
{
internal enum TrayCommands : uint
{
TC_DISPLAY_SETTING = Native.Constants.WM_USER + 0x2,
TC_MODE_PASSIVE = Native.Constants.WM_USER + 0x3,
TC_MODE_INDEFINITE = Native.Constants.WM_USER + 0x4,
TC_MODE_EXPIRABLE = Native.Constants.WM_USER + 0x5,
TC_EXIT = Native.Constants.WM_USER + 0x64,
TC_TIME = Native.Constants.WM_USER + 0x65,
}
}

View File

@@ -1,13 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Awake.Core.Models
{
internal enum TrayIconAction
{
Add,
Update,
Delete,
}
}

View File

@@ -1,26 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
namespace Awake.Core.Models
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WndClassEx
{
public uint CbSize;
public uint Style;
public IntPtr LpfnWndProc;
public int CbClsExtra;
public int CbWndExtra;
public IntPtr HInstance;
public IntPtr HIcon;
public IntPtr HCursor;
public IntPtr HbrBackground;
public string LpszMenuName;
public string LpszClassName;
public IntPtr HIconSm;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -10,11 +10,14 @@ using Awake.Core.Models;
namespace Awake.Core.Native
{
/// <summary>
/// P/Invokes used by the headless Awake core (console attach, power capability query,
/// thread execution state, parent-PID lookup). Tray-icon and HMENU P/Invokes were moved
/// to <see cref="TrayIconService"/> (CsWin32-generated) when the WinUI flyout replaced
/// the legacy popup menu.
/// </summary>
internal sealed class Bridge
{
[UnmanagedFunctionPointer(CallingConvention.Winapi, SetLastError = true)]
internal delegate int WndProcDelegate(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("Powrprof.dll", SetLastError = true)]
internal static extern bool GetPwrCapabilities(out SystemPowerCapabilities lpSystemPowerCapabilities);
@@ -48,70 +51,7 @@ namespace Awake.Core.Native
[MarshalAs(UnmanagedType.U4)] FileAttributes flagsAndAttributes,
IntPtr templateFile);
[DllImport("user32.dll", SetLastError = true)]
internal static extern IntPtr CreatePopupMenu();
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern bool InsertMenu(IntPtr hMenu, uint uPosition, uint uFlags, uint uIDNewItem, string lpNewItem);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y, IntPtr hWnd, IntPtr lptpm);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern IntPtr SendMessage(IntPtr hWnd, uint msg, nuint wParam, nint lParam);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool DestroyMenu(IntPtr hMenu);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
internal static extern void PostQuitMessage(int nExitCode);
[DllImport("shell32.dll", SetLastError = true)]
internal static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData pnid);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll", SetLastError = true)]
internal static extern IntPtr DispatchMessage(ref Msg lpMsg);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern IntPtr RegisterClassEx(ref WndClassEx lpwcx);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern IntPtr CreateWindowEx(uint dwExStyle, string lpClassName, string lpWindowName, uint dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll", SetLastError = true)]
internal static extern int DefWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetCursorPos(out Point lpPoint);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool UpdateWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetMenuInfo(IntPtr hMenu, ref MenuInfo lpcmi);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("ntdll.dll")]
internal static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int RegisterWindowMessage(string lpString);
}
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Awake.Core.Models;
using ManagedCommon;
using Microsoft.UI.Xaml.Media.Imaging;
namespace Awake.Core
{
/// <summary>
/// Enumerates running applications for the flyout's "While app runs" picker. The default list
/// (<see cref="GetRunningAppsAsync"/>) is limited to apps with a visible main window; searching
/// widens the net to all processes (<see cref="GetAllProcessesAsync"/>). Enumeration and icon
/// extraction are intended to run off the UI thread; the per-item XAML icon is built from the
/// captured PNG bytes on the UI thread via <see cref="BuildIconAsync"/>.
/// </summary>
internal static class RunningAppsProvider
{
internal static Task<List<RunningAppInfo>> GetRunningAppsAsync() => Task.Run(() => GetRunningApps(windowedOnly: true));
internal static Task<List<RunningAppInfo>> GetAllProcessesAsync() => Task.Run(() => GetRunningApps(windowedOnly: false));
private static List<RunningAppInfo> GetRunningApps(bool windowedOnly)
{
var apps = new List<RunningAppInfo>();
var seen = new HashSet<int>();
var iconCache = new Dictionary<string, byte[]?>(StringComparer.OrdinalIgnoreCase);
int ownPid = Environment.ProcessId;
foreach (Process process in Process.GetProcesses())
{
try
{
string title = process.MainWindowTitle;
bool hasWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(title);
// The default list only surfaces user-facing apps (a real main window with a
// non-empty title); search widens to every accessible process.
if (windowedOnly && !hasWindow)
{
continue;
}
if (process.Id == ownPid || !seen.Add(process.Id))
{
continue;
}
string executablePath = TryGetExecutablePath(process);
apps.Add(new RunningAppInfo
{
ProcessId = process.Id,
DisplayName = GetFriendlyName(process, executablePath),
WindowTitle = hasWindow ? title : string.Empty,
IconBytes = GetIconPngCached(executablePath, iconCache),
});
}
catch (Exception ex)
{
// Inaccessible/elevated/system processes throw on property access; skip them.
Logger.LogInfo($"Skipping process during enumeration: {ex.Message}");
}
finally
{
process.Dispose();
}
}
return apps
.OrderBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static string TryGetExecutablePath(Process process)
{
try
{
return process.MainModule?.FileName ?? string.Empty;
}
catch
{
// MainModule throws for processes we cannot fully open; no path available.
return string.Empty;
}
}
private static string GetFriendlyName(Process process, string executablePath)
{
if (!string.IsNullOrEmpty(executablePath))
{
try
{
string description = FileVersionInfo.GetVersionInfo(executablePath).FileDescription ?? string.Empty;
if (!string.IsNullOrWhiteSpace(description))
{
return description;
}
}
catch
{
// Fall through to the process name.
}
}
return process.ProcessName;
}
private static byte[]? GetIconPngCached(string executablePath, Dictionary<string, byte[]?> cache)
{
if (string.IsNullOrEmpty(executablePath))
{
return null;
}
if (cache.TryGetValue(executablePath, out byte[]? cached))
{
return cached;
}
byte[]? png = TryGetIconPng(executablePath);
cache[executablePath] = png;
return png;
}
private static byte[]? TryGetIconPng(string executablePath)
{
if (string.IsNullOrEmpty(executablePath))
{
return null;
}
try
{
using Icon? icon = Icon.ExtractAssociatedIcon(executablePath);
if (icon == null)
{
return null;
}
using Bitmap bitmap = icon.ToBitmap();
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
catch
{
return null;
}
}
/// <summary>
/// Builds a XAML <see cref="BitmapImage"/> from captured PNG bytes. Must be called on the
/// UI thread (creates a XAML object).
/// </summary>
internal static async Task<BitmapImage?> BuildIconAsync(byte[]? iconBytes)
{
if (iconBytes == null || iconBytes.Length == 0)
{
return null;
}
try
{
var image = new BitmapImage();
using var stream = new MemoryStream(iconBytes);
await image.SetSourceAsync(stream.AsRandomAccessStream());
return image;
}
catch (Exception ex)
{
Logger.LogInfo($"Failed to build app icon: {ex.Message}");
return null;
}
}
}
}

View File

@@ -1,522 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Awake.Core.Models;
using Awake.Core.Native;
using Awake.Core.Threading;
using Awake.Properties;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
namespace Awake.Core
{
/// <summary>
/// Helper class used to manage the system tray.
/// </summary>
/// <remarks>
/// Because Awake is a console application, there is no built-in
/// way to embed UI components so we have to heavily rely on the native Windows API.
/// </remarks>
internal static class TrayHelper
{
private static NotifyIconData _notifyIconData;
private static SingleThreadSynchronizationContext? _syncContext;
private static Thread? _mainThread;
private static uint _taskbarCreatedMessage;
private static IntPtr TrayMenu { get; set; }
internal static IntPtr WindowHandle { get; private set; }
internal static readonly Icon DefaultAwakeIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/awake.ico"));
internal static readonly Icon TimedIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/timed.ico"));
internal static readonly Icon ExpirableIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/expirable.ico"));
internal static readonly Icon IndefiniteIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/indefinite.ico"));
internal static readonly Icon DisabledIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/disabled.ico"));
private const int TrayIconId = 1000;
static TrayHelper()
{
TrayMenu = IntPtr.Zero;
WindowHandle = IntPtr.Zero;
}
/// <summary>
/// Disposes of all icon resources to prevent GDI handle leaks.
/// </summary>
internal static void DisposeIcons()
{
DefaultAwakeIcon?.Dispose();
TimedIcon?.Dispose();
ExpirableIcon?.Dispose();
IndefiniteIcon?.Dispose();
DisabledIcon?.Dispose();
}
private static void ShowContextMenu(IntPtr hWnd)
{
if (TrayMenu == IntPtr.Zero)
{
Logger.LogError("Tried to create a context menu while the TrayMenu object is a null pointer. Normal when used in standalone mode.");
return;
}
Bridge.SetForegroundWindow(hWnd);
// Get cursor position in screen coordinates
Bridge.GetCursorPos(out Models.Point cursorPos);
// Set menu information
MenuInfo menuInfo = new()
{
CbSize = (uint)Marshal.SizeOf<MenuInfo>(),
FMask = Native.Constants.MIM_STYLE,
DwStyle = Native.Constants.MNS_AUTO_DISMISS,
};
Bridge.SetMenuInfo(TrayMenu, ref menuInfo);
// Display the context menu at the cursor position
Bridge.TrackPopupMenuEx(
TrayMenu,
Native.Constants.TPM_LEFT_ALIGN | Native.Constants.TPM_BOTTOMALIGN | Native.Constants.TPM_LEFT_BUTTON,
cursorPos.X,
cursorPos.Y,
hWnd,
IntPtr.Zero);
}
public static Task InitializeTray(Icon icon, string text)
{
TaskCompletionSource<bool> trayInitialized = new();
IntPtr hWnd = IntPtr.Zero;
// Start the message loop asynchronously
_mainThread = new Thread(() =>
{
_syncContext = new SingleThreadSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(_syncContext);
RunOnMainThread(() =>
{
try
{
WndClassEx wcex = new()
{
CbSize = (uint)Marshal.SizeOf<WndClassEx>(),
Style = 0,
LpfnWndProc = Marshal.GetFunctionPointerForDelegate<Bridge.WndProcDelegate>(WndProc),
CbClsExtra = 0,
CbWndExtra = 0,
HInstance = Marshal.GetHINSTANCE(typeof(Program).Module),
HIcon = IntPtr.Zero,
HCursor = IntPtr.Zero,
HbrBackground = IntPtr.Zero,
LpszMenuName = string.Empty,
LpszClassName = Constants.TrayWindowId,
HIconSm = IntPtr.Zero,
};
Bridge.RegisterClassEx(ref wcex);
hWnd = Bridge.CreateWindowEx(
0,
Constants.TrayWindowId,
text,
0x00CF0000 | 0x00000001 | 0x00000008, // WS_OVERLAPPEDWINDOW | WS_VISIBLE | WS_MINIMIZEBOX
0,
0,
0,
0,
IntPtr.Zero,
IntPtr.Zero,
Marshal.GetHINSTANCE(typeof(Program).Module),
IntPtr.Zero);
if (hWnd == IntPtr.Zero)
{
int errorCode = Marshal.GetLastWin32Error();
throw new Win32Exception(errorCode, "Failed to add tray icon. Error code: " + errorCode);
}
// Keep this as a reference because we will need it when we update
// the tray icon in the future.
WindowHandle = hWnd;
Bridge.ShowWindow(hWnd, 0); // SW_HIDE
Bridge.UpdateWindow(hWnd);
Logger.LogInfo($"Created HWND for the window: {hWnd}");
SetShellIcon(hWnd, text, icon);
trayInitialized.SetResult(true);
}
catch (Exception ex)
{
Logger.LogError($"Failed to properly initialize the tray. {ex.Message}");
trayInitialized.SetException(ex);
}
});
RunOnMainThread(() =>
{
RunMessageLoop();
});
_syncContext!.BeginMessageLoop();
});
_mainThread.IsBackground = true;
_mainThread.Start();
return trayInitialized.Task;
}
internal static void SetShellIcon(IntPtr hWnd, string text, Icon? icon, TrayIconAction action = TrayIconAction.Add, [CallerMemberName] string callerName = "")
{
// For Delete operations, we don't need an icon - only hWnd is required
// For Add/Update operations, we need both hWnd and icon
bool canProceed = hWnd != IntPtr.Zero && (action == TrayIconAction.Delete || icon != null);
if (canProceed)
{
int message = Native.Constants.NIM_ADD;
switch (action)
{
case TrayIconAction.Update:
message = Native.Constants.NIM_MODIFY;
break;
case TrayIconAction.Delete:
message = Native.Constants.NIM_DELETE;
break;
case TrayIconAction.Add:
default:
break;
}
if (action is TrayIconAction.Add or TrayIconAction.Update)
{
_notifyIconData = new NotifyIconData
{
CbSize = Marshal.SizeOf<NotifyIconData>(),
HWnd = hWnd,
UId = TrayIconId,
UFlags = Native.Constants.NIF_ICON | Native.Constants.NIF_TIP | Native.Constants.NIF_MESSAGE,
UCallbackMessage = (int)Native.Constants.WM_USER,
HIcon = icon?.Handle ?? IntPtr.Zero,
SzTip = text,
};
}
else if (action == TrayIconAction.Delete)
{
_notifyIconData = new NotifyIconData
{
CbSize = Marshal.SizeOf<NotifyIconData>(),
HWnd = hWnd,
UId = TrayIconId,
UFlags = 0,
};
}
// Retry configuration based on action type
// Add operations need longer delays as Explorer may still be initializing after Windows updates
int maxRetryAttempts;
int baseDelayMs;
if (action == TrayIconAction.Add)
{
maxRetryAttempts = 10;
baseDelayMs = 500; // 500, 1000, 2000, 2000, 2000... (capped)
}
else
{
maxRetryAttempts = 3;
baseDelayMs = 100; // 100, 200, 400 (existing behavior)
}
const int maxDelayMs = 2000; // Cap delay at 2 seconds
for (int attempt = 1; attempt <= maxRetryAttempts; attempt++)
{
if (Bridge.Shell_NotifyIcon(message, ref _notifyIconData))
{
if (attempt > 1)
{
Logger.LogInfo($"Successfully set shell icon on attempt {attempt}. Action: {action}");
}
break;
}
else
{
int errorCode = Marshal.GetLastWin32Error();
Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}, attempt: {attempt}/{maxRetryAttempts}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}.");
if (attempt == maxRetryAttempts)
{
Logger.LogError($"Failed to change tray icon after {maxRetryAttempts} attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}.");
break;
}
// Exponential backoff with cap
int delayMs = Math.Min(baseDelayMs * (1 << (attempt - 1)), maxDelayMs);
Thread.Sleep(delayMs);
}
}
if (action == TrayIconAction.Delete)
{
_notifyIconData = default;
}
}
else
{
Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero{(action != TrayIconAction.Delete && icon == null ? " or icon is not available" : string.Empty)}. Text: {text} Action: {action}");
}
}
private static void RunMessageLoop()
{
while (Bridge.GetMessage(out Msg msg, IntPtr.Zero, 0, 0))
{
Bridge.TranslateMessage(ref msg);
Bridge.DispatchMessage(ref msg);
}
Logger.LogInfo("Message loop terminated.");
}
private static int WndProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam)
{
switch (message)
{
case Native.Constants.WM_USER:
if (lParam is Native.Constants.WM_LBUTTONDOWN or Native.Constants.WM_RBUTTONDOWN)
{
// Show the context menu associated with the tray icon
ShowContextMenu(hWnd);
}
break;
case Native.Constants.WM_CREATE:
{
_taskbarCreatedMessage = (uint)Bridge.RegisterWindowMessage("TaskbarCreated");
}
break;
case Native.Constants.WM_DESTROY:
// Clean up resources when the window is destroyed
Bridge.PostQuitMessage(0);
break;
case Native.Constants.WM_COMMAND:
long targetCommandValue = wParam.ToInt64() & 0xFFFF;
switch (targetCommandValue)
{
case (uint)TrayCommands.TC_EXIT:
{
Manager.CompleteExit(Environment.ExitCode);
break;
}
case (uint)TrayCommands.TC_DISPLAY_SETTING:
{
Manager.SetDisplay();
break;
}
case (uint)TrayCommands.TC_MODE_INDEFINITE:
{
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings();
Manager.SetIndefiniteKeepAwake(keepDisplayOn: settings.Properties.KeepDisplayOn);
break;
}
case (uint)TrayCommands.TC_MODE_PASSIVE:
{
Manager.SetPassiveKeepAwake();
break;
}
default:
{
// Custom tray time commands start at TC_TIME and increment by 1 for each entry.
// Check if this command falls within the custom time range.
if (targetCommandValue >= (uint)TrayCommands.TC_TIME)
{
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings();
if (settings.Properties.CustomTrayTimes.Count == 0)
{
settings.Properties.CustomTrayTimes.AddRange(Manager.GetDefaultTrayOptions());
}
int index = (int)targetCommandValue - (int)TrayCommands.TC_TIME;
if (index >= 0 && index < settings.Properties.CustomTrayTimes.Count)
{
uint targetTime = settings.Properties.CustomTrayTimes.Values.Skip(index).First();
Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn);
}
else
{
Logger.LogError($"Custom tray time index {index} is out of range. Available entries: {settings.Properties.CustomTrayTimes.Count}");
}
}
break;
}
}
break;
case Native.Constants.WM_POWERBROADCAST:
int eventType = wParam.ToInt32();
if (eventType == Native.Constants.PBT_APMRESUMEAUTOMATIC ||
eventType == Native.Constants.PBT_APMRESUMESUSPEND ||
eventType == Native.Constants.PBT_APMPOWERSTATUSCHANGE)
{
Manager.ReapplyAwakeState();
}
break;
default:
if (message == _taskbarCreatedMessage)
{
Logger.LogInfo("Taskbar re-created");
Manager.SetModeShellIcon(forceAdd: true);
}
// Let the default window procedure handle other messages
return Bridge.DefWindowProc(hWnd, message, wParam, lParam);
}
return Bridge.DefWindowProc(hWnd, message, wParam, lParam);
}
internal static void RunOnMainThread(Action action)
{
_syncContext!.Post(
_ =>
{
try
{
Logger.LogInfo($"Thread execution is on: {Environment.CurrentManagedThreadId}");
action();
}
catch (Exception e)
{
Logger.LogError($"Error in tray thread execution: {e.Message}");
}
},
null);
}
internal static void SetTray(AwakeSettings settings, bool startedFromPowerToys)
{
SetTray(
settings.Properties.KeepDisplayOn,
settings.Properties.Mode,
settings.Properties.CustomTrayTimes,
startedFromPowerToys);
}
public static void SetTray(bool keepDisplayOn, AwakeMode mode, Dictionary<string, uint> trayTimeShortcuts, bool startedFromPowerToys)
{
ClearExistingTrayMenu();
CreateNewTrayMenu(startedFromPowerToys, keepDisplayOn, mode);
InsertAwakeModeMenuItems(mode);
EnsureDefaultTrayTimeShortcuts(trayTimeShortcuts);
CreateAwakeTimeSubMenu(trayTimeShortcuts, mode == AwakeMode.TIMED);
}
private static void ClearExistingTrayMenu()
{
if (TrayMenu != IntPtr.Zero && !Bridge.DestroyMenu(TrayMenu))
{
int errorCode = Marshal.GetLastWin32Error();
Logger.LogError($"Failed to destroy menu: {errorCode}");
}
}
private static void CreateNewTrayMenu(bool startedFromPowerToys, bool keepDisplayOn, AwakeMode mode)
{
TrayMenu = Bridge.CreatePopupMenu();
if (TrayMenu == IntPtr.Zero)
{
return;
}
if (!startedFromPowerToys)
{
InsertMenuItem(0, TrayCommands.TC_EXIT, Resources.AWAKE_EXIT);
}
InsertMenuItem(0, TrayCommands.TC_DISPLAY_SETTING, Resources.AWAKE_KEEP_SCREEN_ON, keepDisplayOn, mode == AwakeMode.PASSIVE);
if (!startedFromPowerToys)
{
InsertSeparator(1);
}
}
private static void InsertMenuItem(int position, TrayCommands command, string text, bool checkedState = false, bool disabled = false)
{
uint state = Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING;
state |= checkedState ? Native.Constants.MF_CHECKED : Native.Constants.MF_UNCHECKED;
state |= disabled ? Native.Constants.MF_DISABLED : Native.Constants.MF_ENABLED;
Bridge.InsertMenu(TrayMenu, (uint)position, state, (uint)command, text);
}
private static void InsertSeparator(int position)
{
Bridge.InsertMenu(TrayMenu, (uint)position, Native.Constants.MF_BYPOSITION | Native.Constants.MF_SEPARATOR, 0, string.Empty);
}
private static void EnsureDefaultTrayTimeShortcuts(Dictionary<string, uint> trayTimeShortcuts)
{
if (trayTimeShortcuts.Count == 0)
{
trayTimeShortcuts.AddRange(Manager.GetDefaultTrayOptions());
}
}
private static void CreateAwakeTimeSubMenu(Dictionary<string, uint> trayTimeShortcuts, bool isChecked = false)
{
nint awakeTimeMenu = Bridge.CreatePopupMenu();
int i = 0;
foreach (var shortcut in trayTimeShortcuts)
{
Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, shortcut.Key);
i++;
}
Bridge.InsertMenu(TrayMenu, 0, Native.Constants.MF_BYPOSITION | Native.Constants.MF_POPUP | (isChecked ? Native.Constants.MF_CHECKED : Native.Constants.MF_UNCHECKED), (uint)awakeTimeMenu, Resources.AWAKE_KEEP_ON_INTERVAL);
}
private static void InsertAwakeModeMenuItems(AwakeMode mode)
{
InsertSeparator(0);
InsertMenuItem(0, TrayCommands.TC_MODE_PASSIVE, Resources.AWAKE_OFF, mode == AwakeMode.PASSIVE);
InsertMenuItem(0, TrayCommands.TC_MODE_INDEFINITE, Resources.AWAKE_KEEP_INDEFINITELY, mode == AwakeMode.INDEFINITE);
InsertMenuItem(0, TrayCommands.TC_MODE_EXPIRABLE, Resources.AWAKE_KEEP_UNTIL_EXPIRATION, mode == AwakeMode.EXPIRABLE, true);
}
}
}

View File

@@ -0,0 +1,212 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.UI.Xaml;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Shell;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT.Interop;
namespace Awake.Core
{
/// <summary>
/// Window-procedure delegate exposed via primitive types so it can interop with
/// CsWin32's SetWindowLongPtr without accessibility issues.
/// </summary>
/// <param name="hwnd">Handle to the window.</param>
/// <param name="msg">The message identifier.</param>
/// <param name="wParam">Additional message information.</param>
/// <param name="lParam">Additional message information.</param>
/// <returns>The result of the message processing.</returns>
internal delegate nint AwakeTrayWndProcDelegate(nint hwnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Owns the Awake notification-area icon. Mirrors PowerDisplay's TrayIconService:
/// a hidden helper Window receives Shell_NotifyIcon callbacks; both left- and
/// right-clicks toggle the WinUI flyout. The legacy HMENU is gone.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.PublicProperties)]
internal sealed partial class TrayIconService
{
private const uint MyNotifyId = 1000;
private const uint WmTrayIcon = PInvoke.WM_USER + 1;
private readonly Action _toggleWindow;
private readonly uint _wmTaskbarRestart;
private Window? _window;
private nint _hwnd;
private nint _originalWndProc;
private AwakeTrayWndProcDelegate? _trayWndProc;
private NOTIFYICONDATAW? _trayIconData;
private string _currentTooltip = string.Empty;
private nint _currentIconHandle;
public static readonly Icon DefaultIcon = new(Path.Combine(AppContext.BaseDirectory, "Assets", "Awake", "Awake.ico"));
public static readonly Icon TimedIcon = new(Path.Combine(AppContext.BaseDirectory, "Assets", "Awake", "timed.ico"));
public static readonly Icon ExpirableIcon = new(Path.Combine(AppContext.BaseDirectory, "Assets", "Awake", "expirable.ico"));
public static readonly Icon IndefiniteIcon = new(Path.Combine(AppContext.BaseDirectory, "Assets", "Awake", "indefinite.ico"));
public static readonly Icon DisabledIcon = new(Path.Combine(AppContext.BaseDirectory, "Assets", "Awake", "disabled.ico"));
public TrayIconService(Action toggleWindow)
{
_toggleWindow = toggleWindow ?? throw new ArgumentNullException(nameof(toggleWindow));
_wmTaskbarRestart = RegisterWindowMessageNative("TaskbarCreated");
}
public void SetupTrayIcon(string tooltip, Icon icon)
{
if (_window is null)
{
_window = new Window();
_hwnd = WindowNative.GetWindowHandle(_window);
// LOAD BEARING: store the delegate in a field so the marshaled pointer
// we hand to SetWindowLongPtr survives past this stack frame.
_trayWndProc = WindowProc;
var trayWndProcPointer = Marshal.GetFunctionPointerForDelegate(_trayWndProc);
_originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndproc, trayWndProcPointer);
}
_currentTooltip = tooltip;
_currentIconHandle = icon.Handle;
if (!CreateOrUpdateTrayIcon(isAdd: true))
{
// Shell can refuse NIM_ADD during explorer startup; we'll retry from WM_WINDOWPOSCHANGING / TaskbarCreated.
Logger.LogWarning("[Awake] Shell_NotifyIcon(NIM_ADD) failed; will retry when shell is ready");
_trayIconData = null;
}
}
public void UpdateIcon(Icon icon, string tooltip)
{
_currentIconHandle = icon.Handle;
_currentTooltip = tooltip;
if (_trayIconData is null)
{
// No icon registered yet; try to add it now.
CreateOrUpdateTrayIcon(isAdd: true);
return;
}
CreateOrUpdateTrayIcon(isAdd: false);
}
public void Destroy()
{
if (_trayIconData is not null)
{
var d = (NOTIFYICONDATAW)_trayIconData;
unsafe
{
if (Shell_NotifyIconNative((uint)NOTIFY_ICON_MESSAGE.NIM_DELETE, &d))
{
_trayIconData = null;
}
}
}
if (_window is not null)
{
_window.Close();
_window = null;
_hwnd = 0;
}
}
private bool CreateOrUpdateTrayIcon(bool isAdd)
{
unsafe
{
var data = new NOTIFYICONDATAW
{
cbSize = (uint)sizeof(NOTIFYICONDATAW),
hWnd = new HWND(_hwnd),
uID = MyNotifyId,
uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP,
uCallbackMessage = WmTrayIcon,
hIcon = new HICON(_currentIconHandle),
szTip = _currentTooltip ?? string.Empty,
};
bool success = Shell_NotifyIconNative(
isAdd ? (uint)NOTIFY_ICON_MESSAGE.NIM_ADD : (uint)NOTIFY_ICON_MESSAGE.NIM_MODIFY,
&data);
if (success)
{
_trayIconData = data;
}
return success;
}
}
private nint WindowProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
switch (uMsg)
{
// Shell can refuse NIM_ADD during explorer startup; WM_WINDOWPOSCHANGING is the first
// reliable signal that the shell is ready, so re-attempt the add there.
case PInvoke.WM_WINDOWPOSCHANGING:
if (_trayIconData is null && _currentIconHandle != 0)
{
CreateOrUpdateTrayIcon(isAdd: true);
}
break;
default:
if (uMsg == _wmTaskbarRestart)
{
Logger.LogInfo("[Awake] TaskbarCreated received; re-adding tray icon");
_trayIconData = null;
if (_currentIconHandle != 0)
{
CreateOrUpdateTrayIcon(isAdd: true);
}
}
else if (uMsg == WmTrayIcon)
{
// Per Awake spec (#28530): both buttons open the flyout, no Win32 menu.
switch ((uint)lParam)
{
case PInvoke.WM_LBUTTONUP:
case PInvoke.WM_RBUTTONUP:
_toggleWindow?.Invoke();
break;
}
}
break;
}
return CallWindowProcIntPtr(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProcIntPtr(IntPtr lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
[LibraryImport("user32.dll", EntryPoint = "RegisterWindowMessageW", StringMarshalling = StringMarshalling.Utf16)]
private static partial uint RegisterWindowMessageNative(string lpString);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("shell32.dll", EntryPoint = "Shell_NotifyIconW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static unsafe partial bool Shell_NotifyIconNative(uint dwMessage, NOTIFYICONDATAW* lpData);
private const int GwlWndproc = -4;
}
}

View File

@@ -0,0 +1,12 @@
// Structs and types only - functions use LibraryImport
NOTIFYICONDATAW
NOTIFY_ICON_MESSAGE
NOTIFY_ICON_DATA_FLAGS
// Window message constants (used by TrayIconService)
WM_USER
WM_COMMAND
WM_RBUTTONUP
WM_LBUTTONUP
WM_LBUTTONDBLCLK
WM_WINDOWPOSCHANGING

View File

@@ -23,6 +23,7 @@ using Awake.Telemetry;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
namespace Awake
{
@@ -50,7 +51,8 @@ namespace Awake
private static ConsoleEventHandler? _handler;
private static SystemPowerCapabilities _powerCapabilities;
private static async Task<int> Main(string[] args)
[STAThread]
private static int Main(string[] args)
{
Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs"));
@@ -90,8 +92,20 @@ namespace Awake
Logger.LogError("CultureNotFoundException: " + ex.Message);
}
await TrayHelper.InitializeTray(TrayHelper.DefaultAwakeIcon, Core.Constants.FullAppName);
AppDomain.CurrentDomain.ProcessExit += (_, _) => TrayHelper.RunOnMainThread(() => LockMutex?.ReleaseMutex());
// Note: dispose (rather than ReleaseMutex) here because ProcessExit runs on a
// different thread than the one that acquired the mutex, and ReleaseMutex requires
// the owning thread. Disposing closes the handle and the OS releases ownership on exit.
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
try
{
LockMutex?.Dispose();
}
catch (Exception ex)
{
Logger.LogError("Failed to dispose the single-instance mutex on exit: " + ex.Message);
}
};
AppDomain.CurrentDomain.UnhandledException += AwakeUnhandledExceptionCatcher;
if (!instantiated)
@@ -102,40 +116,68 @@ namespace Awake
Exit(Core.Constants.AppName + " is already running! Exiting the application.", 1);
return 1;
}
else
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredAwakeEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredAwakeEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
LogCLITelemetry(successful: false);
Exit("PowerToys.Awake tried to start with a group policy setting that disables the tool. Please contact your system administrator.", 1);
return 1;
}
else
{
Logger.LogInfo($"Launching {Core.Constants.AppName}...");
Logger.LogInfo(FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion);
Logger.LogInfo($"Build: {Core.Constants.BuildId}");
Logger.LogInfo($"OS: {Environment.OSVersion}");
Logger.LogInfo($"OS Build: {Manager.GetOperatingSystemBuild()}");
TaskScheduler.UnobservedTaskException += (sender, args) =>
{
Trace.WriteLine($"Task scheduler error: {args.Exception.Message}"); // somebody forgot to check!
args.SetObserved();
};
// To make it easier to diagnose future issues, let's get the
// system power capabilities and aggregate them in the log.
Bridge.GetPwrCapabilities(out _powerCapabilities);
Logger.LogInfo(JsonSerializer.Serialize(_powerCapabilities, _serializerOptions));
var result = await rootCommand.InvokeAsync(args);
LogCLITelemetry(successful: result == 0);
return result;
}
LogCLITelemetry(successful: false);
Exit("PowerToys.Awake tried to start with a group policy setting that disables the tool. Please contact your system administrator.", 1);
return 1;
}
Logger.LogInfo($"Launching {Core.Constants.AppName}...");
Logger.LogInfo(FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion);
Logger.LogInfo($"Build: {Core.Constants.BuildId}");
Logger.LogInfo($"OS: {Environment.OSVersion}");
Logger.LogInfo($"OS Build: {Manager.GetOperatingSystemBuild()}");
TaskScheduler.UnobservedTaskException += (sender, args) =>
{
Trace.WriteLine($"Task scheduler error: {args.Exception.Message}"); // somebody forgot to check!
args.SetObserved();
};
// To make it easier to diagnose future issues, let's get the
// system power capabilities and aggregate them in the log.
Bridge.GetPwrCapabilities(out _powerCapabilities);
Logger.LogInfo(JsonSerializer.Serialize(_powerCapabilities, _serializerOptions));
// Stash the parsed arguments so the WinUI dispatcher can pick them up after the
// tray icon and main window are ready.
_pendingArgs = args;
// Hand control to the WinUI runtime. The callback runs on the dispatcher thread;
// we create the AwakeApp there (which creates the tray icon and the hidden flyout)
// and then invoke the existing System.CommandLine handler from the UI thread.
WinRT.ComWrappersSupport.InitializeComWrappers();
Microsoft.UI.Xaml.Application.Start((_) =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
var app = new AwakeApp(_startedFromPowerToys);
// Defer the handler invocation until after AwakeApp.OnLaunched has run.
DispatcherQueue.GetForCurrentThread().TryEnqueue(() =>
{
try
{
int handlerResult = rootCommand.Invoke(_pendingArgs ?? Array.Empty<string>());
LogCLITelemetry(successful: handlerResult == 0);
}
catch (Exception ex)
{
Logger.LogError($"Unhandled exception while invoking command handler: {ex}");
LogCLITelemetry(successful: false);
}
});
});
return Environment.ExitCode;
}
private static string[]? _pendingArgs;
private static RootCommand BuildRootCommand()
{
Logger.LogInfo("Parsing parameters...");
@@ -523,8 +565,9 @@ namespace Awake
private static void InitializeSettings()
{
AwakeSettings settings = Manager.ModuleSettings?.GetSettings<AwakeSettings>(Core.Constants.AppName) ?? new AwakeSettings();
TrayHelper.SetTray(settings, _startedFromPowerToys);
// The flyout reads CustomTrayTimes directly from settings on open, so there's
// nothing for us to push at startup beyond logging that the settings exist.
_ = Manager.ModuleSettings?.GetSettings<AwakeSettings>(Core.Constants.AppName) ?? new AwakeSettings();
}
private static void HandleAwakeConfigChange(FileSystemEventArgs fileEvent)
@@ -585,8 +628,6 @@ namespace Awake
Logger.LogError("Unknown mode of operation. Check config file.");
break;
}
TrayHelper.SetTray(settings, _startedFromPowerToys);
}
catch (Exception ex)
{

View File

@@ -338,5 +338,257 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_REMAINING", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_TITLE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_TITLE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_MODE_HEADER {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_MODE_HEADER", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_MODE_OFF {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_MODE_OFF", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_MODE_INDEFINITE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_MODE_INDEFINITE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_MODE_TIMED {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_MODE_TIMED", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_MODE_EXPIRABLE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_MODE_EXPIRABLE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_DAY {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_DAY", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_DAYS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_DAYS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_WEEK {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_WEEK", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_WEEKS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_WEEKS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_MONTH {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_MONTH", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_DURATION_MONTHS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_DURATION_MONTHS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_AWAKE_UNTIL {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_AWAKE_UNTIL", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_AWAKE_INDEFINITELY {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_AWAKE_INDEFINITELY", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_WHILE_APP_RUNS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_WHILE_APP_RUNS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_FOREVER {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_FOREVER", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_CUSTOM {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_CUSTOM", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_CARD_WHILE_APP {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_CARD_WHILE_APP", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_CARD_CUSTOM_UNTIL {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_CARD_CUSTOM_UNTIL", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_CUSTOM_DURATION {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_CUSTOM_DURATION", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_STOP {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_STOP", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_START {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_START", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_ACTIVE_UNTIL {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_ACTIVE_UNTIL", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_UNIT_MIN {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_UNIT_MIN", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_UNIT_HOUR {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_UNIT_HOUR", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_UNIT_HOURS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_UNIT_HOURS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_SUBTITLE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_SUBTITLE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_ACTIVE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_ACTIVE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_KEEP_SCREEN_ON_DESC {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_KEEP_SCREEN_ON_DESC", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_TIMED_HEADER {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_TIMED_HEADER", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_EXPIRABLE_HEADER {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_EXPIRABLE_HEADER", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_EXPIRABLE_DATE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_EXPIRABLE_DATE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_EXPIRABLE_TIME {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_EXPIRABLE_TIME", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_EXPIRABLE_APPLY {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_EXPIRABLE_APPLY", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_EDIT_PRESETS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_EDIT_PRESETS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_OPEN_SETTINGS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_OPEN_SETTINGS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_STATUS_OFF {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_STATUS_OFF", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_STATUS_INDEFINITE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_STATUS_INDEFINITE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_STATUS_TIMED {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_STATUS_TIMED", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_STATUS_EXPIRABLE {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_STATUS_EXPIRABLE", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_INTERVAL_HOURS {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_INTERVAL_HOURS", resourceCulture);
}
}
internal static string AWAKE_FLYOUT_INTERVAL_MINUTES {
get {
return ResourceManager.GetString("AWAKE_FLYOUT_INTERVAL_MINUTES", resourceCulture);
}
}
}
}

View File

@@ -221,4 +221,172 @@
<value>remaining</value>
<comment>Suffix for timed mode showing time remaining, e.g. "1:30:00 remaining".</comment>
</data>
<data name="AWAKE_FLYOUT_TITLE" xml:space="preserve">
<value>PowerToys Awake</value>
<comment>Title shown at the top of the Awake tray flyout.</comment>
</data>
<data name="AWAKE_FLYOUT_MODE_HEADER" xml:space="preserve">
<value>Mode</value>
<comment>Header label above the mode selector (Off / Indefinite / Timed / Until expiration).</comment>
</data>
<data name="AWAKE_FLYOUT_MODE_OFF" xml:space="preserve">
<value>Off</value>
<comment>Short label for the Passive (no keep-awake) mode in the flyout's mode selector.</comment>
</data>
<data name="AWAKE_FLYOUT_MODE_INDEFINITE" xml:space="preserve">
<value>Indefinite</value>
<comment>Short label for the Indefinite keep-awake mode in the flyout's mode selector.</comment>
</data>
<data name="AWAKE_FLYOUT_MODE_TIMED" xml:space="preserve">
<value>Timed</value>
<comment>Short label for the Timed keep-awake mode in the flyout's mode selector.</comment>
</data>
<data name="AWAKE_FLYOUT_MODE_EXPIRABLE" xml:space="preserve">
<value>Until date</value>
<comment>Short label for the Expirable keep-awake mode (active until a specified date and time) in the flyout's mode selector.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_DAY" xml:space="preserve">
<value>{0} day</value>
<comment>Subtext under the countdown ring for a single remaining day. {0} is the number 1.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_DAYS" xml:space="preserve">
<value>{0} days</value>
<comment>Subtext under the countdown ring for the number of remaining days. {0} is the day count.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_WEEK" xml:space="preserve">
<value>{0} week</value>
<comment>Subtext under the countdown ring for a single remaining week. {0} is the number 1.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_WEEKS" xml:space="preserve">
<value>{0} weeks</value>
<comment>Subtext under the countdown ring for the number of remaining weeks. {0} is the week count.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_MONTH" xml:space="preserve">
<value>{0} month</value>
<comment>Subtext under the countdown ring for a single remaining month. {0} is the number 1.</comment>
</data>
<data name="AWAKE_FLYOUT_DURATION_MONTHS" xml:space="preserve">
<value>{0} months</value>
<comment>Subtext under the countdown ring for the number of remaining months. {0} is the month count.</comment>
</data>
<data name="AWAKE_FLYOUT_AWAKE_UNTIL" xml:space="preserve">
<value>Awake until {0}</value>
<comment>Line under the countdown gauge stating when keep-awake ends. {0} is a time or date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_AWAKE_INDEFINITELY" xml:space="preserve">
<value>Stays awake until stopped</value>
<comment>Line under the countdown gauge when the machine is kept awake indefinitely.</comment>
</data>
<data name="AWAKE_FLYOUT_WHILE_APP_RUNS" xml:space="preserve">
<value>Awake while {0} runs</value>
<comment>Status line shown while keep-awake is bound to a running app. {0} is the app name.</comment>
</data>
<data name="AWAKE_FLYOUT_FOREVER" xml:space="preserve">
<value>Forever</value>
<comment>Label for the flyout chip that keeps the machine awake indefinitely.</comment>
</data>
<data name="AWAKE_FLYOUT_CUSTOM" xml:space="preserve">
<value>Custom</value>
<comment>Label for the flyout chip that opens a popup to set a custom duration or end date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_CARD_WHILE_APP" xml:space="preserve">
<value>While app runs</value>
<comment>Default label on the flyout card that keeps the PC awake while a chosen app runs.</comment>
</data>
<data name="AWAKE_FLYOUT_CARD_CUSTOM_UNTIL" xml:space="preserve">
<value>Until {0}</value>
<comment>Custom card label when keeping awake until a specific time. {0} is a time or date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_CUSTOM_DURATION" xml:space="preserve">
<value>Duration</value>
<comment>Label for the option in the Custom popup that keeps the machine awake for a specified hours/minutes duration.</comment>
</data>
<data name="AWAKE_FLYOUT_STOP" xml:space="preserve">
<value>Stop</value>
<comment>Label for the button that stops keeping the machine awake and returns to following the power plan.</comment>
</data>
<data name="AWAKE_FLYOUT_START" xml:space="preserve">
<value>Start</value>
<comment>Label for the button that starts keeping the machine awake using the selected duration.</comment>
</data>
<data name="AWAKE_FLYOUT_ACTIVE_UNTIL" xml:space="preserve">
<value>Active until {0}</value>
<comment>Preview line stating when keep-awake will end. {0} is a time or date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_UNIT_MIN" xml:space="preserve">
<value>min</value>
<comment>Short unit label for minutes shown under a duration preset (e.g. "15 min").</comment>
</data>
<data name="AWAKE_FLYOUT_UNIT_HOUR" xml:space="preserve">
<value>hour</value>
<comment>Singular unit label for an hour shown under a duration preset (e.g. "1 hour").</comment>
</data>
<data name="AWAKE_FLYOUT_UNIT_HOURS" xml:space="preserve">
<value>hours</value>
<comment>Plural unit label for hours shown under a duration preset (e.g. "2 hours").</comment>
</data>
<data name="AWAKE_FLYOUT_SUBTITLE" xml:space="preserve">
<value>Choose a duration to prevent sleep.</value>
<comment>Subtitle shown in the flyout header while keep-awake is not active.</comment>
</data>
<data name="AWAKE_FLYOUT_ACTIVE" xml:space="preserve">
<value>ACTIVE</value>
<comment>Badge text shown in the flyout header while keep-awake is running.</comment>
</data>
<data name="AWAKE_FLYOUT_KEEP_SCREEN_ON_DESC" xml:space="preserve">
<value>Prevent your display from going to sleep.</value>
<comment>Description shown beneath the "Keep screen on" checkbox.</comment>
</data>
<data name="AWAKE_FLYOUT_TIMED_HEADER" xml:space="preserve">
<value>Keep awake for</value>
<comment>Header above the list of preset timed durations.</comment>
</data>
<data name="AWAKE_FLYOUT_EXPIRABLE_HEADER" xml:space="preserve">
<value>Keep awake until</value>
<comment>Header above the date and time pickers for expirable mode.</comment>
</data>
<data name="AWAKE_FLYOUT_EXPIRABLE_DATE" xml:space="preserve">
<value>Date</value>
<comment>Label next to the date picker.</comment>
</data>
<data name="AWAKE_FLYOUT_EXPIRABLE_TIME" xml:space="preserve">
<value>Time</value>
<comment>Label next to the time picker.</comment>
</data>
<data name="AWAKE_FLYOUT_EXPIRABLE_APPLY" xml:space="preserve">
<value>Apply</value>
<comment>Button that confirms the chosen expiration date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_EDIT_PRESETS" xml:space="preserve">
<value>Edit presets in Settings</value>
<comment>Link that opens the Awake page in PowerToys Settings to edit the list of preset durations.</comment>
</data>
<data name="AWAKE_FLYOUT_OPEN_SETTINGS" xml:space="preserve">
<value>Open settings</value>
<comment>Footer button that opens the Awake page in PowerToys Settings.</comment>
</data>
<data name="AWAKE_FLYOUT_STATUS_OFF" xml:space="preserve">
<value>Using the system power plan</value>
<comment>Status line shown below the title when Awake is in Passive (Off) mode.</comment>
</data>
<data name="AWAKE_FLYOUT_STATUS_INDEFINITE" xml:space="preserve">
<value>Keeping your PC awake indefinitely</value>
<comment>Status line shown below the title when Awake is in Indefinite mode.</comment>
</data>
<data name="AWAKE_FLYOUT_STATUS_TIMED" xml:space="preserve">
<value>Keeping your PC awake for {0}</value>
<comment>Status line shown below the title when Awake is in Timed mode. {0} is the duration, e.g. "30 minutes".</comment>
</data>
<data name="AWAKE_FLYOUT_STATUS_EXPIRABLE" xml:space="preserve">
<value>Keeping your PC awake until {0}</value>
<comment>Status line shown below the title when Awake is in Expirable mode. {0} is the formatted date/time.</comment>
</data>
<data name="AWAKE_FLYOUT_INTERVAL_HOURS" xml:space="preserve">
<value>Hours</value>
<comment>Header above the hours number input in the flyout's Timed mode section.</comment>
</data>
<data name="AWAKE_FLYOUT_INTERVAL_MINUTES" xml:space="preserve">
<value>Minutes</value>
<comment>Header above the minutes number input in the flyout's Timed mode section.</comment>
</data>
</root>

View File

@@ -0,0 +1,260 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="HeaderTitle.Text" xml:space="preserve">
<value>Awake is not running</value>
<comment>Title shown in the flyout header while keep-awake is not active.</comment>
</data>
<data name="HeaderSubtitle.Text" xml:space="preserve">
<value>PC sleep follows your power plan.</value>
<comment>Subtitle shown in the flyout header while keep-awake is not active.</comment>
</data>
<data name="ActiveBadgeText.Text" xml:space="preserve">
<value>ACTIVE</value>
<comment>Badge text shown in the flyout header while keep-awake is running.</comment>
</data>
<data name="Card30Unit.Text" xml:space="preserve">
<value>min</value>
<comment>Unit label shown under the "30" on the 30-minute preset card.</comment>
</data>
<data name="Card60Unit.Text" xml:space="preserve">
<value>hour</value>
<comment>Unit label shown under the "1" on the 1-hour preset card.</comment>
</data>
<data name="Card120Unit.Text" xml:space="preserve">
<value>hours</value>
<comment>Unit label shown under the "2" on the 2-hour preset card.</comment>
</data>
<data name="Card30.Content" xml:space="preserve">
<value>30 min</value>
<comment>Duration preset button that keeps the PC awake for 30 minutes.</comment>
</data>
<data name="Card60.Content" xml:space="preserve">
<value>1 hour</value>
<comment>Duration preset button that keeps the PC awake for 1 hour.</comment>
</data>
<data name="Card120.Content" xml:space="preserve">
<value>2 hours</value>
<comment>Duration preset button that keeps the PC awake for 2 hours.</comment>
</data>
<data name="CardForever.Content" xml:space="preserve">
<value>Forever</value>
<comment>Duration preset button that keeps the PC awake indefinitely.</comment>
</data>
<data name="CardForeverText.Text" xml:space="preserve">
<value>Forever</value>
<comment>Label on the card that keeps the PC awake indefinitely.</comment>
</data>
<data name="CardCustomText.Text" xml:space="preserve">
<value>Custom</value>
<comment>Label on the button that opens the custom duration picker.</comment>
</data>
<data name="CardWhileAppText.Text" xml:space="preserve">
<value>While app runs</value>
<comment>Label on the button that opens the running-app picker; keeps the PC awake while the chosen app runs.</comment>
</data>
<data name="CardCustomNav.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Configure custom duration</value>
<comment>Accessible name for the chevron that opens the custom duration picker page.</comment>
</data>
<data name="CardCustomNav.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Configure custom duration</value>
<comment>Tooltip for the chevron that opens the custom duration picker page.</comment>
</data>
<data name="CardWhileAppNav.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Choose an app</value>
<comment>Accessible name for the chevron that opens the running-app picker page.</comment>
</data>
<data name="CardWhileAppNav.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Choose an app</value>
<comment>Tooltip for the chevron that opens the running-app picker page.</comment>
</data>
<data name="AppSearchBox.PlaceholderText" xml:space="preserve">
<value>Search apps</value>
<comment>Placeholder text in the search box of the running-app picker.</comment>
</data>
<data name="AppEmptyText.Text" xml:space="preserve">
<value>No running apps found</value>
<comment>Shown in the running-app picker when no apps match the search.</comment>
</data>
<data name="CustomDurationRadio.Content" xml:space="preserve">
<value>Duration</value>
<comment>Option to keep awake for a custom number of hours and minutes.</comment>
</data>
<data name="CustomUntilRadio.Content" xml:space="preserve">
<value>Until date</value>
<comment>Option to keep awake until a specific date and time.</comment>
</data>
<data name="IntervalHoursInput.Header" xml:space="preserve">
<value>Hours</value>
<comment>Header for the custom-duration hours input.</comment>
</data>
<data name="IntervalMinutesInput.Header" xml:space="preserve">
<value>Minutes</value>
<comment>Header for the custom-duration minutes input.</comment>
</data>
<data name="ExpirationDatePicker.Header" xml:space="preserve">
<value>Date</value>
<comment>Header for the keep-awake-until date picker.</comment>
</data>
<data name="ExpirationTimePicker.Header" xml:space="preserve">
<value>Time</value>
<comment>Header for the keep-awake-until time picker.</comment>
</data>
<data name="CustomApplyButton.Content" xml:space="preserve">
<value>Apply</value>
<comment>Button that confirms the custom duration selection.</comment>
</data>
<data name="KeepDisplayOnToggle.Content" xml:space="preserve">
<value>Keep screen on</value>
<comment>Checkbox to also keep the display awake.</comment>
</data>
<data name="ActionStartText.Text" xml:space="preserve">
<value>Start</value>
<comment>Text on the action button that starts keeping the PC awake.</comment>
</data>
<data name="ActionStopText.Text" xml:space="preserve">
<value>Stop</value>
<comment>Text on the action button that stops keeping the PC awake.</comment>
</data>
<data name="OpenSettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Open settings</value>
<comment>Tooltip for the button that opens Awake settings.</comment>
</data>
<data name="OpenSettingsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Open settings</value>
<comment>Accessible name for the button that opens Awake settings.</comment>
</data>
<data name="AwakeBackButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Back</value>
<comment>Tooltip for the button that returns to the main flyout page.</comment>
</data>
<data name="AwakeBackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Back</value>
<comment>Accessible name for the button that returns to the main flyout page.</comment>
</data>
<data name="CustomTimeTitle.Text" xml:space="preserve">
<value>Custom duration</value>
<comment>Title of the page where the user sets a custom keep-awake duration or end time.</comment>
</data>
<data name="AppPickerTitle.Text" xml:space="preserve">
<value>While app runs</value>
<comment>Title of the page where the user picks an app to keep the PC awake while it runs.</comment>
</data>
</root>

View File

@@ -0,0 +1,749 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using Awake.Core;
using Awake.Properties;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
namespace Awake.ViewModels
{
/// <summary>
/// Identifies which keep-awake duration the user has selected in the flyout. Shared across
/// the launch page and the custom-time page so the selection survives frame navigation.
/// </summary>
public enum FlyoutSelectionKind
{
Timed,
Forever,
Custom,
WhileApp,
}
/// <summary>
/// Backs the Awake tray flyout. Reads the current Awake state from <see cref="Manager"/>
/// (which is the single source of truth) and delegates user actions back to the same
/// <c>SetXxxKeepAwake</c> APIs that the old HMENU click handlers used.
/// </summary>
public sealed partial class AwakeFlyoutViewModel : ObservableObject, IDisposable
{
private static readonly CompositeFormat StatusTimedFormat =
CompositeFormat.Parse(Resources.AWAKE_FLYOUT_STATUS_TIMED);
private static readonly CompositeFormat StatusExpirableFormat =
CompositeFormat.Parse(Resources.AWAKE_FLYOUT_STATUS_EXPIRABLE);
private static readonly CompositeFormat AwakeUntilFormat =
CompositeFormat.Parse(Resources.AWAKE_FLYOUT_AWAKE_UNTIL);
private static readonly CompositeFormat AwakeWhileAppFormat =
CompositeFormat.Parse(Resources.AWAKE_FLYOUT_WHILE_APP_RUNS);
private static readonly CompositeFormat CustomUntilCardFormat =
CompositeFormat.Parse(Resources.AWAKE_FLYOUT_CARD_CUSTOM_UNTIL);
private readonly SettingsUtils _settingsUtils;
private bool _suppressApply;
[ObservableProperty]
private AwakeMode _mode;
[ObservableProperty]
private bool _keepDisplayOn;
[ObservableProperty]
private DateTimeOffset _expirationDate;
[ObservableProperty]
private TimeSpan _expirationTime;
[ObservableProperty]
private uint _intervalHours;
[ObservableProperty]
private uint _intervalMinutes;
[ObservableProperty]
private string _statusText = string.Empty;
[ObservableProperty]
private string _countdownTime = string.Empty;
[ObservableProperty]
private string _offAtText = string.Empty;
[ObservableProperty]
private Microsoft.UI.Xaml.Visibility _offAtVisibility = Microsoft.UI.Xaml.Visibility.Collapsed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ActiveAppIconVisibility))]
[NotifyPropertyChangedFor(nameof(ActiveCountdownVisibility))]
private bool _isProcessBound;
[ObservableProperty]
private string _boundAppName = string.Empty;
[ObservableProperty]
private string _customCardText = Resources.AWAKE_FLYOUT_CUSTOM;
[ObservableProperty]
private string _whileAppCardText = Resources.AWAKE_FLYOUT_CARD_WHILE_APP;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ActiveAppIconVisibility))]
[NotifyPropertyChangedFor(nameof(ActiveCountdownVisibility))]
private Microsoft.UI.Xaml.Media.ImageSource? _whileAppCardIcon;
// In the active header, show the bound app's icon (instead of the infinity glyph) whenever
// Awake is tracking an app and we actually have an icon for it.
public Microsoft.UI.Xaml.Visibility ActiveAppIconVisibility =>
IsProcessBound && WhileAppCardIcon != null
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
public Microsoft.UI.Xaml.Visibility ActiveCountdownVisibility =>
IsProcessBound && WhileAppCardIcon != null
? Microsoft.UI.Xaml.Visibility.Collapsed
: Microsoft.UI.Xaml.Visibility.Visible;
public bool KeepDisplayOnEnabled => Mode != AwakeMode.PASSIVE;
// True while a keep-awake session is running; drives the header's active visual state.
public bool IsActive => Mode != AwakeMode.PASSIVE;
public Microsoft.UI.Xaml.Visibility StopButtonVisibility =>
Mode != AwakeMode.PASSIVE ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed;
public Microsoft.UI.Xaml.Visibility TimedSectionVisibility =>
Mode == AwakeMode.TIMED ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed;
public Microsoft.UI.Xaml.Visibility ExpirableSectionVisibility =>
Mode == AwakeMode.EXPIRABLE ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed;
public AwakeFlyoutViewModel(SettingsUtils settingsUtils, bool startedFromPowerToys)
{
_settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils));
_ = startedFromPowerToys;
Manager.ModeChanged += OnManagerModeChanged;
Refresh();
}
/// <summary>
/// Re-reads the current Awake state from <see cref="Manager"/> and the
/// on-disk settings and updates all bindable properties. Safe to call repeatedly.
/// </summary>
public void Refresh()
{
try
{
_suppressApply = true;
// Set the process-bound state *before* Mode. Setting Mode raises PropertyChanged
// synchronously, and listeners (e.g. the launch page) re-run SyncPendingFromMode,
// which needs the correct IsProcessBound to map INDEFINITE to "While app" vs. "Forever".
IsProcessBound = Manager.IsProcessBound;
BoundAppName = Manager.BoundProcessName;
Mode = Manager.CurrentOperatingMode;
KeepDisplayOn = Manager.IsDisplayOn;
var expireAt = Manager.ExpireAt;
if (expireAt <= DateTimeOffset.Now)
{
expireAt = DateTimeOffset.Now.AddMinutes(30);
}
ExpirationDate = new DateTimeOffset(expireAt.Date, expireAt.Offset);
ExpirationTime = expireAt.TimeOfDay;
LoadIntervalFromSettings();
UpdateStatusText();
UpdateCountdown();
}
finally
{
_suppressApply = false;
}
}
private void LoadIntervalFromSettings()
{
try
{
var settings = _settingsUtils.GetSettings<AwakeSettings>(Core.Constants.AppName);
if (settings is not null)
{
IntervalHours = settings.Properties.IntervalHours;
IntervalMinutes = settings.Properties.IntervalMinutes;
}
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to load interval from settings: {ex.Message}");
}
}
private void OnManagerModeChanged(object? sender, EventArgs e)
{
// Manager raises this off the dispatcher; marshal back to the UI thread
// via the app's main window dispatcher if we have one.
var dq = AwakeApp.Current?.MainWindow?.DispatcherQueue;
if (dq is not null)
{
dq.TryEnqueue(Refresh);
}
else
{
Refresh();
}
}
partial void OnModeChanged(AwakeMode value)
{
// Notify the computed/derived properties so XAML one-way bindings refresh.
OnPropertyChanged(nameof(KeepDisplayOnEnabled));
OnPropertyChanged(nameof(IsActive));
OnPropertyChanged(nameof(StopButtonVisibility));
OnPropertyChanged(nameof(TimedSectionVisibility));
OnPropertyChanged(nameof(ExpirableSectionVisibility));
if (_suppressApply)
{
UpdateStatusText();
UpdateCountdown();
return;
}
ApplyMode(value);
UpdateStatusText();
UpdateCountdown();
}
partial void OnKeepDisplayOnChanged(bool value)
{
if (_suppressApply)
{
return;
}
// SetDisplay toggles the persisted value when running under PT config; otherwise
// it directly drives the executor. Either way it always re-applies the current mode.
if (value != Manager.IsDisplayOn)
{
Manager.SetDisplay();
}
}
partial void OnIntervalHoursChanged(uint value)
{
OnIntervalChanged();
}
partial void OnIntervalMinutesChanged(uint value)
{
OnIntervalChanged();
}
partial void OnExpirationDateChanged(DateTimeOffset value)
{
OnExpirationChanged();
}
partial void OnExpirationTimeChanged(TimeSpan value)
{
OnExpirationChanged();
}
private void OnIntervalChanged()
{
if (_suppressApply)
{
return;
}
if (Mode == AwakeMode.TIMED)
{
ApplyTimedFromInterval();
}
UpdateStatusText();
}
private void OnExpirationChanged()
{
if (_suppressApply)
{
return;
}
if (Mode == AwakeMode.EXPIRABLE)
{
ApplyExpirableFromPickers();
}
UpdateStatusText();
}
private void ApplyTimedFromInterval()
{
uint seconds = (IntervalHours * 3600u) + (IntervalMinutes * 60u);
if (seconds == 0)
{
// 0/0 would resolve to an instantaneous expiration; ignore until the user
// provides a non-zero interval.
return;
}
Manager.SetTimedKeepAwake(seconds, KeepDisplayOn);
}
/// <summary>
/// Applies a one-tap timed preset (e.g. the 30m / 1h / 2h / 4h chips). Switches into
/// timed mode if necessary and starts the keep-awake session immediately, applying the
/// hours/minutes in a single shot so we don't kick off two redundant timers.
/// </summary>
public void ApplyTimedPreset(uint hours, uint minutes)
{
try
{
_suppressApply = true;
IntervalHours = hours;
IntervalMinutes = minutes;
}
finally
{
_suppressApply = false;
}
// Setting Mode raises OnModeChanged which applies the timed interval; if we're
// already in timed mode that path doesn't run, so apply directly.
if (Mode != AwakeMode.TIMED)
{
Mode = AwakeMode.TIMED;
}
else
{
ApplyTimedFromInterval();
UpdateStatusText();
}
UpdateCountdown();
}
/// <summary>
/// Applies the custom "Until date" selection from the date/time pickers. Switches into
/// expirable mode if necessary; if already expirable, re-applies so edited values take effect.
/// </summary>
public void ApplyUntilDate()
{
if (Mode != AwakeMode.EXPIRABLE)
{
Mode = AwakeMode.EXPIRABLE;
}
else
{
ApplyExpirableFromPickers();
UpdateStatusText();
}
UpdateCountdown();
}
// The duration the user has selected in the flyout but may not have applied yet (the
// Start button applies it). Kept on the view model so the launch page and the custom-time
// page share one source of truth across frame navigation.
public FlyoutSelectionKind PendingSelection { get; set; } = FlyoutSelectionKind.Timed;
public uint PendingMinutes { get; set; } = 60;
public bool PendingCustomIsUntil { get; set; }
public int PendingProcessId { get; set; }
public string PendingProcessName { get; set; } = string.Empty;
/// <summary>
/// Records a custom duration / until-date selection (without starting it) and updates the
/// Custom card label. The session only starts when the user presses Start, except when a
/// session is already running, in which case it is re-applied live.
/// </summary>
public void SetPendingCustom(bool isUntil)
{
PendingSelection = FlyoutSelectionKind.Custom;
PendingCustomIsUntil = isUntil;
CustomCardText = FormatCustomCardLabel();
ApplyPendingIfActive();
}
/// <summary>
/// Records a "while app runs" selection (without starting it) and updates the While-app
/// card label/icon. Starts on Start, or re-applies live if a session is already running.
/// </summary>
public void SetPendingApp(int processId, string processName, Microsoft.UI.Xaml.Media.ImageSource? icon)
{
PendingSelection = FlyoutSelectionKind.WhileApp;
PendingProcessId = processId;
PendingProcessName = processName ?? string.Empty;
WhileAppCardText = string.IsNullOrEmpty(processName) ? Resources.AWAKE_FLYOUT_CARD_WHILE_APP : processName;
WhileAppCardIcon = icon;
ApplyPendingIfActive();
}
private string FormatCustomCardLabel()
{
if (PendingCustomIsUntil)
{
var target = new DateTimeOffset(
ExpirationDate.Year,
ExpirationDate.Month,
ExpirationDate.Day,
ExpirationTime.Hours,
ExpirationTime.Minutes,
0,
DateTimeOffset.Now.Offset);
string when = target.LocalDateTime.Date == DateTime.Now.Date
? target.ToString("t", CultureInfo.CurrentCulture)
: target.ToString("g", CultureInfo.CurrentCulture);
return string.Format(CultureInfo.CurrentCulture, CustomUntilCardFormat, when);
}
return FormatInterval(IntervalHours, IntervalMinutes);
}
/// <summary>
/// Realigns <see cref="PendingSelection"/> with the running mode so the launch page
/// highlights the matching card after a refresh or a fresh open. Non-preset durations and
/// expirations map to the Custom card; a process binding maps to the While-app card.
/// </summary>
public void SyncPendingFromMode()
{
switch (Mode)
{
case AwakeMode.INDEFINITE when IsProcessBound:
PendingSelection = FlyoutSelectionKind.WhileApp;
PendingProcessName = BoundAppName;
PendingProcessId = ProcessIdOrZero();
if (!string.IsNullOrEmpty(BoundAppName))
{
WhileAppCardText = BoundAppName;
}
break;
case AwakeMode.INDEFINITE:
PendingSelection = FlyoutSelectionKind.Forever;
break;
case AwakeMode.EXPIRABLE:
PendingSelection = FlyoutSelectionKind.Custom;
PendingCustomIsUntil = true;
CustomCardText = FormatCustomCardLabel();
break;
case AwakeMode.TIMED:
uint minutes = (IntervalHours * 60) + IntervalMinutes;
if (minutes is 30 or 60 or 120)
{
PendingSelection = FlyoutSelectionKind.Timed;
PendingMinutes = minutes;
}
else
{
PendingSelection = FlyoutSelectionKind.Custom;
PendingCustomIsUntil = false;
CustomCardText = FormatInterval(IntervalHours, IntervalMinutes);
}
break;
default:
PendingSelection = FlyoutSelectionKind.Timed;
PendingMinutes = 60;
break;
}
}
private static int ProcessIdOrZero() => Manager.IsProcessBound ? Manager.ProcessId : 0;
/// <summary>
/// Refreshes only the Custom sub-mode (duration vs. until-date) from the running session so
/// that reopening the custom page lands on the tab that matches the active mode. Leaves the
/// flag untouched when nothing is running, preserving the user's last in-flyout choice.
/// </summary>
public void RefreshPendingCustomSubMode()
{
switch (Mode)
{
case AwakeMode.EXPIRABLE:
PendingCustomIsUntil = true;
break;
case AwakeMode.TIMED:
PendingCustomIsUntil = false;
break;
default:
break;
}
}
/// <summary>
/// Applies the pending selection, starting (or restarting) a keep-awake session.
/// </summary>
public void ApplyPendingSelection()
{
switch (PendingSelection)
{
case FlyoutSelectionKind.Forever:
Mode = AwakeMode.INDEFINITE;
break;
case FlyoutSelectionKind.WhileApp:
if (PendingProcessId != 0)
{
ApplyProcessBinding(PendingProcessId, PendingProcessName);
}
break;
case FlyoutSelectionKind.Custom when PendingCustomIsUntil:
ApplyUntilDate();
break;
case FlyoutSelectionKind.Custom:
ApplyTimedPreset(IntervalHours, IntervalMinutes);
break;
default:
ApplyTimedPreset(PendingMinutes / 60, PendingMinutes % 60);
break;
}
}
/// <summary>
/// Re-applies the pending selection only when a session is already running, so changing
/// the selection updates the live session without forcing a stop and restart.
/// </summary>
public void ApplyPendingIfActive()
{
if (Mode != AwakeMode.PASSIVE)
{
ApplyPendingSelection();
}
}
/// <summary>
/// Binds keep-awake to a running process: keep the system awake while the target app
/// runs and automatically revert to passive when it exits. Delegates to
/// <see cref="Manager.SetProcessBoundKeepAwake"/> (same mechanism as the CLI <c>--pid</c>
/// path). <see cref="Manager.ModeChanged"/> triggers a <see cref="Refresh"/>, which reads
/// the bound-process state back into the bindable properties.
/// </summary>
public void ApplyProcessBinding(int processId, string appName)
{
try
{
Manager.SetProcessBoundKeepAwake(processId, appName, KeepDisplayOn);
}
catch (Exception ex)
{
Logger.LogError($"Failed to bind keep-awake to process {processId}: {ex.Message}");
}
}
/// <see cref="Manager.ModeStartedAt"/> / <see cref="Manager.ExpireAt"/>. Intended to be
/// called once per second by the flyout while it is visible.
/// </summary>
public void UpdateCountdown()
{
bool countdownMode = Mode == AwakeMode.TIMED || Mode == AwakeMode.EXPIRABLE;
if (countdownMode && Manager.ExpireAt > DateTimeOffset.Now)
{
TimeSpan remaining = Manager.ExpireAt - DateTimeOffset.Now;
CountdownTime = FormatRemaining(remaining);
OffAtText = FormatAwakeUntil(Manager.ExpireAt);
OffAtVisibility = Microsoft.UI.Xaml.Visibility.Visible;
}
else if (Mode == AwakeMode.INDEFINITE)
{
// No finite end: surface the infinity glyph instead of a countdown.
CountdownTime = "\u221E";
OffAtText = IsProcessBound
? string.Format(CultureInfo.CurrentCulture, AwakeWhileAppFormat, BoundAppName)
: Resources.AWAKE_FLYOUT_AWAKE_INDEFINITELY;
OffAtVisibility = Microsoft.UI.Xaml.Visibility.Visible;
}
else
{
// Passive (off) or an already-expired session.
CountdownTime = Resources.AWAKE_FLYOUT_MODE_OFF;
OffAtText = string.Empty;
OffAtVisibility = Microsoft.UI.Xaml.Visibility.Collapsed;
}
}
/// <summary>
/// Formats the big gauge value using compact unit suffixes (e.g. "5h 10m 10s"), showing
/// only the relevant units so it stays readable and visibly ticks down.
/// </summary>
private static string FormatRemaining(TimeSpan remaining)
{
if (remaining.TotalDays >= 1)
{
return string.Format(
CultureInfo.InvariantCulture,
"{0}d {1}h",
(int)remaining.TotalDays,
remaining.Hours);
}
if (remaining.TotalHours >= 1)
{
return string.Format(
CultureInfo.InvariantCulture,
"{0}h {1}m {2}s",
(int)remaining.TotalHours,
remaining.Minutes,
remaining.Seconds);
}
if (remaining.TotalMinutes >= 1)
{
return string.Format(
CultureInfo.InvariantCulture,
"{0}m {1}s",
remaining.Minutes,
remaining.Seconds);
}
return string.Format(
CultureInfo.InvariantCulture,
"{0}s",
remaining.Seconds);
}
/// <summary>
/// Builds the "Awake until …" line shown beneath the gauge. Uses a short time for sessions
/// ending today and a full date+time otherwise so multi-day sessions are unambiguous.
/// </summary>
private static string FormatAwakeUntil(DateTimeOffset expireAt)
{
string when = expireAt.LocalDateTime.Date == DateTime.Now.Date
? expireAt.ToString("t", CultureInfo.CurrentCulture)
: expireAt.ToString("g", CultureInfo.CurrentCulture);
return string.Format(CultureInfo.CurrentCulture, AwakeUntilFormat, when);
}
private void ApplyMode(AwakeMode mode)
{
try
{
switch (mode)
{
case AwakeMode.PASSIVE:
Manager.SetPassiveKeepAwake();
break;
case AwakeMode.INDEFINITE:
Manager.SetIndefiniteKeepAwake(KeepDisplayOn);
break;
case AwakeMode.TIMED:
ApplyTimedFromInterval();
break;
case AwakeMode.EXPIRABLE:
ApplyExpirableFromPickers();
break;
}
}
catch (Exception ex)
{
Logger.LogError($"AwakeFlyoutViewModel.ApplyMode failed: {ex}");
}
}
private void ApplyExpirableFromPickers()
{
var target = new DateTimeOffset(
ExpirationDate.Year,
ExpirationDate.Month,
ExpirationDate.Day,
ExpirationTime.Hours,
ExpirationTime.Minutes,
0,
DateTimeOffset.Now.Offset);
if (target <= DateTimeOffset.Now)
{
Logger.LogWarning("Expirable target is in the past; ignoring.");
return;
}
Manager.SetExpirableKeepAwake(target, KeepDisplayOn);
}
[RelayCommand]
private void OpenSettings()
{
try
{
SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.Awake);
}
catch (Exception ex)
{
Logger.LogError($"OpenSettings failed: {ex.Message}");
}
}
private void UpdateStatusText()
{
StatusText = Mode switch
{
AwakeMode.INDEFINITE => IsProcessBound
? string.Format(CultureInfo.CurrentCulture, AwakeWhileAppFormat, BoundAppName)
: Resources.AWAKE_FLYOUT_STATUS_INDEFINITE,
AwakeMode.TIMED => string.Format(
CultureInfo.CurrentCulture,
StatusTimedFormat,
FormatInterval(IntervalHours, IntervalMinutes)),
AwakeMode.EXPIRABLE => string.Format(
CultureInfo.CurrentCulture,
StatusExpirableFormat,
Manager.ExpireAt.ToString("g", CultureInfo.CurrentCulture)),
_ => Resources.AWAKE_FLYOUT_STATUS_OFF,
};
}
private static string FormatInterval(uint hours, uint minutes)
{
if (hours > 0 && minutes > 0)
{
return string.Format(CultureInfo.CurrentCulture, "{0}h {1}m", hours, minutes);
}
if (hours > 0)
{
return string.Format(CultureInfo.CurrentCulture, "{0}h", hours);
}
return string.Format(CultureInfo.CurrentCulture, "{0}m", minutes);
}
public void Dispose()
{
Manager.ModeChanged -= OnManagerModeChanged;
}
}
}

View File

@@ -1,9 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="1.0.0.0" name="PowerToys.Awake.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<!-- Windows 11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -57,7 +57,7 @@ private:
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L"--use-pt-config --pid " + std::to_wstring(powertoys_pid);
std::wstring application_path = L"PowerToys.Awake.exe";
std::wstring application_path = L"WinUI3Apps\\PowerToys.Awake.exe";
std::wstring full_command_path = application_path + L" " + executable_args.data();
Logger::trace(L"PowerToys Awake launching with parameters: " + executable_args);

View File

@@ -443,7 +443,7 @@ public sealed partial class MainListPage : DynamicListPage,
{
specialFallbacks.Add(s);
}
else
else if (s.IsEnabled)
{
commonFallbacks.Add(s);
}

View File

@@ -105,6 +105,13 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string? Homepage => _entry.Homepage;
// Validated, browser-openable homepage uri. Null when the entry has no
// homepage or it is not a web uri. NavigateUri bindings must use this
// (a Uri) rather than the raw Homepage string: x:Bind evaluates bindings
// regardless of element visibility, and converting a null/invalid string
// to Uri throws and crashes the page.
public Uri? HomepageUri => _homepageHttpUri;
public Uri IconUri { get; }
public ImageSource IconSource

View File

@@ -232,7 +232,7 @@
Grid.Row="3"
Padding="0"
AutomationProperties.AutomationId="CmdPal_GalleryItemPage_ViewRepository"
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
NavigateUri="{x:Bind ViewModel.HomepageUri, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.Homepage, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasHomepage), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="4">
@@ -332,7 +332,7 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="232" />
</Grid.RowDefinitions>
<TextBlock
@@ -365,6 +365,7 @@
<SolidColorBrush x:Key="ItemContainerBackgroundPressed" Color="Transparent" />
</ItemContainer.Resources>
<Border
Width="356"
Height="200"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"

View File

@@ -160,20 +160,24 @@ public sealed partial class SettingsWindow : WindowEx,
break;
}
if (pageType is not null)
if (pageType is null)
{
NavFrame.Navigate(pageType);
return;
}
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
if (NavFrame.Content?.GetType() == pageType)
{
return;
}
NavFrame.Navigate(pageType);
// Now, make sure to actually select the correct menu item too
foreach (var obj in NavView.MenuItems)
{
if (obj is NavigationViewItem item && item.Tag is string s && s == page)
{
if (obj is NavigationViewItem item)
{
if (item.Tag is string s && s == page)
{
NavView.SelectedItem = item;
}
}
NavView.SelectedItem = item;
}
}
}

View File

@@ -4,13 +4,23 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="using:Microsoft.PowerToys.Common.UI.Controls.Window"
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<controls:TransientSurface
x:Name="Surface"
MaxWidth="560"
Margin="24,24,24,16"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
HideTransition="Bottom"
ShowTransition="Bottom">
<TextBlock
Margin="16,10,20,12"
Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</controls:TransientSurface>
</common:TransparentWindow>

View File

@@ -17,11 +17,14 @@ using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// CmdPal's transient toast banner. Inherits all of its chrome, click-through,
/// acrylic, and fade/slide animations from
/// <see cref="TransparentWindow"/>; adds only the bits that are bespoke to
/// CmdPal toasts: a bound message <c>TextBlock</c>, a 2.5 s auto-dismiss timer,
/// bottom-center positioning, and <see cref="QuitMessage"/> handling.
/// CmdPal's transient toast notification. It is a bare
/// <see cref="TransparentWindow"/> host whose content is a
/// <see cref="Microsoft.PowerToys.Common.UI.Controls.TransientSurface"/> — the
/// surface supplies the acrylic, border, corners, shadow, and the fade/slide
/// animation, driven automatically off the window's show/hide events. This class
/// adds only the bits bespoke to CmdPal toasts: a bound message <c>TextBlock</c>,
/// a 2.5 s auto-dismiss timer, bottom-center positioning, and
/// <see cref="QuitMessage"/> handling.
/// </summary>
public sealed partial class ToastWindow : TransparentWindow,
IRecipient<QuitMessage>
@@ -39,13 +42,10 @@ public sealed partial class ToastWindow : TransparentWindow,
AppWindow.Title = RS_.GetString("ToastWindowTitle");
this.SetWindowSize(600, 180);
// Pin the chrome card to bottom-center with the toast's classic 560-wide
// pill shape. The window itself stays 600x180 so the slide animations
// have headroom and we don't have to chase SizeToContent.
Card.HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center;
Card.VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Bottom;
Card.MaxWidth = 560;
Card.Margin = new Microsoft.UI.Xaml.Thickness(24, 24, 24, 16);
// Let the surface animate itself in/out in response to this window's
// Show()/Hide(). The 600x180 window leaves the bottom-center 560-wide
// pill (positioned in XAML) room for its slide + shadow.
Surface.SubscribeTo(this);
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
}

View File

@@ -119,6 +119,7 @@ public class ExtensionGalleryItemViewModelTests
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.HasAuthorUrl);
Assert.IsFalse(viewModel.HasUrlSource);
Assert.IsFalse(viewModel.HasActionableSourceDetails);
@@ -131,6 +132,32 @@ public class ExtensionGalleryItemViewModelTests
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_SetsHomepageUri_WhenHomepageIsWebUri()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = "https://example.com/extension";
var viewModel = CreateViewModel(entry);
Assert.IsTrue(viewModel.HasHomepage);
Assert.AreEqual(new Uri("https://example.com/extension"), viewModel.HomepageUri);
Assert.IsTrue(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_LeavesHomepageUriNull_WhenHomepageIsMissing()
{
var entry = CreateEntry(iconUrl: null);
entry.Homepage = null;
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsNull(viewModel.HomepageUri);
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{

View File

@@ -134,6 +134,25 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
MoreCommands = _networkPage.Commands,
};
if (isBandPage)
{
_networkUpItem = new ListItem(_networkPage)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage.Commands,
};
_networkDownItem = new ListItem(_networkPage)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage.Commands,
};
}
_networkPage.Updated += (s, e) =>
{
_networkItem.Title = _networkPage.GetItemTitle(isBandPage);
@@ -253,22 +272,6 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
}
else
{
_networkUpItem = new ListItem(_networkPage!)
{
Title = $"{_networkUpSpeed}",
Subtitle = Resources.GetResource("Network_Send_Subtitle"),
Icon = Icons.NetworkUpIcon,
MoreCommands = _networkPage!.Commands,
};
_networkDownItem = new ListItem(_networkPage!)
{
Title = $"{_networkDownSpeed}",
Subtitle = Resources.GetResource("Network_Receive_Subtitle"),
Icon = Icons.NetworkDownIcon,
MoreCommands = _networkPage!.Commands,
};
return _batteryItem is not null
? new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem!, _batteryItem! }
: new[] { _cpuItem!, _memoryItem!, _networkUpItem!, _networkDownItem!, _gpuItem! };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -33,16 +33,40 @@ namespace Community.PowerToys.Run.Plugin.VSCodeWorkspaces.WorkspacesHelper
return null;
}
var (workspaceEnv, machineName) = ParseVSCodeAuthority.GetWorkspaceEnvironment(authority ?? rfc3986Uri.Authority);
var isFileUri = string.Equals(
rfc3986Uri.Scheme,
Uri.UriSchemeFile,
StringComparison.OrdinalIgnoreCase);
var isUncFileUri =
isFileUri &&
string.IsNullOrEmpty(authority) &&
!string.IsNullOrEmpty(rfc3986Uri.Authority) &&
!string.Equals(
rfc3986Uri.Authority,
"localhost",
StringComparison.OrdinalIgnoreCase);
// file://server/share is a local Windows UNC path, not a VS Code remote URI.
var effectiveAuthority =
isFileUri && string.IsNullOrEmpty(authority)
? string.Empty
: authority ?? rfc3986Uri.Authority;
var (workspaceEnv, machineName) =
ParseVSCodeAuthority.GetWorkspaceEnvironment(effectiveAuthority);
if (workspaceEnv is null)
{
return null;
}
var path = rfc3986Uri.Path;
var path = isUncFileUri
? $@"\\{rfc3986Uri.Authority}{rfc3986Uri.Path.Replace('/', '\\')}"
: rfc3986Uri.Path;
// Remove preceding '/' from local (Windows) path
if (workspaceEnv == WorkspaceEnvironment.Local)
// file:///C:/... becomes C:/...
if (workspaceEnv == WorkspaceEnvironment.Local && !isUncFileUri)
{
path = path[1..];
}

View File

@@ -8,7 +8,7 @@ namespace PowerDisplay.Helpers
{
/// <summary>
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
/// <c>Microsoft.PowerToys.Common.UI.Controls.Window.FlyoutWindowHelper</c> (Common.UI.Controls).
/// </summary>
internal static partial class WindowHelper
{

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Configuration;

View File

@@ -5,7 +5,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ManagedCommon;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Windowing;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -7,7 +7,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.PowerToys.QuickAccess.Services;
using Microsoft.PowerToys.QuickAccess.ViewModels;
using Microsoft.UI.Dispatching;

View File

@@ -44,6 +44,24 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("spotlight_mode")]
public BoolProperty SpotlightMode { get; set; }
[JsonPropertyName("ripple_mode")]
public BoolProperty RippleMode { get; set; }
[JsonPropertyName("ripple_size")]
public IntProperty RippleSize { get; set; }
[JsonPropertyName("ripple_intensity")]
public DoubleProperty RippleIntensity { get; set; }
[JsonPropertyName("ripple_duration_ms")]
public IntProperty RippleDurationMs { get; set; }
[JsonPropertyName("ripple_show_drag_trail")]
public BoolProperty RippleShowDragTrail { get; set; }
[JsonPropertyName("ripple_show_release_pulse")]
public BoolProperty RippleShowReleasePulse { get; set; }
public MouseHighlighterProperties()
{
ActivationShortcut = DefaultActivationShortcut;
@@ -51,11 +69,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library
RightButtonClickColor = new StringProperty("#a60000FF");
AlwaysColor = new StringProperty("#00FF0000");
HighlightOpacity = new IntProperty(166); // for migration from <=1.1 to 1.2
HighlightRadius = new IntProperty(20);
HighlightFadeDelayMs = new IntProperty(500);
HighlightFadeDurationMs = new IntProperty(250);
HighlightRadius = new IntProperty(30);
HighlightFadeDelayMs = new IntProperty(400);
HighlightFadeDurationMs = new IntProperty(400);
AutoActivate = new BoolProperty(false);
SpotlightMode = new BoolProperty(false);
RippleMode = new BoolProperty(true);
RippleSize = new IntProperty(60);
RippleIntensity = new DoubleProperty(0.7);
RippleDurationMs = new IntProperty(480);
RippleShowDragTrail = new BoolProperty(true);
RippleShowReleasePulse = new BoolProperty(true);
}
}
}

View File

@@ -8,6 +8,7 @@
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:panels="using:Microsoft.PowerToys.Settings.UI.Panels"
xmlns:ptcontrols="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
xmlns:ui="using:CommunityToolkit.WinUI"
@@ -265,30 +266,32 @@
</tkcontrols:SettingsExpander>
<tkcontrols:SettingsExpander
Name="MouseHighlighterAppearanceBehavior"
x:Uid="Appearance_Behavior"
x:Uid="MouseUtils_MouseHighlighter_Appearance"
AutomationProperties.AutomationId="MouseUtils_MouseHighlighterAppearanceBehaviorId"
HeaderIcon="{ui:FontIcon Glyph=&#xEB3C;}"
HeaderIcon="{ui:FontIcon Glyph=&#xE790;}"
IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}">
<ComboBox
x:Uid="MouseUtils_MouseHighlighter_SpotlightModeType"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind ViewModel.HighlightModeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="HighlightMode_Spotlight_Mode" />
<ComboBoxItem x:Uid="HighlightMode_Circle_Highlight_Mode" />
<ComboBoxItem x:Uid="HighlightMode_Ripple_Mode" />
</ComboBox>
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_PrimaryButtonClickColor" IsEnabled="{x:Bind ViewModel.IsSpotlightModeEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_PrimaryButtonClickColor" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x6}">
<controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterLeftButtonClickColor, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_SecondaryButtonClickColor" IsEnabled="{x:Bind ViewModel.IsSpotlightModeEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_SecondaryButtonClickColor" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x6}">
<controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterRightButtonClickColor, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterAlwaysColor" x:Uid="MouseUtils_MouseHighlighter_AlwaysColor">
<tkcontrols:SettingsCard
Name="MouseUtilsMouseHighlighterAlwaysColor"
x:Uid="MouseUtils_MouseHighlighter_AlwaysColor"
Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x3}">
<controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterAlwaysColor, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="HighlightMode">
<ComboBox
x:Uid="MouseUtils_MouseHighlighter_SpotlightModeType"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind ViewModel.IsSpotlightModeEnabled, Converter={StaticResource ReverseBoolToComboBoxIndexConverter}, Mode=TwoWay}">
<ComboBoxItem x:Uid="HighlightMode_Spotlight_Mode" />
<ComboBoxItem x:Uid="HighlightMode_Circle_Highlight_Mode" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_HighlightRadius">
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_HighlightRadius" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x3}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="10"
@@ -297,7 +300,10 @@
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.MouseHighlighterRadius, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterFadeDelayMs" x:Uid="MouseUtils_MouseHighlighter_FadeDelayMs">
<tkcontrols:SettingsCard
Name="MouseUtilsMouseHighlighterFadeDelayMs"
x:Uid="MouseUtils_MouseHighlighter_FadeDelayMs"
Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x3}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="100"
@@ -306,7 +312,10 @@
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.MouseHighlighterFadeDelayMs, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterFadeDurationMs" x:Uid="MouseUtils_MouseHighlighter_FadeDurationMs">
<tkcontrols:SettingsCard
Name="MouseUtilsMouseHighlighterFadeDurationMs"
x:Uid="MouseUtils_MouseHighlighter_FadeDurationMs"
Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x3}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="100"
@@ -315,6 +324,42 @@
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.MouseHighlighterFadeDurationMs, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_RippleSize" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x4}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="10"
Maximum="300"
Minimum="10"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.RippleSize, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_RippleIntensity" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x4}">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="0.1"
Maximum="1.35"
Minimum="0.15"
SmallChange="0.05"
StepFrequency="0.05"
Value="{x:Bind ViewModel.RippleIntensity, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_RippleDurationMs" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x4}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="100"
Maximum="2000"
Minimum="60"
SmallChange="10"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.RippleDurationMs, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x4}">
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="MouseUtils_MouseHighlighter_RippleShowDragTrail" IsChecked="{x:Bind ViewModel.RippleShowDragTrail, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left" Visibility="{x:Bind ViewModel.HighlightModeIndex, Mode=OneWay, Converter={StaticResource IndexBitFieldToVisibilityConverter}, ConverterParameter=0x4}">
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="MouseUtils_MouseHighlighter_RippleShowReleasePulse" IsChecked="{x:Bind ViewModel.RippleShowReleasePulse, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>

View File

@@ -1149,6 +1149,12 @@ opera.exe</value>
<data name="Appearance_Behavior.Header" xml:space="preserve">
<value>Appearance &amp; behavior</value>
</data>
<data name="MouseUtils_MouseHighlighter_Appearance.Header" xml:space="preserve">
<value>Highlight mode</value>
</data>
<data name="MouseUtils_MouseHighlighter_Appearance.Description" xml:space="preserve">
<value>Choose the highlight style and customize its appearance</value>
</data>
<data name="StartupAndPermissions.Header" xml:space="preserve">
<value>Startup &amp; permissions</value>
</data>
@@ -2811,17 +2817,29 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Fade delay (ms)</value>
<comment>ms = milliseconds</comment>
</data>
<data name="MouseUtils_MouseHighlighter_FadeDelayMs.Description" xml:space="preserve">
<value>Time before the highlight begins to fade (ms)</value>
<comment>ms = milliseconds</comment>
</data>
<data name="MouseUtils_MouseHighlighter_FadeDurationMs.Header" xml:space="preserve">
<value>Fade duration (ms)</value>
<comment>ms = milliseconds</comment>
</data>
<data name="MouseUtils_MouseHighlighter_FadeDurationMs.Description" xml:space="preserve">
<value>Duration of the disappear animation (ms)</value>
<comment>ms = milliseconds</comment>
<data name="MouseUtils_MouseHighlighter_RippleSize.Header" xml:space="preserve">
<value>Size (px)</value>
<comment>Ripple mode only. px = pixels. Base radius of the click pulse.</comment>
</data>
<data name="MouseUtils_MouseHighlighter_RippleIntensity.Header" xml:space="preserve">
<value>Intensity</value>
<comment>Ripple mode only. Brightness/punch of the pulse.</comment>
</data>
<data name="MouseUtils_MouseHighlighter_RippleDurationMs.Header" xml:space="preserve">
<value>Duration (ms)</value>
<comment>Ripple mode only. ms = milliseconds.</comment>
</data>
<data name="MouseUtils_MouseHighlighter_RippleShowDragTrail.Header" xml:space="preserve">
<value>Follow cursor while held</value>
<comment>Ripple mode only. Toggle for whether the held ring tracks the cursor when dragged.</comment>
</data>
<data name="MouseUtils_MouseHighlighter_RippleShowReleasePulse.Header" xml:space="preserve">
<value>Show crosshairs on right-click release</value>
<comment>Ripple mode only. Toggle for the expanding crosshair lines drawn when a right-click is released.</comment>
</data>
<data name="MouseUtils_MousePointerCrosshairs.Header" xml:space="preserve">
<value>Mouse Pointer Crosshairs</value>
@@ -5188,7 +5206,7 @@ The break timer font matches the text font.</value>
<value>No shortcuts to show.</value>
</data>
<data name="HighlightMode.Description" xml:space="preserve">
<value>Highlight the cursor or dim the screen to spotlight it</value>
<value>Highlight the cursor, dim the screen to spotlight it, or pulse a ripple on each click</value>
</data>
<data name="HighlightMode.Header" xml:space="preserve">
<value>Highlight mode</value>
@@ -5199,6 +5217,10 @@ The break timer font matches the text font.</value>
<data name="HighlightMode_Spotlight_Mode.Content" xml:space="preserve">
<value>Spotlight</value>
</data>
<data name="HighlightMode_Ripple_Mode.Content" xml:space="preserve">
<value>Ripple</value>
<comment>Name of the highlight mode that draws an expanding ring pulse on each click.</comment>
</data>
<data name="GeneralPage_EnableDataDiagnosticsText.Text" xml:space="preserve">
<value>Helps us make PowerToys faster, more stable, and better over time</value>
</data>

View File

@@ -77,6 +77,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
string alwaysColor = MouseHighlighterSettingsConfig.Properties.AlwaysColor.Value;
_highlighterAlwaysColor = !string.IsNullOrEmpty(alwaysColor) ? alwaysColor : "#00FF0000";
_isSpotlightModeEnabled = MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value;
_isRippleModeEnabled = MouseHighlighterSettingsConfig.Properties.RippleMode.Value;
_rippleSize = MouseHighlighterSettingsConfig.Properties.RippleSize.Value;
_rippleIntensity = MouseHighlighterSettingsConfig.Properties.RippleIntensity.Value;
_rippleDurationMs = MouseHighlighterSettingsConfig.Properties.RippleDurationMs.Value;
_rippleShowDragTrail = MouseHighlighterSettingsConfig.Properties.RippleShowDragTrail.Value;
_rippleShowReleasePulse = MouseHighlighterSettingsConfig.Properties.RippleShowReleasePulse.Value;
_highlighterRadius = MouseHighlighterSettingsConfig.Properties.HighlightRadius.Value;
_highlightFadeDelayMs = MouseHighlighterSettingsConfig.Properties.HighlightFadeDelayMs.Value;
@@ -608,6 +614,64 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool IsRippleModeEnabled
{
get => _isRippleModeEnabled;
set
{
if (_isRippleModeEnabled != value)
{
_isRippleModeEnabled = value;
MouseHighlighterSettingsConfig.Properties.RippleMode.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
// ComboBox index for the highlight mode selector.
// 0 = Spotlight, 1 = Circle, 2 = Ripple
public int HighlightModeIndex
{
get
{
if (_isSpotlightModeEnabled)
{
return 0;
}
return _isRippleModeEnabled ? 2 : 1;
}
set
{
bool spotlight = value == 0;
bool ripple = value == 2;
bool changed = false;
if (_isSpotlightModeEnabled != spotlight)
{
_isSpotlightModeEnabled = spotlight;
MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value = spotlight;
OnPropertyChanged(nameof(IsSpotlightModeEnabled));
changed = true;
}
if (_isRippleModeEnabled != ripple)
{
_isRippleModeEnabled = ripple;
MouseHighlighterSettingsConfig.Properties.RippleMode.Value = ripple;
OnPropertyChanged(nameof(IsRippleModeEnabled));
changed = true;
}
if (changed)
{
OnPropertyChanged(nameof(HighlightModeIndex));
NotifyMouseHighlighterPropertyChanged();
}
}
}
public int MouseHighlighterRadius
{
get
@@ -680,6 +744,76 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public int RippleSize
{
get => _rippleSize;
set
{
if (value != _rippleSize)
{
_rippleSize = value;
MouseHighlighterSettingsConfig.Properties.RippleSize.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
public double RippleIntensity
{
get => _rippleIntensity;
set
{
if (value != _rippleIntensity)
{
_rippleIntensity = value;
MouseHighlighterSettingsConfig.Properties.RippleIntensity.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
public int RippleDurationMs
{
get => _rippleDurationMs;
set
{
if (value != _rippleDurationMs)
{
_rippleDurationMs = value;
MouseHighlighterSettingsConfig.Properties.RippleDurationMs.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
public bool RippleShowDragTrail
{
get => _rippleShowDragTrail;
set
{
if (value != _rippleShowDragTrail)
{
_rippleShowDragTrail = value;
MouseHighlighterSettingsConfig.Properties.RippleShowDragTrail.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
public bool RippleShowReleasePulse
{
get => _rippleShowReleasePulse;
set
{
if (value != _rippleShowReleasePulse)
{
_rippleShowReleasePulse = value;
MouseHighlighterSettingsConfig.Properties.RippleShowReleasePulse.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
public void NotifyMouseHighlighterPropertyChanged([CallerMemberName] string propertyName = null)
{
OnPropertyChanged(propertyName);
@@ -1214,6 +1348,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private string _highlighterRightButtonClickColor;
private string _highlighterAlwaysColor;
private bool _isSpotlightModeEnabled;
private bool _isRippleModeEnabled;
private int _rippleSize;
private double _rippleIntensity;
private int _rippleDurationMs;
private bool _rippleShowDragTrail;
private bool _rippleShowReleasePulse;
private int _highlighterRadius;
private int _highlightFadeDelayMs;
private int _highlightFadeDurationMs;