Window | CoUI
LogoCoUI

Window

드래그 · 리사이즈 · 스냅을 지원하는 다중 창 매니저

Window#

WindowWindowNavigator가 함께 데스크톱형 창 매니저를 만듭니다. 타이틀 바로 드래그하고, 모든 변·모서리에서 크기를 바꾸며, 화면 가장자리에 스냅합니다. 최소화 / 최대화 / 항상 위 레이어도 지원합니다. Flutter와 Web의 API와 동작은 같습니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • IDE·대시보드·도구 팔레트처럼 여러 창을 동시에 띄우고 옮길 때
  • 창마다 드래그 · 리사이즈 · 가장자리 스냅이 필요할 때
  • 최소화 / 최대화 / 항상 위 레이어가 필요할 때

대신 다른 컴포넌트를 사용하세요:

  • Dialog: 한 번의 확인이 필요한 모달
  • Drawer: 화면 가장자리에서 밀어 여는 패널
  • WindowPanel: 창 매니저 없이 타이틀 바 chrome만 필요할 때

Import#

import 'package:coui_flutter/coui_flutter.dart';
import 'package:coui_web/coui_web.dart';
import 'package:jaspr/jaspr.dart';

기본 사용법 (Basic Usage)#

WindowNavigator(
  initialWindows: [
    Window(
      bounds: const CoreWindowRect.fromLTWH(0, 0, 240, 180),
      title: const Text('Window 1'),
      content: const Text('Content'),
    ),
  ],
  child: const Center(child: Text('Desktop')),
)

빠른 오버라이드 (Chain)#

이미 만든 Window 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class WindowChainExample extends StatefulWidget {
  const WindowChainExample({super.key});

  @override
  State<WindowChainExample> createState() => _WindowChainExampleState();
}

class _WindowChainExampleState extends State<WindowChainExample> {
  final GlobalKey<WindowNavigatorHandle> _navigatorKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        OutlinedContainer(
          containerStyle: const CoreOutlinedContainerStyle(height: 600),
          child: WindowNavigator(
            key: _navigatorKey,
            initialWindows: [
              Window(
                bounds: const CoreWindowRect.fromLTWH(0, 0, 200, 200),
                title: const Text('Window 1'),
                content: const Center(child: Text('Window 1')),
              ).withStyle(
                const CoreWindowStyle(
                  titleBarHeight: CoreSpace.space48,
                  resizeThickness: CoreSpace.space8,
                  unfocusedTitleStyle: CoreTextStyle(
                    color: CoreColor.token(CoreColors.outline),
                  ),
                  panelStyle: CoreWindowPanelStyle(
                    borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
                    borderColor: CoreColor.token(CoreColors.primary),
                    containerBorderWidth: CoreStrokeWidth.stroke2,
                  ),
                ),
              ),
              Window(
                bounds: const CoreWindowRect.fromLTWH(200, 0, 200, 200),
                title: const Text('Window 2'),
                content: const Center(child: Text('Window 2')),
              ),
            ],
            child: const Center(child: Text('Desktop')),
          ),
        ),
        const Gap.space8(),
        Button(
          onPressed: () {
            final nav = _navigatorKey.currentState;
            if (nav == null) return;
            final index = nav.windows.length + 1;
            nav.pushWindow(
              Window(
                bounds: const CoreWindowRect.fromLTWH(0, 0, 200, 200),
                title: Text('Window $index'),
                content: Center(child: Text('Window $index')),
              ),
            );
          },
          child: const Text('Add Window'),
        ),
      ],
    );
  }
}
class WindowChainExample extends StatefulComponent {
  const WindowChainExample({super.key});

  @override
  State<WindowChainExample> createState() => _WindowChainExampleState();
}

class _WindowChainExampleState extends State<WindowChainExample> {
  final GlobalKey _navigatorKey = GlobalKey();

