glasskit 0.8.0

Native Windows UI with a glass look: tray icon and menu, glass popup, themed dialogs, and helpers for tray apps.
Documentation
// Copyright (c) 2026 John Law
// SPDX-License-Identifier: MIT

//! A frosted-glass popup that asks a question with choice buttons.
//!
//! The blurred desktop shows through the popup. It fades in at a [`Placement`] in the work
//! area of the primary monitor, stays on top, and does not take the keyboard focus.

mod countdown;
mod glass;
mod image;
mod input;
mod layout;
mod markup;
mod prompt;
mod state;

use std::ffi::c_void;
use std::rc::Rc;
use std::time::SystemTime;

use windows::Win32::Foundation::{HINSTANCE, HWND, RECT};
use windows::Win32::Graphics::Dwm::{
    DWMWA_USE_HOSTBACKDROPBRUSH, DWMWA_USE_IMMERSIVE_DARK_MODE, DWMWA_WINDOW_CORNER_PREFERENCE,
    DWMWCP_DONOTROUND,
};
use windows::Win32::UI::WindowsAndMessaging::{
    CreateWindowExW, IDC_ARROW, SPI_GETWORKAREA, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS,
    SystemParametersInfoW, WS_EX_NOACTIVATE, WS_EX_NOREDIRECTIONBITMAP, WS_EX_TOOLWINDOW,
    WS_EX_TOPMOST, WS_POPUP,
};
use windows::core::{PCWSTR, w};

use crate::Ui;
use crate::error::{Call, Error, Result};
use crate::events::PopupId;
use crate::native::{self, NativeWindow};
use crate::placement::{self, Area, Placement, Rect, SCREEN_MARGIN};
use crate::render::Graphics;
use crate::theme::Theme;
use crate::ui::PopupSlot;
use countdown::{label, whole_seconds};
use glass::Scene;
pub use image::Image;
pub use prompt::{Mark, PopupStyle, Prompt};
use state::{WindowState, wndproc};

const CLASS_NAME: PCWSTR = w!("GlasskitChoicePopup");
/// The space between two popups that show together, at 100% display scale.
const STACK_GAP: f32 = 8.0;

/// How a popup ended.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Response {
    /// The user clicked the choice at this index.
    Choice(usize),
    /// The user typed this text in the input box and pressed Enter. See [`Prompt::input`].
    /// The text has no space at the start or the end, and it is not empty.
    Text(String),
    /// The user clicked the dismiss button.
    Dismissed,
    /// The auto-dismiss countdown came to zero, and the popup faded out.
    TimedOut,
    /// The thread got WM_QUIT while the popup showed. The popup posts WM_QUIT again for the caller.
    Quit,
}

/// The answer of [`ChoicePopup::ask`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
    /// How the popup ended.
    pub response: Response,
    /// The time of the click or of the timeout, not the time the popup closed.
    pub at: SystemTime,
}

/// A popup that asks one [`Prompt`] at a time.
///
/// It keeps no window between prompts: [`ChoicePopup::show`] makes the window and the GPU
/// resources, and [`ChoicePopup::take_reply`] frees them.
///
/// To ask more than one prompt at a time, make more popups with the same [`Ui`]. A popup
/// that shows does not cover the others: it goes above them when its placement is in the
/// lower half of the work area, and below them when it is in the upper half. If there is no
/// space, it shows at its own place.
pub struct ChoicePopup {
    ui: Ui,
    id: PopupId,
    placement: Placement,
    style: PopupStyle,
    active: Option<Active>,
}

/// The window of a prompt that shows, and what it needs.
struct Active {
    window: NativeWindow<WindowState>,
    _slot: PopupSlot,
    /// The GPU resources. They go last, after the window and its scene.
    _gfx: Graphics,
}

impl ChoicePopup {
    /// The next popup uses this style.
    pub fn set_style(&mut self, style: PopupStyle) {
        self.style = style;
    }

    /// The next popup shows at this place in the work area.
    pub fn set_placement(&mut self, placement: Placement) {
        self.placement = placement;
    }

    /// A popup that shows at `placement` in the work area.
    pub fn new(ui: &Ui, placement: Placement) -> Result<Self> {
        native::register_class(CLASS_NAME, Some(wndproc), ui.hinstance(), IDC_ARROW)?;
        Ok(Self {
            ui: ui.clone(),
            id: PopupId(ui.new_id()),
            placement,
            style: PopupStyle::default(),
            active: None,
        })
    }

    /// The id that [`Event::Popup`](crate::events::Event::Popup) gives when a prompt of this
    /// popup ends.
    pub fn id(&self) -> PopupId {
        self.id
    }

    /// Whether a prompt shows now.
    pub fn is_showing(&self) -> bool {
        self.active.is_some()
    }

