Skip to content

Title-Bar Drag and Double-Click Gestures

desktop-apps specs/desktop-apps/title-bar-double-click.kmd

Como apps desktop Koder devem tratar arraste e duplo-clique na barra de título. Define o contrato cross-platform (Linux/Windows/macOS) e proíbe que o duplo-clique num botão dispare o maximize da janela. Implementação go-forward é **Kroma** (`window_titlebar` + o tier `WindowRequest`); os widgets Flutter do `engines/sdk/koder_kit` (`KoderTitleBar` + `KoderTitleBarFreeArea`) são **legado** (`stack-RFC-019`).

When this spec applies

All triggers

Specification body

Spec — Title-Bar Drag and Double-Click Gestures

Applicability

Toda variante desktop (Linux / Windows / macOS) de qualquer produto Koder que renderiza sua própria barra de título — kruze, kterm, hub desktop, kortex, mosaic, talk, drive, eye, koru desktop, sky, e adjacentes. Não se aplica a variantes mobile, TV, web ou CLI.

Vale independentemente do framework: o Required Behavior abaixo é o contrato, e tanto Kroma (go-forward) quanto Flutter (legado) têm de honrá-lo. Um app que abre a janela borderless — o caso normal de quem desenha a própria chrome — está no escopo por construção: ao desligar a decoração do compositor, ele assume a obrigação de fornecer arraste e maximizar que o usuário perdeu.

Required Behavior

A barra de título de uma janela Koder DEVE responder a:

  1. Arraste (pan) em qualquer ponto da barra que não seja capturado por um widget interativo (botão, dropdown, controle de janela, célula de tab) — move a janela.
  2. Duplo-clique em uma área livre explicitamente declarada (espaço vazio decorativo, texto de título, gap entre tab strip e controles) — alterna entre maximizado e restaurado.
  3. Duplo-clique em um widget interativo (botão fechar X da aba, botão + nova aba, dropdown , controle de janela minimize/maximize/close, qualquer botão custom) NÃO dispara o toggle de maximize. O widget interativo trata o duplo-clique conforme sua semântica própria (geralmente: dispara onTap, ou um onDoubleTap distinto declarado pelo autor).

Implementation (Kroma — go-forward · Flutter legacy)

A implementação go-forward é Kroma (Flutter em sunset, stack-RFC-019): toda barra de título nova nasce em Kroma. A seção Flutter mais abaixo é legado, mantida só para os apps que ainda não migraram.

Go-forward (Kroma)

O Required Behavior acima é atendido por três peças do engines/sdk/kroma, e as três já existem — não reimplementar regra de gesto por app (reuse-first.kmd):

  1. Janela sem decoração. run_app_with_options(app, title, app_id, WindowOptions { decorations: false }) (app_runner.rs). Um app que desenha a própria chrome DEVE abrir borderless por aqui; decorations: true é o default, então nenhum app existente muda de comportamento.

  2. Classificação do gesto — a metade pura, headless-testável (window_titlebar.rs):

    let zone   = titlebar_zone(x, bar_w, cell_h, account);  // -> TitleZone
    let action = title_action(zone, is_double_click);       // -> TitleAction
    

    titlebar_zone responde "que zona da barra é este x"; title_action aplica a regra de no-hijack desta spec. Nenhuma das duas toca o windowing, então ambas são testáveis sem abrir janela.

  3. Execução no tier de janela. TitleAction::window_request() -> Option<WindowRequest> converte a ação de barra na op de janela; o runner a aplica em about_to_wait (set_maximized / set_minimized / drag_window / exit). Na camada declarativa, os controles são nós window_button(key, label, WindowRequest::…) — o on_press comum é Fn(&mut State) e não alcança a janela, por isso os controles de janela têm nó próprio.

Por que o hijack do duplo-clique é estruturalmente impossível em Kroma (e não apenas evitado por disciplina, como em Flutter): não existe gesture arena. Não há despacho ambiente que possa entregar o evento a um ancestral — title_action resolve a zona primeiro, e uma zona de controle retorna a ação daquele controle independentemente de is_double_click. O duplo-clique de maximizar só é alcançável a partir de TitleZone::FreeArea. A regra 3 do Required Behavior é, em Kroma, uma propriedade do código, não uma convenção que o autor precisa lembrar de honrar.

TitleAction é deliberadamente de tier misto — carrega ops de janela (Drag/ToggleMaximize/Close) e ações de domínio do kterm (NewTab/SplitRight/ SplitDown/Account). Por isso window_request() devolve None para as últimas: uma ação de domínio nunca deve escorrer para o tier de janela (senão o usuário pede um pane e a janela redimensiona). Não colapsar os dois enums num só.

Gap conhecido — cluster de janela incompleto. TitleZone hoje tem Maximize e Close, mas não tem Minimize (a barra do kterm nunca precisou dele). O tier de janela já suporta a op (WindowRequest::Minimize existe e o runner a aplica), então o que falta é só a metade zona/paint. Um app que precise do cluster completo minimize/maximize/close DEVE estender titlebar_zone (e o paint correspondente) em vez de desenhar um botão de minimizar por fora da classificação — um controle que o classificador não conhece cai em FreeArea e o duplo-clique nele vira maximize, que é exatamente o bug que esta spec proíbe.

Legacy (Flutter, migrando) — engines/sdk/koder_kit