  @override
  Component build(BuildContext context) {
    return div(
      [
        OutlinedContainer(
          containerStyle: const CoreOutlinedContainerStyle(height: 600),
          child: WindowNavigator(
            key: _navigatorKey,
            initialWindows: [
              Window(
                bounds: const CoreWindowRect.fromLTWH(0, 0, 200, 200),
                title: const Text('Window 1'),
                content: div(
                  [const Text('Window 1')],
                  classes: 'flex items-center justify-center w-full h-full',
                ),
              ).withStyle(
                const CoreWindowStyle(
                  titleBarHeight: CoreSpace.space48,
                  resizeThickness: CoreSpace.space8,
                  unfocusedTitleStyle: CoreTextStyle(
                    color: CoreColor.token(CoreColors.outline),
                  ),
                  panelStyle: CoreWindowPanelStyle(
                    borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
                    borderColor: CoreColor.token(CoreColors.primary),
                    containerBorderWidth: CoreStrokeWidth.stroke2,
                  ),
                ),
              ),
              Window(
                bounds: const CoreWindowRect.fromLTWH(200, 0, 200, 200),
                title: const Text('Window 2'),
                content: div(
                  [const Text('Window 2')],
                  classes: 'flex items-center justify-center w-full h-full',
                ),
              ),
            ],
            child: div(
              [const Text('Desktop')],
              classes: 'flex items-center justify-center w-full h-full',
            ),
          ),
        ),
        Gap.space8(),
        Button(
          onPressed: () {
            final nav = _navigatorKey.currentWindowNavigator;
            if (nav == null) return;
            final index = nav.windows.length + 1;
            nav.pushWindow(
              Window(
                bounds: const CoreWindowRect.fromLTWH(0, 0, 200, 200),
                title: Text('Window $index'),
                content: div(
                  [Text('Window $index')],
                  classes: 'flex items-center justify-center w-full h-full',
                ),
              ),
            );
          },
          child: const Text('Add Window'),
        ),
      ],
      classes: 'flex flex-col w-full',
    );
  }
}

Props / Parameters#

여기에는 위젯 생성자 파라미터만 적습니다. 프레임 chrome 은 windowStyle.panelStyle 중첩 슬롯으로 흐릅니다.

Window#

속성타입기본값설명
title Widget? / Component? null 타이틀 바 안의 제목 슬롯
actions Widget? / Component? 기본 액션 최소화/최대화/닫기 행을 덮어씀
content Widget? / Component? null 본문 영역
controller CoreWindowController? null 외부 반응형 상태 컨트롤러
bounds CoreWindowRect? 필수 (Window.controlled는 없음 — 컨트롤러가 소유) 초기 플로팅 영역
maximized CoreWindowRect? null 상대 좌표(0..1) 최대화 영역
minimized bool? false 처음에 최소화된 상태로 시작할지
alwaysOnTop bool? false 항상 위 레이어에 고정
enableSnapping bool? true 가장자리 드래그 스냅
resizable bool? true 8방향 변/모서리 리사이즈 핸들
draggable bool? true 타이틀 바 드래그
closablebool?true닫기 컨트롤
maximizable bool? true 최대화 컨트롤
minimizable bool? true 최소화 컨트롤
constraints CoreWindowConstraints? kCoreDefaultWindowConstraints (최소 200×200) 리사이즈 중 적용되는 최소/최대 크기
windowStyle CoreWindowStyle? null chrome 단일 진입점 — 타이틀 바 높이, 리사이즈 두께, 스냅 바/미리보기 chrome, 창 전환, 중첩 panelStyle ( CoreWindowPanelStyle )

WindowNavigator#

창 스택을 소유하는 호스트입니다. 생성자 파라미터는 위 Window와 별개입니다.

속성타입기본값설명
initialWindows List<Window> 필수 마운트 시 그릴 창 목록
child Widget? / Component? null 배경(데스크톱) 콘텐츠
showTopSnapBar bool true 프리셋 타일 스냅 바를 그릴지

프로그래밍 제어#

final controller = CoreWindowController(
  bounds: CoreWindowRect.fromLTWH(0, 0, 320, 240),
);
controller.bounds = CoreWindowRect.fromLTWH(100, 100, 320, 240);
controller.maximized = const CoreWindowRect.fromLTWH(0, 0, 1, 1);
controller.minimized = true;

스타일 시스템 — windowStyle#

모든 chrome / 치수 오버라이드는 단일 windowStyle 슬롯(프로젝트 전역은 CoreWindowTheme.style)으로 흐릅니다. 패널 프레임 자체는 합성된 WindowPanel이라, 그 chrome은 중첩 panelStyle로 갑니다.

CoreWindowStyle 필드#

필드타입설명
titleBarHeight double? Title bar height in logical pixels.
resizeThickness double? Thickness of each of the 8 resize hit regions in logical pixels.
snapPreviewBorderRadius CoreBorderRadius? Border radius for the floating snap-preview overlay (semi-transparent surface shown while dragging a window into a snap zone). Overrides [defaultSnapPreviewBorderRadius] when set.
snapBarBorderRadius CoreBorderRadius? Border radius for the snap-bar banner container (the rounded strip that drops down from the navigator top while a window is being dragged). Overrides [defaultSnapBarBorderRadius] when set.
snapBarTileSpacing double? Spacing between adjacent tiles inside the snap-bar grid in logical pixels. Flutter applies it as native Row.spacing ; Web emits the matching CSS gap value on the flex container. Overrides [defaultSnapBarTileSpacing] when set.
snapBarPadding CoreEdgeInsets? Inner padding of the snap-bar banner / snap-preview container. Overrides [defaultSnapBarPadding] when set.
snapPreviewBorderColor CoreColor? Outline colour of the snap-preview overlay border. Overrides [defaultSnapPreviewBorderColor] when set.
snapPreviewBackgroundColor CoreColor? Semi-transparent surface fill of the snap-preview overlay. Overrides [defaultSnapPreviewBackgroundColor] when set.
snapBarBorderColor CoreColor? Outline colour of the snap-bar banner container border. Overrides [defaultSnapBarBorderColor] when set.
snapBarBackgroundColor CoreColor? Surface fill of the snap-bar banner container. Overrides [defaultSnapBarBackgroundColor] when set.
snapBarBorderWidth double? Border width of the snap-bar banner container. Overrides [defaultSnapBarBorderWidth] when set.
snapBarHeight double? Height of the snap-bar banner (logical/CSS px). Overrides [defaultSnapBarHeight] when set.
snapBarTopOffset double? Top offset of the expanded snap-bar banner (logical/CSS px). Overrides [defaultSnapBarTopOffset] when set.
snapBarHiddenOffset double? Resting "peek" pose of the hidden snap-bar as a fraction of its height (−1 = fully hidden, 0 = fully shown); also the start of the content fade-in. Overrides [defaultSnapBarHiddenOffset] when set.
snapBarAnimation Duration? Snap-bar drop-down show/hide animation duration. Overrides [defaultSnapBarAnimation] when set.
snapTileSpacing double? Inner gap between abutting snap-bar tiles (logical px). Overrides [defaultSnapTileSpacing] when set.
windowTransition Duration? Window open / close / maximize transition duration. Overrides [defaultWindowTransition] when set.
minimizedScale double? Scale of the window while minified during a drag-snap. Overrides [defaultMinimizedScale] when set.
closeAnimationScaleMin double? Lower bound of the close-animation scale tween. Overrides [defaultCloseAnimationScaleMin] when set.
closeAnimationScaleMax double? Upper bound of the close-animation scale tween. Overrides [defaultCloseAnimationScaleMax] when set.
snapPreviewBlurSigma double? Gaussian σ of the snap-preview ghost's backdrop blur. Overrides [defaultSnapPreviewBlurSigma] when set.
snapTileBorderColor CoreColor? Outline colour of each snap-bar tile border. Overrides [defaultSnapTileBorderColor] when set.
snapTileBorderWidth double? Border width of each snap-bar tile in logical/CSS px. Overrides [defaultSnapTileBorderWidth] when set.
snapTileBackgroundColor CoreColor? Fill of a snap-bar tile at rest. Overrides [defaultSnapTileBackgroundColor] when set.
snapTileHoverBackgroundColor CoreColor? Fill of a snap-bar tile while hovered. Overrides [defaultSnapTileHoverBackgroundColor] when set.
unfocusedTitleStyle CoreTextStyle? Title-text overlay merged onto the panel's titleStyle for every window EXCEPT the frontmost one — both platforms dim unfocused titles with it (reference behaviour: focused = onSurface, rest = muted). Overrides [defaultUnfocusedTitleStyle] when set.
panelStyle CoreWindowPanelStyle? Chrome of the composed WindowPanel — nested slot raw-forwarded into the panel (child-component-composition). Merged over [defaultPanelStyle]; a FULL maximize forces the corner radius to zero on top of the merged result.

테마#

기본값은 CoreWindowTheme.style로 덮어씁니다.

const CoreComponentTheme(
  window: CoreWindowTheme(
    style: CoreWindowStyle(
      titleBarHeight: CoreSpace.space36,
    ),
  ),
);

리사이즈 hit 영역의 두께는 chrome 이 아니라 behaviour 입니다 (8 개 영역은 아무것도 그리지 않습니다) — Theme 이 아니라 위젯 파라미터로 조정합니다:

Window(
  bounds: bounds,
  resizeThickness: CoreSpace.space10,
);

사용 가이드라인 (Usage Guidelines)#

✅ Do#

프레임 chrome 은 중첩 슬롯 panelStyle 로 조정

Window(
  bounds: const CoreWindowRect.fromLTWH(0, 0, 320, 240),
  title: const Text('Editor'),
  windowStyle: CoreWindowStyle(
    panelStyle: CoreWindowPanelStyle(
      borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
      titleBarColor: CoreColor.token(CoreColors.surfaceContainerHigh),
    ),
  ),
)

Window 의 프레임 자체는 합성된 WindowPanel 이라, 모서리 반경 · 타이틀 바 배경 같은 프레임 chrome 은 windowStyle 최상위가 아니라 중첩 슬롯 windowStyle.panelStyle 로 흐릅니다.


❌ Don't#

CoreWindowStyle 최상위에 프레임 반경 필드가 있다고 가정하지 않기

// ❌ CoreWindowStyle 에는 프레임용 borderRadius 필드가 없다
Window(
  bounds: const CoreWindowRect.fromLTWH(0, 0, 320, 240),
  windowStyle: CoreWindowStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
  ),
)

snapPreviewBorderRadius / snapBarBorderRadius 는 각각 스냅 미리보기 고스트와 스냅 바 배너 전용입니다 — 실제 창 프레임 반경은 panelStyle 안에서만 조정됩니다.

✅ Do#

프로그래밍 방식 제어가 필요하면 Window.controlled 로 컨트롤러를 직접 소유

final controller = CoreWindowController(
  bounds: const CoreWindowRect.fromLTWH(0, 0, 320, 240),
);
Window.controlled(
  controller: controller,
  title: const Text('Editor'),
  content: const Text('Content'),
)
// 이후 controller.bounds = ... 로 위치/크기를 외부에서 갱신

창을 외부 상태(저장된 레이아웃 복원 등)로 옮기거나 크기를 바꿔야 한다면 Window.controlledCoreWindowController 를 직접 소유시키세요. 초기 위치는 bounds: 파라미터가 아니라 컨트롤러 생성 시 넘깁니다.


❌ Don't#

기본 생성자에 boundscontroller 를 동시에 넘기지 않기

// ❌ controller 가 주어지면 bounds 는 조용히 무시된다
Window(
  bounds: const CoreWindowRect.fromLTWH(0, 0, 320, 240),
  controller: controller,
  content: const Text('Content'),
)

기본 Window(...) 생성자에서도 controller 가 주어지면 bounds/closable/constraints 등 나머지 필드는 전부 무시되고 컨트롤러의 값만 쓰입니다 — 두 값을 동시에 주면 bounds 가 조용히 버려집니다.

접근성 (Accessibility)#

역할 / Semantics#

WindowWindowNavigator 자체는 아무 시맨틱도 내보내지 않습니다 — Flutter 쪽에 Semantics 호출이 하나도 없고, Web 쪽이 붙이는 속성은 data-co-window 류 데이터 속성뿐입니다.

윈도우의 접근성 정보는 전부 내부에 합성된 WindowPanel 에서 옵니다 — Web 은 role="dialog" + aria-label="window", Flutter 는 Semantics(container: true, label: 'window'), 그리고 타이틀 바의 컨트롤 버튼 세 개가 각각 라벨을 갖습니다. 스냅 바 · 스냅 타일 · 리사이즈 핸들 · 데스크탑 배경은 역할도 라벨도 없습니다.

키보드#

동작
Enter포커스된 타이틀 바 컨트롤(최소화 / 최대화 / 닫기) 활성화
Space포커스된 타이틀 바 컨트롤 활성화

window.dart 에는 양 플랫폼 모두 키 핸들러가 없습니다. 위 두 키는 타이틀 바의 Button 이 제공하는 것이고, 윈도우를 올리고 · 옮기고 · 크기를 바꾸고 · 스냅하는 조작은 전부 포인터 전용입니다. 8방향 리사이즈 핸들도 드래그로만 동작하며 Escape 는 아무 동작도 하지 않습니다.

포커스#

입력 포커스를 관리하지 않습니다. focusWindow / unfocusWindow 는 이름과 달리 z-순서를 올리고 타이틀 스타일을 바꾸는 것이 전부이며, 키보드 포커스는 옮기지 않습니다. 포커스 트랩도, 닫힐 때의 복원도, 배경 윈도우에 대한 inert / BlockSemantics 처리도 없습니다.

윈도우 안에서 포커스를 받는 것은 타이틀 바 버튼과 호출자가 넣은 content 안의 요소들뿐입니다.

스크린 리더#

Web 에서는 각 윈도우가 "window" 라는 이름의 dialog 로, 그 안에 최소화 / 최대화 / 닫기 세 버튼을 가진 것으로 발표됩니다. Flutter 에서는 "window" 라벨이 붙은 시맨틱 컨테이너로 읽히고, 이 문자열은 영어 리터럴이라 번역되지 않습니다(버튼 라벨은 로케일을 따르는 것과 다릅니다).

윈도우의 실제 title 은 접근성 이름으로 쓰이지 않습니다 — 두 플랫폼 모두 "window" 로만 읽힙니다.

알려진 제약#

  • 윈도우를 키보드로 조작할 방법이 없습니다. 이동 · 크기 조절 · 스냅 · 최대화 · 최소화 · 닫기 어느 것도 키 경로가 없고 Escape 도 동작하지 않습니다. 키보드로 창을 다룰 수 있어야 하는 제품이라면 CoreWindowController 를 직접 조작하는 별도 컨트롤을 호출자가 제공해야 합니다.
  • 여러 윈도우가 동시에 접근성 트리와 Tab 순서에 남습니다. role="dialog" 를 쓰면서도 배경 윈도우를 비활성화하지 않으므로, 뒤에 있는 윈도우의 버튼과 콘텐츠에 그대로 Tab 이 닿습니다.
  • 모든 윈도우가 같은 이름 "window" 로 발표됩니다. 여러 개를 띄우면 리더에서 서로 구분되지 않습니다.
  • WindowActions 로 직접 그린 액션 행에는 접근성 이름이 아예 없습니다. 이 위젯은 아이콘만 든 Button 세 개를 라벨 없이 만들기 때문에, WindowPanel 이 그리는 동일한 컨트롤과 달리 리더가 이름을 읽을 수 없습니다. actions 를 직접 구성한다면 각 버튼에 라벨을 직접 붙이세요.

컴포넌트와 무관하게 적용되는 축(동작 줄이기 · 고대비 · 색 강제 모드 · 최소 터치 타겟)은 전역 접근성 축에 정리되어 있습니다.