    /// Show the prompt and return at once. The prompt ends when the user clicks a choice or the
    /// dismiss button, or when the countdown ends. Then the event loop gives
    /// [`Event::Popup`](crate::events::Event::Popup) with [`ChoicePopup::id`], and
    /// [`ChoicePopup::take_reply`] gives the reply. A click on the dismiss button, or on a choice
    /// of a prompt without marks, ends the prompt at once.
    ///
    /// It fails with [`Error::PopupShowing`] while another prompt of this popup shows.
    pub fn show(&mut self, request: &Prompt) -> Result<()> {
        if self.active.is_some() {
            return Err(Error::PopupShowing);
        }
        let scale = primary_monitor_scale();
        let gfx = Graphics::new()?;
        let fonts = layout::Fonts::new(&gfx, scale, &self.style)?;
        let dismiss_label = match request.auto_dismiss {
            Some(after) => label(&request.dismiss, whole_seconds(after)),
            None => request.dismiss.to_string(),
        };
        let content = layout::Content::of(request, dismiss_label);
        let layout = layout::build(scale, self.style.width, &gfx, &fonts, &content)?;
        let theme = Theme::current();
        let others = self.ui.popup_rects();
        let (hwnd, rect) = create_window(
            self.ui.hinstance(),
            &layout,
            self.placement,
            scale,
            &theme,
            &others,
        )?;
        let guard = native::DestroyOnError::new(hwnd);
        let scene = Scene::new(
            &gfx,
            hwnd,
            layout.width,
            layout.height,
            layout.window_radius,
            &theme,
        )?;
        let state = WindowState::new(self.id.0, self.style, layout, scene, theme, fonts, request);
        let handle = NativeWindow::attach(hwnd, Rc::new(state));
        guard.disarm();
        handle.state().open(hwnd)?;
        self.active = Some(Active {
            window: handle,
            _slot: self.ui.claim_popup_rect(self.id.0, rect),
            _gfx: gfx,
        });
        Ok(())
    }

    /// The reply of the prompt, once it has ended. It closes the window and frees its GPU
    /// resources. It gives `None` while the prompt still shows, and when no prompt shows.
    pub fn take_reply(&mut self) -> Option<Reply> {
        let reply = self.active.as_ref()?.window.state().reply()?;
        self.close();
        Some(reply)
    }

    /// Close the prompt that shows, without a reply.
    pub fn close(&mut self) {
        self.active = None;
    }

    /// Show the prompt and wait until it ends, with a message loop of its own. It is for an app
    /// without an [`EventLoop`](crate::events::EventLoop); with one, use
    /// [`ChoicePopup::show`]. If the thread gets `WM_QUIT`, the popup closes, posts `WM_QUIT`
    /// again for the caller, and gives [`Response::Quit`].
    pub fn ask(&mut self, request: &Prompt) -> Result<Reply> {
        self.show(request)?;
        loop {
            if let Some(reply) = self.take_reply() {
                return Ok(reply);
            }
            match self.ui.pump_wait() {
                Ok(true) => {}
                Ok(false) => {
                    let at = SystemTime::now();
                    self.close();
                    return Ok(Reply {
                        response: Response::Quit,
                        at,
                    });
                }
                Err(err) => {
                    self.close();
                    return Err(err);
                }
            }
        }
    }
}

impl Drop for ChoicePopup {
    fn drop(&mut self) {
        self.close();
    }
}

fn primary_monitor_scale() -> f32 {
    crate::primary_monitor_dpi() as f32 / 96.0
}

/// Create the popup window at `placement` in the work area, with its DWM attributes.
fn create_window(
    hinstance: HINSTANCE,
    layout: &layout::Layout,
    placement: Placement,
    scale: f32,
    theme: &Theme,
    others: &[Rect],
) -> Result<(HWND, Rect)> {
    unsafe {
        let mut work = RECT::default();
        SystemParametersInfoW(
            SPI_GETWORKAREA,
            0,
            Some(&mut work as *mut _ as *mut c_void),
            SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
        )
        .call("SystemParametersInfoW(SPI_GETWORKAREA)")?;
        let margin = (SCREEN_MARGIN * scale).round() as i32;
        let (win_w, win_h) = (layout.width as i32, layout.height.ceil() as i32);
        let (x, y) = placement.place(
            Area::from((work.left, work.top, work.right, work.bottom)),
            win_w,
            win_h,
            margin,
        );
        let rect = placement::stack(
            Rect {
                x,
                y,
                width: win_w,
                height: win_h,
            },
            others,
            placement.y() > 0.5,
            (STACK_GAP * scale).round() as i32,
            work.top,
            work.bottom,
        );
        let (x, y) = (rect.x, rect.y);
        // A popup with an input box must take the keyboard when the user clicks it.
        let mut ex_style = WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP;
        if layout.input.is_none() {
            ex_style |= WS_EX_NOACTIVATE;
        }
        let hwnd = CreateWindowExW(
            ex_style,
            CLASS_NAME,
            CLASS_NAME,
            WS_POPUP,
            x,
            y,
            win_w,
            win_h,
            None,
            None,
            Some(hinstance),
            None,
        )
        .call("CreateWindowExW")?;

        native::set_dwm_attribute(
            hwnd,
            DWMWA_WINDOW_CORNER_PREFERENCE,
            DWMWCP_DONOTROUND.0 as u32,
        );
        native::set_dwm_attribute(hwnd, DWMWA_USE_HOSTBACKDROPBRUSH, 1);
        native::set_dwm_attribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, u32::from(theme.dark));
        Ok((hwnd, rect))
    }
}