⚠️ Flutter está em sunset Stack-wide (stack-RFC-019) — não iniciar barra de título nova aqui. Esta seção descreve o mapeamento Flutter do mesmo contrato, para os apps ainda não migrados:

  • KoderTitleBar({child, height, color}) — wrapper externo da barra de título inteira. Declara apenas o gesto de arraste (onPanStart: (_) => windowManager.startDragging()). Não declara onDoubleTap — o gesto de toggle vive em KoderTitleBarFreeArea.

  • KoderTitleBarFreeArea({child}) — wrapper que marca uma região como área livre. Declara onDoubleTap e dispara windowManager.maximize() / unmaximize() conforme estado atual. Use behavior: HitTestBehavior.opaque. Posicione em Expanded(SizedBox.expand()) para o gap entre tab strip e controles, ou em volta de texto de título decorativo.

Layout canônico (kruze, kterm, hub):

KoderTitleBar(
  child: Row(
    children: [
      // Tab strip + botões internos (sized to content).
      Flexible(child: ReorderableListView.builder(...)),
      // Área livre — duplo-clique aqui alterna maximize/restore.
      Expanded(
        child: KoderTitleBarFreeArea(child: SizedBox.expand()),
      ),
      // Lado direito — dropdowns + window controls.
      DropdownAllTabs(),
      WindowControl(min),
      WindowControl(max),
      WindowControl(close),
    ],
  ),
)

Forbidden Patterns

Kroma

Não desenhar um controle de janela que titlebar_zone não classifica. O que o classificador não conhece é FreeArea, e um duplo-clique ali maximiza a janela — o bug desta spec, reintroduzido por fora. Estender a classificação, nunca contorná-la.

Não ligar um controle da barra de título aos requests de tile (ctx.request_maximize() / ctx.request_close(), do #090): esses agem sobre um pane dentro da janela. Um botão de fechar da barra ligado ao tier de tile mata um pane; um close de pane ligado ao tier de janela mata a janela. Os dois tiers são vizinhos de nome e distantes de significado — usar WindowRequest para a janela.

Não reimplementar titlebar_zone/title_action no app. A regra de no-hijack é uma só; uma segunda interpretação da mesma spec é como as duas divergem (reuse-first.kmd).

Flutter (legado)

Os anti-patterns abaixo são específicos da gesture arena do Flutter — o mecanismo que entrega o gesto a um ancestral mesmo quando o filho foi tocado. Kroma não tem esse mecanismo, então esta subseção não se aplica a apps Kroma.

Não envolver a barra inteira em um GestureDetector com onDoubleTap + onPanStart:

// ANTI-PATTERN — Flutter gesture arena entrega o duplo-clique pro
// detector externo mesmo quando o usuário clica num botão filho.
// Usuário double-clica em "X" → janela maximiza em vez de fechar tab.
GestureDetector(
  onDoubleTap: _toggleMaximize,
  onPanStart: (_) => windowManager.startDragging(),
  child: TitleBarRow(...),
)

Não declarar onDoubleTap no KoderTitleBar e em widgets filhos sem coordenar — gesture arena hijack reaparece.

Não usar Caddy DragToMoveArea ou outras alternativas de window_manager que envolvem toda a área — perdem o split free-area / interactive.

Validation

Esta spec é enforcement-checked via testes estruturais.

Kroma (go-forward)

A conformidade é comportamental, não estrutural — a metade pura é headless, então as regras são testadas de verdade, sem grep e sem abrir janela:

  • engines/sdk/kroma/src/window_titlebar.rswindow_titlebar::tests::gesture_rules_follow_the_spec_no_hijack — exercita as três regras do Required Behavior, incluindo o caso que dá nome à spec: duplo-clique em cima de um controle roda aquele controle, nunca o toggle de maximize.
  • window_titlebar::tests::zones_map_the_cell_h_button_bands — a classificação de zona bate com o que é pintado (hit-test e paint concordam).
  • window_titlebar::tests::title_actions_map_to_the_window_tier_and_domain_actions_do_not — a costura TitleAction::window_request(): as três ações de janela mapeiam, e as de domínio do kterm mapeiam para None (a proibição de vazamento de tier acima).
  • kroma::widget::button::tests::a_window_request_surfaces_through_the_dispatcher_and_not_as_a_tile_request — fixa a separação window-tier × tile-tier nas duas direções.

Flutter (legado)

  • engines/sdk/koder_kit/tests/regression/<NNN>-title-bar-widgets.test.sh — verifica que KoderTitleBar e KoderTitleBarFreeArea existem, que KoderTitleBar NÃO declara onDoubleTap, e que KoderTitleBarFreeArea chama windowManager.maximize/unmaximize.
  • Per-product structural tests no tests/regression/ de cada módulo desktop, verificando que o pattern legacy (GestureDetector(onDoubleTap:...) em volta da barra) não voltou.

Casos históricos consertados (registry regression-test-cases.md):

  • kterm v1.3.2 — close button da aba e botão close do Preferences dialog (cases #438, #439).
  • kruze 1.0.14 — botão X close de aba e botão + nova aba (caso #459 e seguintes; este commit).
  • specs/desktop-apps/title-bar.kmd — formato do nome do produto na barra de título (texto, não gesto).
  • specs/koder-app/behaviors.kmd — comportamentos cross-cutting de apps Koder (auth, telemetria, update etc.).
  • policies/reuse-first.kmd — sempre usar o SDK (Kroma window_titlebar; em legado, koder_kit) em vez de reimplementar gestos por app.
  • rfcs/stack-RFC-019-koder-ui-render-strategy.kmd — por que Kroma é go-forward e Flutter é legado.
  • specs/develop/pointer-interaction-tdds.kmd — TDDs de interação de ponteiro para surfaces desktop (a barra de título é uma delas).

References