Frameless window and menu

Frameless window and menu

A frameless window has no OS titlebar, so the app draws its own. Done carelessly this is how an app ends up looking like a web page in a box. Done properly the window controls keep the shape the platform uses, and the result reads as native.

The native side

import pekoui as ui;

fn on_start() {
    let app: ui::app::App = ui::app::from_bundle();

    let view: ui::webview::WebView = app.webview();
    view.set_decorations(false);
    view.set_custom_controls(true);

    app.use_html_menu();

    app.run();
}

set_decorations(false) removes the OS frame. set_custom_controls(true) hides the macOS traffic lights so your own sit where they belong instead of overlapping Apple's.

use_html_menu() suppresses the native menu bar. Without it you get a real menu bar on macOS and Linux but none on Windows, where a frameless window has no place to put one. Suppressing it everywhere means one definition and one appearance on all three.

Window controls that match the platform

<Toolbar> will draw generic buttons for you, which is the one-line option. For an app that should feel native, turn them off with controls={false} and draw the shape each OS actually uses:

import { peko, usePlatform } from '@peko/client/react'

export function WindowButtons() {
  const platform = usePlatform()
  // Nothing to draw on a decorated window or on mobile.
  if (!platform.windowControls) return null

  if (platform.os === 'macos') {
    return (
      <div className="traffic-lights" data-peko-no-drag>
        <button className="tl tl-close" onClick={() => peko.window.close()} aria-label="Close" />
        <button className="tl tl-min" onClick={() => peko.window.minimize()} aria-label="Minimize" />
        <button className="tl tl-max" onClick={() => peko.window.maximize()} aria-label="Zoom" />
      </div>
    )
  }

  const os = platform.os === 'linux' ? 'linux' : 'windows'
  return (
    <div className={`window-controls os-${os}`} data-peko-no-drag>
      <button className="wc wc-min" onClick={() => peko.window.minimize()} aria-label="Minimize" />
      <button className="wc wc-max" onClick={() => peko.window.maximize()} aria-label="Maximize" />
      <button className="wc wc-close" onClick={() => peko.window.close()} aria-label="Close" />
    </div>
  )
}

Three things make this read as native rather than as chrome bolted onto a web view:

  • Order and side. macOS puts close, minimize, zoom at the leading edge. Windows and Linux put minimize, maximize, close at the trailing edge.
  • Shape. Round traffic lights on macOS with the glyphs revealed on hover of the group. Square edge-to-edge buttons on Windows with a red close. Round symbolic buttons on Linux.
  • No background on the bar itself. The titlebar carries no fill and no border, so the window background runs straight through it and the bar is part of the app rather than a strip above it.

data-peko-no-drag keeps a click on a button from being read as a window drag.

The menu

import { Menu, Toolbar, usePekoEvent } from '@peko/client/react'
import type { PekoMenuTop } from '@peko/client'

// Defined outside the component so the reference is stable. Entries carry an
// action id rather than a callback, so the same handler serves this HTML menu
// and a native menu bar, and the definition holds no state.
const MENU: PekoMenuTop[] = [
  {
    label: 'Counter',
    items: [
      { label: 'Increment', action: 'counter.increment', accelerator: 'CmdOrCtrl+Up' },
      { label: 'Decrement', action: 'counter.decrement', accelerator: 'CmdOrCtrl+Down' },
      { separator: true },
      { label: 'Reset', action: 'counter.reset', accelerator: 'CmdOrCtrl+0' },
    ],
  },
]

Put it in the toolbar, and handle every choice in one place:

usePekoEvent('menu', (data) => {
  const id = (data as { id?: string }).id
  if (id === 'counter.increment') setCount((v) => (v ?? 0) + 1)
  if (id === 'counter.decrement') setCount((v) => (v ?? 0) - 1)
  if (id === 'counter.reset') setCount(0)
})

return (
  <>
    <Toolbar className="titlebar" controls={false}>
      <WindowButtons />
      <Menu items={MENU} />
      <span className="titlebar-title">Counter</span>
    </Toolbar>
    <main className="counter">{/* ... */}</main>
  </>
)

Toolbar is the drag region. Menu renders only where there is no native menu bar, so the same code is correct whether or not you called use_html_menu().

Both the HTML menu and a native menu bar deliver the same menu event carrying the action id, which is why the entries hold ids instead of callbacks. Switching to a real macOS menu bar later means deleting use_html_menu() and calling set_menu with the same ids. The React handler does not change.

The updates are functional (setCount((v) => ...)) so the menu handler never reads a stale count.

Styling is ordinary CSS and is left out here; the sample project has it.