M10: Windows: the window no longer draws below a hidden caption (#1343)

FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them WS_CAPTION | WS_BORDER. On some systems the desktop compositor reserves that caption, so the content is drawn about 30 px low (a black band above it) while clicks keep the client coordinates and land above what is drawn. Fixes #1343; the same report as #1422 and #2233.

A per-frame step (apps/figcraft/src/window_frame.rs, run from raw_input_hook) clears WS_CAPTION and WS_BORDER from every winit window of the process (the main window and the other windows alike), keeps WS_THICKFRAME and the window buttons so edge resizing and snap work, and calls SetWindowPos(SWP_FRAMECHANGED) only when a style changed. It uses safe winsafe calls (no unsafe), Windows only; other platforms and the browser are unchanged. The maximize-to-work-area trim of DesignCraft's fix is left for a later change.

Ports the fix of DesignCraft #2139 and PhotoCraft #2401. Tests: the style arithmetic (caption and border cleared, thick frame and buttons kept, a clean style unchanged) and the per-frame hook (runs every frame, a no-op when unset).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-10 20:55:23 -05:00
1 parent be991533b0
commit 97ceeca35e
5 files changed
+193 -1

No files matched your search

Generated
+1
View File
@@ -1810,6 +1810,7 @@ dependencies = [
"windows",
"winit",
"winresource",
"winsafe",
"zbus",
]
+3
View File
@@ -37,6 +37,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The custom title bar's window styles (src/window_frame.rs): safe Win32 wrappers (MIT); the `user`
# feature is User32.dll only. Same version as DesignCraft and PhotoCraft.
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
+10
View File
@@ -29,6 +29,7 @@ mod graphics;
mod recovery;
mod services;
mod startup;
mod window_frame;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
@@ -63,6 +64,8 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Keeps the custom title bar's undecorated windows borderless (Windows, #1343).
window_frame: window_frame::WindowFrame,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
@@ -111,6 +114,9 @@ impl eframe::App for App {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// winit gives an undecorated window its caption back when it changes the window's flags
// (maximize, restore, ...): take it off again before the frame is laid out (#1343).
self.window_frame.run();
// Pointer and key events sent over the control channel (`ui.input`), for the window
// about to draw.
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
@@ -268,6 +274,7 @@ fn main() -> eframe::Result {
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
let custom_titlebar = fig.ui.title_bar == figcraft_ui_egui::TitleBar::Custom;
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
@@ -304,6 +311,9 @@ fn main() -> eframe::Result {
ctx_slot,
title: String::new(),
recovery,
// The custom title bar's undecorated window must stay borderless, or its caption
// and border offset the content and the pointer on some Windows systems (#1343).
window_frame: window_frame::WindowFrame::new(custom_titlebar),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
}))
+177
View File
@@ -0,0 +1,177 @@
//! Keeps the custom (undecorated) title bar windows truly borderless on Windows (#1343).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
//! desktop compositor still reserves that frame for the window surface: the content is drawn about
//! 30 px low (a black band above it) while the pointer keeps the un-offset client coordinates, so
//! clicks land on something other than what is drawn under them.
//!
//! winit 0.27 omitted those styles for undecorated windows and had no offset. [`WindowFrame`]
//! restores that: it strips `WS_CAPTION` and `WS_BORDER` from every window of this process that
//! draws its own title bar, and has Windows recalculate the frame. winit puts the styles back
//! whenever it changes the window flags (maximize, restore, ...), so the step runs every frame; it
//! reads each style and writes only when it differs, so it costs next to nothing.
//!
//! The same fix is in DesignCraft (#2139) and PhotoCraft (#2401). The long-term fix is in winit
//! (storytold/winit#17); this module goes when the pinned winit has it.
//!
//! The Windows calls go through safe `winsafe` wrappers (the workspace forbids `unsafe`). Which
//! windows are ours is found by enumerating this process's top-level windows of winit's window
//! class, so the main window and every other window opened later are covered alike, and a file
//! dialog or message box of the same process is left alone.
/// `WS_BORDER`.
#[cfg(any(windows, test))]
const WS_BORDER: u32 = 0x0080_0000;
/// `WS_CAPTION`, which already includes `WS_BORDER`.
#[cfg(any(windows, test))]
const WS_CAPTION: u32 = 0x00C0_0000;
/// `style` without the OS title bar and frame (`WS_CAPTION`, `WS_BORDER`). The sizing border
/// (`WS_THICKFRAME`) and the window buttons stay, so edge resizing, Aero snap and the system menu
/// keep working, as they did with winit 0.27's undecorated windows.
#[cfg(any(windows, test))]
pub fn borderless_style(style: u32) -> u32 {
style & !(WS_CAPTION | WS_BORDER)
}
/// The per-frame step that keeps the windows borderless; a no-op where the OS draws the frame
/// (macOS, Linux, the BSDs), where the app uses the system title bar, and in the browser.
pub struct WindowFrame {
step: Option<Box<dyn FnMut()>>,
}
impl WindowFrame {
/// The step for an app whose windows draw their own title bar when `custom_titlebar`.
pub fn new(custom_titlebar: bool) -> Self {
Self { step: platform::step(custom_titlebar) }
}
/// Run the step; call it once per frame, before the frame is laid out.
pub fn run(&mut self) {
if let Some(step) = self.step.as_mut() {
step();
}
}
#[cfg(test)]
fn with_step(step: Box<dyn FnMut()>) -> Self {
Self { step: Some(step) }
}
}
#[cfg(windows)]
mod platform {
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// winit's window class (`WindowAttributes`' default `class_name`).
const WINIT_CLASS: &str = "Window Class";
/// A step that strips the frame styles from this process's windows each call, or `None` when
/// the app uses the system title bar (the windows are decorated).
pub fn step(custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
if !custom_titlebar {
return None;
}
let pid = std::process::id();
Some(Box::new(move || {
// The windows are handed over as real `HWND`s; nothing is wrapped from a raw handle.
let _ = winsafe::EnumWindows(|hwnd| {
strip_if_ours(&hwnd, pid);
true
});
}))
}
/// Strip `hwnd` when it is one of this process's winit windows.
fn strip_if_ours(hwnd: &HWND, pid: u32) {
if hwnd.GetWindowThreadProcessId().1 != pid || !matches!(hwnd.GetClassName().as_deref(), Ok(WINIT_CLASS)) {
return;
}
strip(hwnd);
}
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame; a no-op once they
/// are gone.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
// The decision is the tested arithmetic; the write uses the same bits as winsafe's flags.
if super::borderless_style(style.raw()) == style.raw() {
return;
}
hwnd.set_style(style & !(co::WS::CAPTION | co::WS::BORDER));
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
}
#[cfg(not(windows))]
mod platform {
pub fn step(_custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
None
}
}
#[cfg(test)]
mod window_frame_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{WS_BORDER, WS_CAPTION, WindowFrame, borderless_style};
#[cfg(windows)]
#[test]
fn the_style_bits_match_winsafes() {
use winsafe::co;
assert_eq!(co::WS::CAPTION.raw(), WS_CAPTION);
assert_eq!(co::WS::BORDER.raw(), WS_BORDER);
}
const WS_SYSMENU: u32 = 0x0008_0000;
const WS_THICKFRAME: u32 = 0x0004_0000;
const WS_MINIMIZEBOX: u32 = 0x0002_0000;
const WS_MAXIMIZEBOX: u32 = 0x0001_0000;
const WS_VISIBLE: u32 = 0x1000_0000;
#[test]
fn the_borderless_style_drops_the_caption_and_border_only() {
let decorated = WS_CAPTION | WS_BORDER | WS_SYSMENU | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
let bare = borderless_style(decorated);
assert_eq!(bare & WS_CAPTION, 0, "caption gone");
assert_eq!(bare & WS_BORDER, 0, "border gone");
assert_eq!(bare & WS_THICKFRAME, WS_THICKFRAME, "edge resizing kept");
assert_eq!(bare & (WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU), WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU, "window buttons kept");
assert_eq!(bare & WS_VISIBLE, WS_VISIBLE, "unrelated bits kept");
}
#[test]
fn an_already_borderless_style_is_unchanged_so_no_frame_change_is_made() {
let bare = WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
assert_eq!(borderless_style(bare), bare);
assert_eq!(borderless_style(borderless_style(bare | WS_CAPTION)), bare);
}
#[test]
fn the_step_runs_each_time_the_frame_runs_it() {
let calls = Arc::new(AtomicUsize::new(0));
let n = calls.clone();
let mut frame = WindowFrame::with_step(Box::new(move || {
n.fetch_add(1, Ordering::SeqCst);
}));
for _ in 0..3 {
frame.run();
}
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn no_step_is_fine() {
// Decorated windows and other platforms have no step; running must do nothing.
let mut frame = WindowFrame::new(false);
frame.run();
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (Windows: the custom title bar's windows stay borderless, so the content is no longer drawn about 30 px low with clicks landing above it (#1343); under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | patch | M10, #1343 (also #1422, #2233): Windows: the window no longer draws below a hidden caption. FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them `WS_CAPTION | WS_BORDER`; on some systems the desktop compositor reserves that caption, so the content was drawn about 30 px low (a black band above it) while clicks kept the client coordinates and landed above what was drawn. A per-frame step (`apps/figcraft/src/window_frame.rs`, safe `winsafe` calls, Windows only) clears `WS_CAPTION` and `WS_BORDER` from every FigCraft window, the main one and the others, keeping the sizing border and window buttons so edge resizing and snap work, and writes only when a style changed (winit puts them back on maximize and restore). The same fix as DesignCraft #2139 and PhotoCraft #2401; the maximize-to-work-area trim from DesignCraft is left for a later change. Not run on a Windows machine that shows the offset: the style math and the per-frame hook are tested, the window itself needs a live check. Tests: `window_frame_tests`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
| 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged |