第拾壱話
デスクトップ
トレイアイコンと Dock メニュー、バックグラウンドで動くアプリ、グローバルショートカット、ファイルダイアログとファイルマネージャー、ファイルを開く処理、単一インスタンス、終了、最近使った書類、ログイン時の起動、アップデート、ディスプレイ、電源、アプリのアイコンでの進捗表示
ウィンドウとメニューバー(ナビゲーション)のほかにも、デスクトップにしかない機能があります。トレイアイコンと Dock メニュー、フライアウト、バックグラウンドで動き続けるアプリ、グローバルショートカット、パネルとランチャー、インストール済みアプリの検索、クリックスルーウィンドウ、ファイルダイアログとファイルマネージャー、アプリで開くファイル、単一インスタンス、終了、最近使ったドキュメント、ドキュメントベースのアプリ、ログイン時の起動、アップデート、「再生中」とメディアキー、ディスプレイ、電源、アプリアイコン上の進捗表示、そしてシステムの外観です。ファイルやフォルダーの変更(アプリが表示しているフォルダーや、ほかのアプリが保存したドキュメントなど)を追いかけるには、ファイルの監視を参照してください。こちらはスマートフォンと Web でも使えます。
どの機能もパッケージか、ui または app の関数として提供されていて、Mac、Windows、Linux で動作します。その機能を持たないスマートフォンやブラウザーでは、使えないことを示す値が返ります(ErrUnsupported、ErrNotAvailable、または Supported() が false)。そのため、アプリは動作する環境でだけその機能を提供できます。
トレイアイコンと Dock メニュー
コンポーネントの中で tray.Show を呼ぶと、そのコンポーネントがマウントされているあいだ、トレイにアイコンが表示されます。Mac ではメニューバーの NSStatusItem、Windows では通知領域のアイコン、Linux では StatusNotifierItem になります(KDE、Xfce、Cinnamon、MATE、Budgie、そして AppIndicator 拡張機能を入れた GNOME で表示されます)。
tray.Show(tray.Props{
Symbol: ui.Val("moon.zzz"),
Title: remaining, // シグナル:アイコンの横に "25:00" と表示
Tooltip: ui.Val("Focus timer"),
Menu: signal.Static([]ui.MenuItem{
{Title: "Start", OnPress: start},
{Title: "Length", Submenu: []ui.MenuItem{
{Title: "25 minutes", OnPress: func() { length.Set(25) }},
{Title: "50 minutes", OnPress: func() { length.Set(50) }},
}},
ui.MenuSeparator,
ui.MenuQuit,
}),
})Image または Symbol(Mac ではテンプレート画像になり、メニューバーの外観に合わせて色が変わります)、Title(Mac と、ラベルを表示する Linux のパネルで表示されます)、Tooltip、Menu は、それぞれのシグナルに追従します。オーナーが破棄されるとアイコンは消えます。OnClick を指定しなければ、クリックでメニューが開きます。指定した場合は、クリックで OnClick が実行され、右クリックでメニューが開きます。
メニューには、ui.MenuBar のメニューと同じく Submenu と ui.MenuQuit を使えます。tray.DockMenu(items) は、Mac では Dock アイコンのメニューに項目を追加します。Windows ではジャンプリストの「タスク」になり、選ぶと実行中のアプリに届きます。Linux ではランチャーのクイックリストになります。
examples/tray は、メニューバーに常駐するタイマーです。
フライアウト
フライアウトは、トレイアイコンからぶら下がるように開くウィンドウです。Mac のメニューバーエクストラや、Windows の通知領域から開くパネルがこれにあたります。ウィンドウ内の矩形を起点に開くこともできます。実体は Flyout を指定した ui.Window です。
open := signal.New(false)
icon := tray.Show(tray.Props{
Symbol: ui.Val("bolt.fill"),
OnClick: func() { open.Set(!open.Peek()) },
Menu: ui.Val([]ui.MenuItem{ui.MenuQuit}), // 右クリックで開く
})
ui.Window(ui.WindowProps{
Open: open,
Flyout: &ui.Flyout{Anchor: icon.Anchor(), Placement: ui.PlacementBottom},
FitContent: true, MinWidth: 260, MaxHeight: 480,
Content: panel,
})- 枠もタスクバーのボタンもありません。Mac では、
Backgroundを指定しないとコンテンツの背後にメニューと同じマテリアルが表示され、角が丸くなって影が付きます。そのため Mac では、コンテンツに背景を付けないでください。Windows 11 では、ウィンドウの色を背景に、角が丸くなって影が付きます。Linux ではどちらも描かれません。 Placementは、アンカーのどちら側に出すかと、アンカーに対する揃え方を決めます(PlacementBottomなら下側の中央)。ディスプレイのその側に余地がなければ反対側に回り(Mac のメニューバーのアイコンなら下、画面下端のタスクバーのアイコンなら上)、それでも足りなければディスプレイの内側へずれ、最後にはディスプレイに収まるよう縮みます。Gapはアンカーとのあいだに空ける間隔です。Anchorはアクセサに追従します。ui.Anchor{X, Y, Width, Height}は、フライアウトを宣言したウィンドウ内の矩形で、コンテンツの左上を原点としたポイント単位で指定します(OnLayoutで得たビューのフレームなど)。この場合、フライアウトはそのウィンドウの子になり、ウィンドウと一緒に動きます。- 表示されるとキーボード入力を受け取りますが、アプリを前面には出しません。ユーザーがほかの場所をクリックしたとき、ほかのアプリに切り替えたとき、コンテンツが処理しなかった Escape キーを押したときは、ユーザーが閉じたときと同じように閉じます(
Openが false になる、OnCloseが呼ばれる、またはOnCloseRequestに問い合わせる)。 - フライアウトの上に出すダイアログ:コンテンツ内で宣言した
Modalのui.Windowは、フライアウトの手前に、その中央に揃えて表示されます。ダイアログが閉じるまでフライアウトは開いたままで、閉じるとキーボード入力はフライアウトに戻ります。フライアウトの上にAlertを出したときも、フライアウトは開いたままです。 FitContentを指定するとコンテンツに合わせたサイズになり(後述)、コンテンツが大きくなったり小さくなったりするたびに、アンカーに対して配置し直されます。
| トレイのフライアウト | 配置の基準 | |
|---|---|---|
| macOS | メニューと同じレベルに置かれる、枠なしでアクティブにならない NSPanel。背景はメニューのマテリアル |
ユーザーがクリックしたステータス項目のコピー(ディスプレイごとのメニューバーにそれぞれある) |
| Windows | Windows 11 の角丸を持つ、枠なしで最前面のツールウィンドウ | アイコンの矩形(Shell_NotifyIconGetRect)。タスクバーがどの辺にあるかも考慮する |
| Linux | 装飾がなく、タスクバーに出ないウィンドウ | X11 では、アイコンがクリックされたとパネルが報告した位置。Wayland ではアプリがウィンドウを配置できないため、デスクトップが置いた位置 |
| Web | ページ内のパネル。トレイをアンカーにした場合はページの右上 | ページ内の、そのウィンドウの矩形 |
examples/tray のアイコンは、タイマーの入ったフライアウトを開き、その上にダイアログも出します。
コンテンツに合わせたウィンドウサイズ
FitContent(WindowProps と WindowOptions のフィールド)は、ウィンドウを表示内容に合わせたサイズにし、内容が変わればそれに追従させます。たとえば、ノードの一覧が増えていくフライアウトや、検索結果が出たり消えたりするランチャーに使います。幅はコンテンツのいちばん長い行の幅(max-content)で、MinWidth から MaxWidth の範囲に収まります。MaxWidth に達すると、テキストはその幅で折り返されます。高さはその幅でのコンテンツの高さで、MinHeight から MaxHeight の範囲に収まります。MaxHeight を超えた分は、中にある Scroll がスクロールさせます。コアは各フレームのレイアウトでコンテンツをそのサイズで配置し、ウィンドウも同じパスでそのサイズになります。そのため、ウィンドウは最初から正しいサイズで表示され、いったん別のサイズで表示されることはありません。フライアウトはアンカーに対して配置し直され、通常のウィンドウやパネルは上端を保ったまま下へ伸びます。ユーザーはサイズを変更できません。Web では、メインウィンドウはタブそのもので、ページ全体を占めます。
バックグラウンドで動くアプリ
app.KeepRunning() を呼ぶと、最後のウィンドウを閉じてもアプリは動き続けます。ウィンドウは隠れるだけで、ui.MainWindow().Show() で再び表示できます(Mac では Dock アイコンのクリックでも表示されます)。app.RunInBackground()、または asuka.json の "background": true は、これに加えて、ウィンドウなしで(Mac では Dock アイコンもなしで)アプリを起動します。メニューバーやトレイに常駐するアプリ向けです。どちらも app.Run より前に呼んでください。
グローバルショートカット
hotkey.Register は、ほかのアプリが前面にあってもアプリに届くキーを登録します。登録は、それを登録したコンポーネントが存在するあいだ有効です。
k := hotkey.Register(hotkey.Props{
Shortcut: ui.Val(ui.Cmd("space").With(ui.Alt)),
Description: ui.Val("Show the launcher"),
OnPress: func() { ui.MainWindow().Focus() },
})OnPress は、キーが押されるたびにメインスレッドで実行されます。ショートカットはアクセサに追従するので、ユーザーが設定で選んだキーにも対応できます。k.Status() と k.Err() を見れば、有効になっているか、保留中か、失敗したならその理由がわかります。理由は ErrTaken(ほかのアプリがそのキーを使っている)、ErrNotAllowed(ユーザーが拒否した、またはデスクトップがアプリを認識していない)、ErrNotAvailable、ErrKey のいずれかです。
- macOS:Carbon のホットキーを使うので、アクセシビリティの許可は要りません。システム自身のショートカットは、使用中のキーとして扱われます。キーを押してもアプリは前面に出ません。前面に出すには、ウィンドウを
Focusしてください。 - Windows:
RegisterHotKeyを使います。キーが押されると、アプリはフォアグラウンドになることを許可されます。 - Linux:X11 では、アプリ自身がキーをグラブします。Wayland では、デスクトップが GlobalShortcuts ポータル(GNOME 48、KDE Plasma 6)を通じてキーを割り当てます。その際、ユーザーに確認を求めたり、別のキーを選ばせたりすることがあります。また、アプリがデスクトップエントリーとともにインストールされている必要があります。
OnPressで表示したウィンドウは、そのキー入力のアクティベーショントークンを使ってフォーカスを得ます。 - スマートフォンと Web にはありません(
ErrNotAvailable)。
パネルとランチャー
パネルは、Spotlight や Raycast のような、ランチャー、コマンドパレット、クイック入力のためのウィンドウです。グローバルショートカットでどこからでも呼び出せ、すべてのアプリのウィンドウより手前に浮かびます。ユーザーがいる操作スペース(Space)や仮想デスクトップに表示され、フルスクリーンのアプリの上にも重なります。タスクバー、Dock のウィンドウ一覧、アプリスイッチャーには現れません。Mac では、アプリを前面に出さずにキーボード入力を受け取ります。そのため、ユーザーが使っていたアプリのメニューバーはそのまま残り、パネルが消えるとキーボード入力もそのアプリに戻ります。パネルは Panel を指定したウィンドウです。次の例では、バックグラウンドで動くアプリのメインウィンドウとしてランチャーを作っています。
func main() {
app.RunInBackground()
app.RunWindow(ui.WindowOptions{Panel: true, Hidden: true, FitContent: true,
MinWidth: 640, MaxWidth: 640, MaxHeight: 440}, Launcher)
}
func Launcher() ui.Node {
w := ui.UseWindow()
w.OnCloseRequest(func() bool { w.Hide(); return false }) // Escape キー、ほかの場所のクリック
hotkey.Register(hotkey.Props{
Shortcut: ui.Val(ui.Shortcut{Key: "space", Mods: ui.Alt}),
OnPress: func() { w.Show() },
})
// ...
}- 枠はありません。Mac では、
Backgroundを指定しないとコンテンツの背後にポップオーバーのマテリアルが表示され、角が丸くなって影が付きます。Windows 11 では角が丸くなります。Linux では、通常のウィンドウのコンテンツと同じように描かれます。 XとY(またはStateKey)を指定しないと、表示するたびに、ポインターのあるディスプレイの左右中央、上から 3 分の 1 の位置に表示されます。SetPositionやCenterを使うと、位置を固定できます。FitContentを指定すると、結果が増えるにつれて上端から下へ伸びます。- コンテンツが処理しなかった Escape キー(テキストフィールドでの Escape も含む)を押したときや、キーボードフォーカスを失ったとき(ほかの場所のクリック、ほかのアプリへの切り替え)は、ユーザーが閉じたときと同じように閉じます。閉じる代わりに隠すようにすれば(上の例の
OnCloseRequest)、次はすぐに元の状態のまま表示できます。なお、動き続けるアプリのメインウィンドウは、どのみち閉じると隠れるだけです。 Showは、パネルをキーボード入力を受け取る状態で表示します。Focusも同じ動作で、アプリを前面に出すことはありません。
| パネル | |
|---|---|
| macOS | nonactivating スタイルの NSPanel。すべての操作スペースとフルスクリーンのアプリの上に表示され(canJoinAllSpaces、fullScreenAuxiliary)、ウィンドウの切り替え対象から外れ、フローティングする。メインウィンドウにはならない |
| Windows | 非表示のウィンドウが所有する、枠なしで最前面のツールウィンドウ(タスクバーのボタンなし)。Windows 11 の角丸付き。フォアグラウンドになる(グローバルショートカットのキー入力がそれを許可する) |
| Linux | 装飾のないウィンドウ。X11 では、常に手前に保たれ、すべてのワークスペースに表示され、タスクバーとページャーに出ないユーティリティウィンドウ。Wayland では、アプリが gtk4-layer-shell(LD_PRELOAD)付きで、そのプロトコルを持つコンポジター(KDE Plasma、Sway、Hyprland、COSMIC)上で動いていれば、overlay レイヤーの layer-shell サーフェス。そうでなければほかと同じ普通のウィンドウ(GNOME が配置し、常に手前に保たれることもない) |
| Web | ページの上に重なるパネル。上から 3 分の 1 の位置 |
examples/launcher は自作の Spotlight です。⌥Space(Alt+Space)で検索フィールド付きのパネルが開き、入力に合わせてインストール済みのアプリがアイコン付きで表示されます。Enter を押すと先頭のアプリが開きます。
アプリの検索
asuka/sdk/apps は、ランチャーが検索するのと同じように、インストール済みのアプリを一覧にします。
apps.List(func(list []apps.App, err error) { installed.Set(list) })
results := apps.Search(installed.Get(), query.Get())
apps.Icon(results[0], 32, func(path string, err error) { icon.Set(path) }) // ui.Image で使える PNG
apps.Open(results[0], nil)Listは、各アプリのName(システムが表示するとおりの、ユーザーの言語での名前)、ID、Pathを名前順に返します。ディスクの読み込みはメインスレッドの外で行い、結果はメインスレッドで返します。アプリが追加されたり削除されたりしているかもしれないので、パネルを表示するたびに取得し直してください。Searchは、ユーザーの入力に対してアプリを順位付けします。大文字と小文字、アクセント記号の違いは無視します。順位は、名前と完全に一致するもの、名前の先頭に一致するもの、単語の先頭か頭文字に一致するもの(Visual Studio Code に対する「vsc」)、どこかに含まれるもの、文字が順番どおりに含まれるもの、の順です。Iconは、アプリのアイコンを指定したサイズ(ポイント単位)の PNG ファイルとしてアプリのキャッシュに描き出し、そのパスを返します。ファイルは次回のために残されます。Openは、アプリを起動するか、前面に出します。先にパネルを隠してください。そうすれば、開いたアプリがキーボード入力を受け取るまで、ユーザーが使っていたアプリが受け取り続けます。
| 場所 | ID | アイコン | 起動 | |
|---|---|---|---|---|
| macOS | /Applications、/System/Applications、~/Applications と、それぞれの一つ下のフォルダー、そして Finder |
bundle identifier | ワークスペースが返すそのアプリのアイコン | NSWorkspace |
| Windows | スタートメニューのアプリ(シェルの Applications フォルダー)。パッケージアプリも含む | AppUserModelID | シェルが返すそのアプリの画像 | シェル |
| Linux | メニューに表示されるデスクトップエントリー(XDG のほか、Flatpak と Snap のものも含む)。OnlyShowIn、NoDisplay、TryExec で判定する |
デスクトップファイル ID | テーマから取得したエントリーのアイコン | GIO(ディスプレイの起動コンテキスト付き) |
スマートフォンと Web では apps.ErrUnsupported が返ります。
クリックスルーウィンドウ
ClickThrough(または ui.UseWindow().SetClickThrough(true))を指定すると、マウス操作がウィンドウを素通りして、その背後にあるもの(ほかのアプリのウィンドウやデスクトップ)に届きます。Transparent と AlwaysOnTop を組み合わせれば、オーバーレイ、HUD、デスクトップマスコットなどを作れます。ClickThrough() は、この状態をシグナルとして返します。有効なあいだ、ウィンドウはクリックもホバーもホイールも一切受け取りません。ユーザーに操作させたいときは無効にしてください。
ui.Window(ui.WindowProps{Open: hud, Transparent: true, AlwaysOnTop: true, ClickThrough: true,
Width: 240, Height: 80, Content: meters})実装は、Mac では ignoresMouseEvents、Windows では WS_EX_TRANSPARENT を付けたレイヤードウィンドウ、Linux では空の入力領域(gdk_surface_set_input_region)です。Web では、ページ内のウィンドウがクリックをページに通します。ブラウザーの外までクリックを通す方法はありません。
ファイルダイアログ
asuka/sdk/dialog はシステムのファイルダイアログを表示し、コピーではなく、ユーザーのファイルそのもののパスを返します。
dialog.Save(dialog.SaveOptions{
Name: "Notes.md",
Filters: []dialog.Filter{{Name: "Markdown", Extensions: []string{"md"}}},
From: view, // ダイアログが属するウィンドウ
}, func(path string, err error) {
if path != "" {
os.WriteFile(path, export(), 0o644)
}
})用意されているのは dialog.Open(Multiple で複数ファイルを選択。Filters で image/* のような MIME タイプや拡張子による絞り込み)、dialog.Save(Name で名前を提案。既存のファイルを置き換える前にはダイアログが確認します)、dialog.Folder です。Title、Button、Start(フォルダー、またはその中のファイル)、From で設定します。キャンセルされた場合は、パスが空で、エラーもありません。
Mac ではウィンドウのシートとして、Windows ではウィンドウに対してモーダルなコモンアイテムダイアログとして、Linux では GTK のファイルダイアログとして表示されます(Flatpak の中では、デスクトップのポータルになります)。ブラウザーでは、Open と Folder は選ばれたものをアプリのファイルにコピーします。Save で得たファイルに書いた内容は、ユーザーが選んだファイルに書き込まれるか(Chrome、Edge)、ダウンロードされます。iOS と Android では、Open はドキュメントピッカーになり(device.PickFiles と同じくコピーを返します)、Save と Folder は dialog.ErrUnsupported を返します。
ファイルマネージャー
asuka/sdk/shell は、ファイルマネージャーがファイルに対して行う操作を提供します。shell.Reveal は、ファイルを含むフォルダーを開き、そのファイルを選択した状態で表示します(Finder、エクスプローラー、またはデスクトップのファイルマネージャー)。shell.Open は、ファイルをその種類に対応するユーザーのアプリで開き(該当するアプリがなければ shell.ErrNoApp)、フォルダーならファイルマネージャーで開きます。shell.Trash は、ファイルをゴミ箱(Windows ではごみ箱)に移します。完全に削除することはなく、移せないときは失敗します。shell.Beep は警告音を鳴らします。スマートフォンと Web にはファイルマネージャーがないため、Reveal と Trash は shell.ErrUnsupported を返します。Web の Open は、新しいタブでファイルを表示します。ファイルの内容をアプリの中で表示するには、asuka/sdk/preview を使ってください。
ファイルを開く
アプリが開くファイルの種類を asuka.json の fileTypes で宣言し(配布)、ui.OnOpenFiles で処理します。
ui.OnOpenFiles(func(paths []string) {
for _, p := range paths {
open(p)
}
})一緒に開かれたファイルは、絶対パスとして 1 回の呼び出しにまとめて届きます。Finder、エクスプローラー、ファイル(Files)でのダブルクリック、「このアプリケーションで開く」(Open With)、Dock アイコンへのドロップ、最近使ったドキュメントの選択、そして Windows と Linux ではコマンドラインに指定したファイルが対象です。アプリの起動時に渡されたファイルは、最初のハンドラーが登録されるまで待たされます。iOS では、パスは元の場所にあるドキュメントそのもので、アプリが終了するまで読み取れます。Android と Web では、アプリのキャッシュにあるアプリ専用のコピーです。
単一インスタンス
Windows と Linux では、id を持つアプリは一つしか起動しません。もう一度起動すると、実行中のアプリが前面に出ます。app.OnSecondInstance(func(args []string, dir string)) で、新たな起動の引数と作業ディレクトリを受け取れます。引数に含まれるファイルとリンクは、ui.OnOpenFiles と ui.OnOpenURL に渡されます。毎回新しいインスタンスを起動したい場合は、asuka.json で "singleInstance": false を設定します。Mac では、アプリは常に一つしか起動せず、渡されるのは引数ではなくファイルです。
終了
app.Quit() は、ユーザーが「終了」を選んだときと同じようにアプリを終了します。終了のきっかけ(⌘Q、Dock、最後のウィンドウを閉じる、Ctrl+C や SIGTERM、ログアウト)にかかわらず、まず app.OnQuit(func(e *app.QuitEvent)) が呼ばれます。e.Cancel() を呼べばアプリは終了しないので、未保存の変更について確認できます。e.SessionEnding が設定されているときは終了を取り消せないため、ダイアログを出さずにすぐ保存してください。ウィンドウ自体を閉じるかどうかは、それより先に、そのウィンドウの OnCloseRequest で問い合わせられます(ナビゲーション)。
app.DataDir、ConfigDir、CacheDir、LogDir はアプリ専用のフォルダーで、要求されたときに作られます。
app.Relaunch() は同じ手順で終了し(ハンドラーで取り消すこともできます)、終了したあとでアプリを再び起動します。アップデートのあとや、起動時に反映される設定を変えたときに使います。
アップデート
アプリのルートに update.Window(update.Options{}) を置くと、Sparkle 方式のアップデーターになります。起動直後と 1 日 1 回、新しいバージョンを確認し、リリースノートを「アップデートをインストール」「このバージョンをスキップ」「後で通知」のボタンとともに表示します。ダウンロードは進捗バー付きで行い(実行中のバージョンからの差分があれば、変更部分だけをダウンロードします)、アプリの鍵で署名されていることを確かめてからインストールし、「今すぐ再起動」を提示します。update.AppMenu() は、Mac では「…について」の次に「アップデートを確認…」を追加します(ほかのプラットフォームでは「ヘルプ」メニューに追加します)。独自の UI を作るには、update.Check、Release.Install、update.Relaunch を使います。
鍵ペアは asuka update keygen で作ります。リリースの公開先(GitHub のリリース、S3 バケットや任意の HTTPS ディレクトリ、または自前のサーバー)は asuka.json の updates で指定し、asuka publish --platform mac(windows、linux)でビルド、署名、アップロードを行います(配布)。Mac アプリの新しいバンドルは、アプリが終了するのを待ってから置き換わります。Windows と Linux では、実行中にアプリのファイルを置き換えます。システムのパッケージ、Flatpak、Snap、アプリストアからインストールされたアプリは、それらによって更新されます。そのことは update.Supported() でわかり、スマートフォンでは ErrUnsupported が返ります。Web では、サイトより古いページがリロードを促します。
最近使ったドキュメント
ユーザーがドキュメントを開いたり保存したりするたびに、recent.Add(path, nil) を呼んでください。Mac では「ファイル」メニューの「最近使った項目を開く」と Dock メニューに、Windows ではジャンプリストに、Linux ではユーザーの最近使ったファイルに表示されます。そこから選ぶと、ui.OnOpenFiles を通じて開かれます。recent.List は新しい順に返し、なくなったファイルは飛ばします。recent.Clear は、そのアプリが追加したものを消去します。
ドキュメント
asuka/sdk/document を使うと、テキストエディターやドローアプリのように、ウィンドウごとに一つのファイルを表示するアプリを作れます。Mac アプリが NSDocument から得られるものを、すべてのプラットフォームで提供します。ドキュメントの種類を記述して、実行するだけです。
func main() {
document.New(document.Options[string]{
Types: []document.Type{{Name: "Plain Text", Extensions: []string{"txt"}}},
Read: func(r io.Reader) (string, error) {
b, err := io.ReadAll(r)
return string(b), err
},
Write: func(w io.Writer, text string) error {
_, err := io.WriteString(w, text)
return err
},
Equal: func(a, b string) bool { return a == b },
View: func(doc *document.Document[string]) ui.Node {
return ui.TextField(ui.TextFieldProps{Value: doc.Content(), Multiline: true, Plain: true})
},
}).Run()
}システムがこれらのファイルを開くアプリとしてこのアプリを提示できるよう、asuka.json の fileTypes にも同じ種類を列挙してください。examples/textedit は、このエディターに、ステータス行と、ほかのアプリがファイルを変更したときに出るバーを加えたものです。
ドキュメントの Content() はシグナルです。ビューはこれにバインドし、値を変えるとドキュメントは編集済みになります。ただし、Equal がファイルの内容と等しいと判断した場合(取り消して元に戻した、1 文字入力して消した、など)は編集済みになりません。ファイルを読み込み直したときにも、このシグナルに値が設定されます。アプリがその場で書き換えるコンテンツ(モデルへのポインターなど)は、変更したことを doc.Changed() で知らせ、書き込む前にコピーを取る Snapshot を用意します。doc.State() は、ドキュメントの名前とファイル、そして編集済み、読み取り専用、競合中、削除済みのどれにあたるかを表すシグナルです。
デスクトップでは、ユーザーは次のような動作を得られます。
- ドキュメントごとのウィンドウ:ウィンドウの名前はファイル名になり(ファイルがまだないうちは「名称未設定」「名称未設定 2」)、そのファイルのウィンドウをユーザーが最後に置いた位置に開きます。Mac では、未保存の変更があるあいだクローズボタンに点が表示され、タイトルバーにファイルのアイコンが表示されます。Windows と Linux では、タイトルの前に
*が付き、タイトルの末尾はアプリ名になります。GNOME では、タイトルの下にファイルのフォルダーも表示されます。 - 「ファイル」メニュー:「新規」「開く…」「最近使った項目を開く」「閉じる」「保存」「別名で保存…」と、Mac では「複製」「名称変更…」「移動…」「保存された状態に戻す」(ほかのプラットフォームでは「元に戻す」)が、それぞれのショートカット付きで並び、使用中のウィンドウに対して動作します。
Options.Menusを使うと、その隣にアプリ独自のメニューを追加できます。タイトルが File のメニューを渡すと、その項目は「ファイル」メニューに追加されます。Windows と Linux では、メニューはドキュメントごとのウィンドウに表示されます。 - 自動保存:ファイルのあるドキュメントは、Mac アプリと同じように、ユーザーが入力を止めて少したったとき、ウィンドウがフォーカスを失ったとき、ウィンドウを閉じるときに保存されます。確認は一切しません。
AutosaveOffを指定すると、従来の Windows アプリのように、ユーザーが指示したときに保存し、ウィンドウを閉じるときに確認します。 - 確認するのは自動で保存できないときだけ:名称未設定のドキュメント(「新規書類“名称未設定”を残しますか?」)、ファイルが読み取り専用のドキュメントや、ほかのアプリにファイルを変更されたドキュメント(「このまま保存」「元に戻す」)、
AutosaveOffのときの編集済みのドキュメントを閉じる場合です。 - 復元用のコピー:未保存の変更は、ユーザーが入力を止めて 1 秒後にアプリのデータ領域へコピーされます。クラッシュしても、強制終了しても、普通に終了しても、何も失われません。次回の起動時に、それらのドキュメントが編集済みの状態で再び開かれます。終了するときは、VS Code と同じように、ドキュメントをその状態のまま残します。
AskOnQuitを指定すると、代わりに確認します(Mac では「未保存の変更がある書類が 3 個あります。終了する前に変更内容を確認しますか?」、Windows と Linux では、該当するドキュメントを列挙した一つの確認)。セッションが終了するときは確認する時間がないため、すべてをただちに保存するか、そのまま残します。 - 外部から開いたファイル:Finder、エクスプローラー、Dock、コマンドライン、「最近使った項目を開く」、「開く…」から開いたファイルは、そのドキュメントのウィンドウがすでに開いていれば、そのウィンドウに切り替わります。
- ほかのアプリによるファイルの変更:編集していないドキュメントはファイルを読み込み直し、編集済みのドキュメントは次に保存するときに確認します。ファイルの名前が変わっても追従し(Mac では、同じディスク上ならユーザーがどこへ移動しても追従します)、削除されたファイルは次の保存で書き直されます。
Mac のドキュメントアプリは、ウィンドウがなくなっても起動したままで、Dock アイコンをクリックすると新しいドキュメントを作ります。Windows と Linux では、最後のドキュメントのウィンドウを閉じるとアプリも終了します。NoUntitled を指定すると、起動時に名称未設定のドキュメントを開かず、何も開きません。
スマートフォンとブラウザーでは、一度に扱うドキュメントは一つです。アプリはドキュメントの一覧(iOS では「ファイル」アプリ内のアプリのフォルダー)を「新規」「開く…」とともに表示して起動し、ドキュメントの画面はその上にプッシュされます。各ドキュメントは作成した時点からファイルを持ち、UIDocument を使うアプリと同じように、ユーザーが編集するたび、アプリがバックグラウンドに移ったとき、ユーザーが前の画面に戻ったときに保存されます。確認は一切ありません。ほかの場所にあるファイルは、まずアプリのドキュメントにコピーされます。
確認ダイアログとメニューは、アプリの言語で表示されます(現在は英語、ドイツ語、フランス語、スペイン語、日本語、中国語)。ほかの言語を追加したり文言を変えたりするには、アプリのカタログに document.<key> というキーを書きます。
Mac の NSDocument だけが備える機能のうち、バージョン(Revert To で過去のバージョンに戻す機能)、ロック、タイトルバーのポップオーバーでの名称変更、フォルダー形式のドキュメントは提供していません。
ログイン時の起動
login.Set(true, done) を設定項目として用意し、login.Enabled で現在の状態を読み取ります。macOS 13 以降ではログイン項目(パッケージされたアプリのみ。ユーザーが「システム設定」で許可するまでは login.ErrNeedsApproval が返ります)、Windows では Run エントリー、Linux では自動起動(autostart)エントリー(Flatpak の中では Background ポータル)になります。login.OpenedAtLogin を使うとログイン時の起動かどうかを見分けられるので、その場合はウィンドウを出さずに起動できます。
「再生中」とメディアキー
nowplaying.Show は、アプリが再生しているものをシステムのメディアコントロールに表示します。そのボタンやキーボードのメディアキー(再生/一時停止、次へ、前へ)の操作は、アプリのハンドラーに届きます。Mac ではコントロールセンターとメニューバーの「再生中」、Windows では音量のフライアウトとクイック設定のメディアコントロール(アプリのウィンドウの SystemMediaTransportControls)、Linux では MPRIS プレイヤーになり、GNOME や KDE のコントロール、playerctl から操作できます。スマートフォンと同じパッケージで、スマートフォンではロック画面のコントロールになります(デバイスとサービス)。
music := audio.Load("music/spring.mp3")
signal.OnCleanup(music.Release)
nowplaying.Show(nowplaying.Props{
Title: ui.Val("Spring"),
Artist: ui.Val("Vivaldi"),
Player: music, // 再生状態と位置を反映し、再生・一時停止・シークはこれに対して行う
OnNext: playNext,
})ディスプレイ、電源、アプリのアイコン
screen.UseDisplays() はディスプレイの状態に追従します。各ディスプレイの位置、作業領域、スケール、リフレッシュレート、どれがメインかがわかります。Nearest と Matching は、ある点やウィンドウの下にあるディスプレイを選びます。Wayland には作業領域がないため、ディスプレイ全体の範囲になります。
power.Use() は、バッテリー、低電力モード、温度の状態に追従します。power.OnSuspend、OnResume、OnLock、OnUnlock で、コンピューターのスリープと復帰、画面のロックとその解除がわかります。power.IdleTime() は、ユーザーが操作していない時間を返します。スマートフォンにはロックとロック解除はありますが、スリープとアイドル時間はありません。Web ではバッテリーだけです。
dock.SetProgress は、タスクの進捗を Dock アイコン、タスクバーのボタン、または Unity ランチャー API を持つ Linux のドックに表示します(GNOME 標準の Dash には表示されません)。dock.RequestAttention は、アプリがバックグラウンドにあるあいだ、アイコンを弾ませたり点滅させたりします。アイコン上の数字は notify.SetBadge で設定します。
appearance.Use() は、システムのアクセントカラーと、「コントラストを上げる」「透明度を下げる」の設定を返します。これはアプリが自分で描くもののためのもので、Asuka が表示するビューはすでにこれらに従います。