picoride 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ # ビルドログとプラットフォームセットアップログをまとめたタブ切り替えパネル。
2
+ #
3
+ # 以前は BuildPanel / PlatformPanel という別々のパネルを常時両方表示していたが、
4
+ # 同時に見る場面は少なく画面を圧迫するだけだったので、タブで切り替える1つの
5
+ # パネルに統合した。どちらのタブを表示中かは親(EditorApp)のstate(log_tab)に
6
+ # 持たせてあるので、タブを切り替えてもポーリングによるログ更新自体は
7
+ # バックグラウンドで両方継続する。
8
+ #
9
+ # 他のパネルと同じく表示専用。タブ切り替え/ログ更新は
10
+ # props[:on_tab_change] / props[:on_build_refresh] / props[:on_platform_refresh]
11
+ # 経由で親(EditorApp)に委譲する。
12
+ class LogPanel < Funicular::Component
13
+ BUILD_STATUS_LABELS = {
14
+ 'idle' => 'ビルド未実行',
15
+ 'running' => 'ビルド中…',
16
+ 'success' => 'ビルド成功',
17
+ 'failed' => 'ビルド失敗'
18
+ }.freeze
19
+
20
+ PLATFORM_STATUS_LABELS = {
21
+ 'idle' => '未実行',
22
+ 'running' => 'セットアップ中…',
23
+ 'success' => 'セットアップ完了',
24
+ 'failed' => 'セットアップ失敗'
25
+ }.freeze
26
+
27
+ def render
28
+ div(class: 'log-panel') do
29
+ div(class: 'log-panel-header') do
30
+ render_tab_button('build', 'R2P2 Build', :handle_select_build_tab)
31
+ render_tab_button('platform', 'Platform Setup', :handle_select_platform_tab)
32
+ div(class: 'log-panel-spacer')
33
+ span(class: 'platform-target') { props[:selected_platform] || '未選択' } unless build_tab?
34
+ render_refresh_button
35
+ span(class: "build-status #{status}") { status_label }
36
+ end
37
+ tag(:pre, class: 'build-log') { log_text }
38
+ end
39
+ end
40
+
41
+ def handle_select_build_tab(event)
42
+ event.preventDefault
43
+ select_tab('build')
44
+ end
45
+
46
+ def handle_select_platform_tab(event)
47
+ event.preventDefault
48
+ select_tab('platform')
49
+ end
50
+
51
+ def handle_refresh(event)
52
+ event.preventDefault
53
+ on_refresh = build_tab? ? props[:on_build_refresh] : props[:on_platform_refresh]
54
+ on_refresh.call if on_refresh
55
+ end
56
+
57
+ private
58
+
59
+ def select_tab(tab)
60
+ on_tab_change = props[:on_tab_change]
61
+ on_tab_change.call(tab) if on_tab_change
62
+ end
63
+
64
+ def render_tab_button(key, label_text, handler)
65
+ classes = key == active_tab ? 'log-panel-tab active' : 'log-panel-tab'
66
+ button(class: classes, onclick: handler) { label_text }
67
+ end
68
+
69
+ def render_refresh_button
70
+ button(class: 'build-refresh', onclick: :handle_refresh) { 'ログを更新' }
71
+ end
72
+
73
+ def active_tab
74
+ props[:active_tab] == 'platform' ? 'platform' : 'build'
75
+ end
76
+
77
+ def build_tab?
78
+ active_tab == 'build'
79
+ end
80
+
81
+ def status
82
+ build_tab? ? props[:build_status] : props[:platform_status]
83
+ end
84
+
85
+ def status_label
86
+ if build_tab?
87
+ BUILD_STATUS_LABELS[props[:build_status]] || props[:build_status].to_s
88
+ else
89
+ PLATFORM_STATUS_LABELS[props[:platform_status]] || props[:platform_status].to_s
90
+ end
91
+ end
92
+
93
+ def log_text
94
+ if build_tab?
95
+ log = props[:build_log].to_s
96
+ props[:build_log_truncated] ? "…(冒頭は省略)\n#{log}" : log
97
+ else
98
+ log = props[:platform_log].to_s
99
+ props[:platform_log_truncated] ? "…(冒頭は省略)\n#{log}" : log
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,266 @@
1
+ # 最上部のメニュー/グローバルステータスバー。
2
+ #
3
+ # 常設ドロップダウンは「プロジェクト」の切り替え用。プロジェクトが1つも
4
+ # 開かれていない(props[:known_projects]が空)間は、この場所に実質的に
5
+ # 「プロジェクトを開く」ボタンを出す(ドロップダウンの代わりにbuttonを
6
+ # 描画するだけで、同じprops[:on_open_project]を使う)。アプリ起動時に自動で
7
+ # ダイアログを出したりはしない。ユーザーがこのボタン/ファイルメニューから
8
+ # 明示的に開く操作をするまで、プロジェクトは開かれない。
9
+ #
10
+ # 以前は「複数プロジェクトを束ねるワークスペース」という上位概念があり、
11
+ # このドロップダウンはワークスペース選択用、プロジェクトの切り替えは別の
12
+ # ダイアログ(OpenProjectDialog)、という2階層構成だった。しかし
13
+ # 「ワークスペース=プロジェクトだった」というフィードバックにより2階層は
14
+ # 不要と判明し、1階層に統合した。このドロップダウンはそのままプロジェクト
15
+ # 選択用になり、OpenProjectDialog・「新しいプロジェクト」メニュー項目(旧・
16
+ # ワークスペース配下に名前でサブディレクトリを作る機能)は削除した
17
+ # (「新しいプロジェクト」はDirectoryBrowserDialogの「新しいフォルダ」+
18
+ # 「このディレクトリを開く」に統合されている)。
19
+ #
20
+ # ターゲットチップ・VM・USB Consoleは以前ここに常設セレクト/ビルド時ダイアログで
21
+ # 出していたが、「プロジェクトの設定としてプロジェクトに含めたい」という
22
+ # フィードバックを受けてプロジェクトごとの設定(`~/.picoride/project.toml`)に
23
+ # 持たせる方式に変更した。そのため、ここには設定値を編集する「設定」ボタンと、
24
+ # その設定値をそのまま使って実行するだけの「セットアップ」「ビルド開始」
25
+ # ボタンだけが残っている。
26
+ # 左端には「ファイル」メニュー(プロジェクトを開く/mrbgemを追加/新規ファイル/
27
+ # 新しいフォルダ)を置いてある。開閉状態はここでは持たず(理由は
28
+ # editor_app.rbの「子コンポーネントは親の再描画のたびに作り直される」の節参照)、
29
+ # props[:file_menu_open]として親(EditorApp)のstateをそのまま反映するだけにしてある。
30
+ # 他のパネルと同じく表示専用で、実処理(状態管理・API呼び出し)は
31
+ # props[:on_project_select] / props[:on_settings] / props[:on_setup] /
32
+ # props[:on_build] / props[:on_file_menu_toggle] / props[:on_open_project] /
33
+ # props[:on_add_mrbgem] / props[:on_new_file] / props[:on_new_folder]
34
+ # 経由で親(EditorApp)に委譲する。
35
+ #
36
+ # props[:project] は現在開いているプロジェクトの絶対パス(未設定ならnil)。
37
+ # props[:known_projects] はこのセッションで一度でも開いたプロジェクトの
38
+ # 絶対パスの配列(ドロップダウンの選択肢)。
39
+ class MenuBar < Funicular::Component
40
+ STATUS_LABELS = {
41
+ 'idle' => 'ビルド未実行',
42
+ 'running' => 'ビルド中…',
43
+ 'success' => 'ビルド成功',
44
+ 'failed' => 'ビルド失敗'
45
+ }.freeze
46
+
47
+ def render
48
+ div(class: 'menu-bar') do
49
+ span(class: 'menu-bar-title') { 'PicoRIDE' }
50
+ render_file_menu
51
+ render_project_select
52
+ div(class: 'menu-bar-spacer')
53
+ render_settings_button
54
+ render_setup_button
55
+ render_build_button
56
+ render_status_pill
57
+ render_install_button
58
+ end
59
+ end
60
+
61
+ def handle_file_menu_toggle(event)
62
+ event.preventDefault
63
+ on_file_menu_toggle = props[:on_file_menu_toggle]
64
+ on_file_menu_toggle.call if on_file_menu_toggle
65
+ end
66
+
67
+ def handle_open_project(event)
68
+ event.preventDefault
69
+ on_open_project = props[:on_open_project]
70
+ on_open_project.call if on_open_project
71
+ end
72
+
73
+ def handle_add_mrbgem(event)
74
+ event.preventDefault
75
+ on_add_mrbgem = props[:on_add_mrbgem]
76
+ on_add_mrbgem.call if on_add_mrbgem
77
+ end
78
+
79
+ def handle_new_file(event)
80
+ event.preventDefault
81
+ on_new_file = props[:on_new_file]
82
+ on_new_file.call if on_new_file
83
+ end
84
+
85
+ def handle_new_folder(event)
86
+ event.preventDefault
87
+ on_new_folder = props[:on_new_folder]
88
+ on_new_folder.call if on_new_folder
89
+ end
90
+
91
+ # <select> の変更イベント。選ばれたプロジェクトの絶対パスをそのまま渡す。
92
+ def handle_project_change(event)
93
+ node = refs[:project_select]
94
+ return unless node
95
+
96
+ value = node[:value].to_s
97
+ return if value.empty?
98
+
99
+ on_project_select = props[:on_project_select]
100
+ on_project_select.call(value) if on_project_select
101
+ end
102
+
103
+ def handle_settings(event)
104
+ event.preventDefault
105
+ on_settings = props[:on_settings]
106
+ on_settings.call if on_settings
107
+ end
108
+
109
+ def handle_setup(event)
110
+ event.preventDefault
111
+ on_setup = props[:on_setup]
112
+ on_setup.call if on_setup
113
+ end
114
+
115
+ def handle_build(event)
116
+ event.preventDefault
117
+ on_build = props[:on_build]
118
+ on_build.call if on_build
119
+ end
120
+
121
+ private
122
+
123
+ def render_file_menu
124
+ div(class: 'file-menu') do
125
+ button(class: 'file-menu-toggle', onclick: :handle_file_menu_toggle) { 'ファイル' }
126
+ render_file_menu_dropdown if props[:file_menu_open]
127
+ end
128
+ end
129
+
130
+ def render_file_menu_dropdown
131
+ div(class: 'file-menu-dropdown') do
132
+ button(class: 'file-menu-item', onclick: :handle_open_project) { 'プロジェクトを開く…' }
133
+ render_add_mrbgem_menu_item
134
+ render_new_file_menu_item
135
+ render_new_folder_menu_item
136
+ end
137
+ end
138
+
139
+ # mrbgem追加・新規ファイル作成はプロジェクトを開いていないと行き先が無いので、
140
+ # 未選択時は押せないようにする(disabled: false を渡す書き方はこのDSLでは
141
+ # 効かないため、分岐で二通りの要素を書き分ける。toolbar.rbのrender_save_button参照)。
142
+ def render_add_mrbgem_menu_item
143
+ if props[:project].to_s.empty?
144
+ button(class: 'file-menu-item', disabled: true) { 'mrbgemを追加…' }
145
+ else
146
+ button(class: 'file-menu-item', onclick: :handle_add_mrbgem) { 'mrbgemを追加…' }
147
+ end
148
+ end
149
+
150
+ def render_new_file_menu_item
151
+ if props[:project].to_s.empty?
152
+ button(class: 'file-menu-item', disabled: true) { '新規ファイル…' }
153
+ else
154
+ button(class: 'file-menu-item', onclick: :handle_new_file) { '新規ファイル…' }
155
+ end
156
+ end
157
+
158
+ def render_new_folder_menu_item
159
+ if props[:project].to_s.empty?
160
+ button(class: 'file-menu-item', disabled: true) { '新しいフォルダ…' }
161
+ else
162
+ button(class: 'file-menu-item', onclick: :handle_new_folder) { '新しいフォルダ…' }
163
+ end
164
+ end
165
+
166
+ # プロジェクトを1つも開いたことが無ければ(known_projectsが空)、<select>の
167
+ # 代わりに「プロジェクトを開く」ボタンを出す(props[:on_open_project]、
168
+ # ファイルメニューの項目と同じコールバック)。1つ以上開いていれば、それらを
169
+ # 選択肢に持つ<select>にする(選ぶとon_project_select経由で即座に切り替わる)。
170
+ def render_project_select
171
+ known = props[:known_projects] || []
172
+
173
+ if known.empty?
174
+ button(class: 'menu-project-open', onclick: :handle_open_project) { 'プロジェクトを開く…' }
175
+ else
176
+ tag(:select, class: 'menu-project-select', ref: :project_select, onchange: :handle_project_change,
177
+ title: props[:project].to_s) { render_project_options(known) }
178
+ end
179
+ end
180
+
181
+ def render_project_options(known)
182
+ known.each do |path|
183
+ label = project_label(path)
184
+ if path == props[:project]
185
+ tag(:option, value: path, selected: true) { label }
186
+ else
187
+ tag(:option, value: path) { label }
188
+ end
189
+ end
190
+ end
191
+
192
+ # プロジェクト名は既定でディレクトリ名(パスの最後のセグメント)にする。
193
+ # PicoRuby.wasm上ではFile.basenameのようなFile系メソッドの対応が不確かなため
194
+ # (editor_app.rbのextensionヘルパーと同じ理由)、素の文字列分割で求める。
195
+ def project_label(path)
196
+ segments = path.to_s.split('/')
197
+ name = segments.last
198
+ (name.nil? || name.empty?) ? path.to_s : name
199
+ end
200
+
201
+ # 設定はプロジェクト単位(project_store.rb参照)なので、プロジェクト未設定の
202
+ # 間は保存先が無く押せない。
203
+ def render_settings_button
204
+ if props[:project].to_s.empty?
205
+ button(class: 'menu-settings', disabled: true) { '設定' }
206
+ else
207
+ button(class: 'menu-settings', onclick: :handle_settings) { '設定' }
208
+ end
209
+ end
210
+
211
+ # ターゲットが未設定なら押せない(設定ダイアログで選んでもらう)。
212
+ def render_setup_button
213
+ platform = value_of(props[:project_config], 'platform')
214
+
215
+ if props[:platform_building]
216
+ button(class: 'menu-setup', disabled: true) { 'セットアップ中…' }
217
+ elsif platform.to_s.empty?
218
+ button(class: 'menu-setup', disabled: true) { 'ターゲット未設定' }
219
+ else
220
+ button(class: 'menu-setup', onclick: :handle_setup) { "#{platform}をセットアップ" }
221
+ end
222
+ end
223
+
224
+ def render_build_button
225
+ if props[:building]
226
+ button(class: 'menu-build', disabled: true) { 'ビルド中…' }
227
+ else
228
+ button(class: 'menu-build', onclick: :handle_build) { 'ビルド開始' }
229
+ end
230
+ end
231
+
232
+ def render_status_pill
233
+ span(class: "menu-bar-status #{props[:build_status]}") { status_label }
234
+ end
235
+
236
+ # ESP32: 実際の書き込み処理はブラウザのWeb Serial API経由でESP Web Tools
237
+ # (esp-web-install-button カスタム要素)が行う。旧 install_panel.rb と同じ使い方。
238
+ # Raspberry Pi Pico: ブラウザから直接書き込む仕組みは使わず、ビルド成果物の
239
+ # .uf2をダウンロードさせる(BOOTSELを押しながら接続して現れるドライブへ
240
+ # ユーザーがコピーする)。<a download>をボタン風に見せている。
241
+ def render_install_button
242
+ if props[:project].to_s.empty?
243
+ button(class: 'menu-install', disabled: true) { 'デバイスにインストール' }
244
+ elsif ProjectSettingsDialog.pico?(value_of(props[:project_config], 'platform'))
245
+ tag(:a, class: 'menu-install menu-download', href: '/api/firmware/uf2', download: '',
246
+ title: 'BOOTSELボタンを押しながらPicoを接続し、現れたドライブへUF2ファイルをコピーしてください') do
247
+ 'UF2をダウンロード'
248
+ end
249
+ else
250
+ tag(:'esp-web-install-button', manifest: '/api/firmware/manifest.json') do
251
+ button(slot: 'activate', class: 'menu-install') { 'デバイスにインストール' }
252
+ span(slot: 'unsupported', class: 'menu-bar-hint') { '未対応ブラウザ' }
253
+ span(slot: 'not-allowed', class: 'menu-bar-hint') { 'HTTPS必須' }
254
+ end
255
+ end
256
+ end
257
+
258
+ def status_label
259
+ STATUS_LABELS[props[:build_status]] || props[:build_status].to_s
260
+ end
261
+
262
+ def value_of(data, key)
263
+ return nil unless data
264
+ data[key] || data[key.to_sym]
265
+ end
266
+ end
@@ -0,0 +1,203 @@
1
+ # プロジェクトごとの設定(ターゲットチップ・VM・USB Console)を編集するダイアログ。
2
+ #
3
+ # 以前はターゲットをメニューバー常設のセレクトで、VM/USB Consoleをビルド開始の
4
+ # たびに出るダイアログで選ばせていたが、「プロジェクトの設定としてプロジェクトに
5
+ # 含めたい」というフィードバックを受けて永続化する方式に変更した。永続化先は
6
+ # プロジェクトごとの隠しファイル(`.config.yml`)→(一時期あった「複数プロジェクトを
7
+ # 束ねるワークスペース」という上位概念の下で)`~/.picoride/workspace.toml`、と
8
+ # 経緯を経て、現在は`~/.picoride/project.toml`(`project_store.rb`)に落ち着いている。
9
+ # このダイアログはその値を編集して保存するためだけのもので、保存後は
10
+ # 「プラットフォームをセットアップ」「ビルド開始」の各ボタンがこの設定値を
11
+ # そのまま使う(都度選び直す必要はない)。
12
+ #
13
+ # 他のパネルと同じく表示専用。値の変更/保存/キャンセルは
14
+ # props[:on_platform_change] / props[:on_vm_change] / props[:on_usb_console_change] /
15
+ # props[:on_save] / props[:on_cancel] 経由で親(EditorApp)に委譲する。
16
+ #
17
+ # ターゲットはESP32系とRaspberry Pi Pico系の2ファミリーに分かれていて、
18
+ # どちらを選んだかでサーバー側のビルド用Dockerイメージ・ビルド手順が切り替わる
19
+ # (lib/picoride/server.rb の PLATFORM_FAMILIES / BUILDER_IMAGES)。
20
+ # Picoでは「USB Console」はESP32専用の設定なので出さず、VMはボードによって
21
+ # 選べるものが違う(RP2040のpico/pico_wはFemtoRubyのみ)。
22
+ class ProjectSettingsDialog < Funicular::Component
23
+ # [optgroupのラベル, [[値, 表示名], ...]] の並び。値はサーバー側の
24
+ # PLATFORM_FAMILIES と一致させる。
25
+ PLATFORM_GROUPS = [
26
+ ['ESP32', [
27
+ ['esp32', 'esp32'],
28
+ ['esp32c3', 'esp32c3'],
29
+ ['esp32c6', 'esp32c6'],
30
+ ['esp32h2', 'esp32h2'],
31
+ ['esp32p4', 'esp32p4'],
32
+ ['esp32s3', 'esp32s3']
33
+ ]],
34
+ ['Raspberry Pi Pico', [
35
+ ['pico', 'Pico (RP2040)'],
36
+ ['pico_w', 'Pico W (RP2040)'],
37
+ ['pico2', 'Pico 2 (RP2350)'],
38
+ ['pico2_w', 'Pico 2 W (RP2350)']
39
+ ]]
40
+ ].freeze
41
+
42
+ # 空文字は「デフォルトVM」を意味する(ESP32: idf.py buildにVM未指定、
43
+ # Pico: FemtoRuby)。
44
+ VM_OPTIONS = [
45
+ ['', 'デフォルト'],
46
+ ['femtoruby', 'FemtoRuby (mruby/c)'],
47
+ ['picoruby', 'PicoRuby (mruby)']
48
+ ].freeze
49
+
50
+ # Picoの各ボードで選べるVM(サーバー側のPICO_BOARD_VMSと一致させる)。
51
+ PICO_BOARD_VMS = {
52
+ 'pico' => ['femtoruby'],
53
+ 'pico_w' => ['femtoruby'],
54
+ 'pico2' => ['femtoruby', 'picoruby'],
55
+ 'pico2_w' => ['femtoruby', 'picoruby']
56
+ }.freeze
57
+
58
+ PICO_NOTE = 'ビルドするとUF2ファイルができます。BOOTSELボタンを押しながらPicoをUSB接続し、' +
59
+ '現れたドライブ(RPI-RP2 / RP2350)へコピーすると書き込めます。' +
60
+ 'app/ はターミナルタブの「app/ をアップロード」で転送してください。'
61
+
62
+ def self.pico?(platform)
63
+ PICO_BOARD_VMS.key?(platform.to_s)
64
+ end
65
+
66
+ # そのターゲットで選べるVMかどうか(空文字=デフォルトは常に可)。
67
+ # ターゲットを切り替えたときに、選べなくなったVMをデフォルトに戻すのに使う
68
+ # (EditorAppのon_platform_change)。
69
+ def self.vm_allowed?(platform, vm)
70
+ return true if vm.to_s.empty?
71
+ return true unless pico?(platform)
72
+
73
+ PICO_BOARD_VMS[platform.to_s].include?(vm.to_s)
74
+ end
75
+
76
+ def render
77
+ div(class: 'dialog-overlay') do
78
+ div(class: 'dialog settings-dialog') do
79
+ h2 { "プロジェクト設定(#{props[:project_path]})" }
80
+ div(class: 'build-options') do
81
+ render_platform_select
82
+ render_vm_select
83
+ if ProjectSettingsDialog.pico?(props[:platform])
84
+ render_pico_note
85
+ else
86
+ render_usb_console_checkbox
87
+ end
88
+ end
89
+ div(class: 'dialog-actions') do
90
+ button(class: 'dialog-cancel', onclick: :handle_cancel) { 'キャンセル' }
91
+ button(class: 'dialog-confirm', onclick: :handle_save) { '保存' }
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def handle_cancel(event)
98
+ event.preventDefault
99
+ on_cancel = props[:on_cancel]
100
+ on_cancel.call if on_cancel
101
+ end
102
+
103
+ def handle_save(event)
104
+ event.preventDefault
105
+ on_save = props[:on_save]
106
+ on_save.call if on_save
107
+ end
108
+
109
+ def handle_platform_change(event)
110
+ node = refs[:platform_select]
111
+ return unless node
112
+ value = node[:value].to_s
113
+ on_platform_change = props[:on_platform_change]
114
+ on_platform_change.call(value.empty? ? nil : value) if on_platform_change
115
+ end
116
+
117
+ def handle_vm_change(event)
118
+ node = refs[:vm_select]
119
+ return unless node
120
+ on_vm_change = props[:on_vm_change]
121
+ on_vm_change.call(node[:value].to_s) if on_vm_change
122
+ end
123
+
124
+ def handle_usb_console_change(event)
125
+ node = refs[:usb_console_checkbox]
126
+ return unless node
127
+ on_usb_console_change = props[:on_usb_console_change]
128
+ on_usb_console_change.call(truthy?(node[:checked])) if on_usb_console_change
129
+ end
130
+
131
+ private
132
+
133
+ def render_platform_select
134
+ label(class: 'build-option') do
135
+ span { 'ターゲット' }
136
+ tag(:select, ref: :platform_select, onchange: :handle_platform_change) { render_platform_options }
137
+ end
138
+ end
139
+
140
+ def render_platform_options
141
+ if props[:platform].to_s.empty?
142
+ tag(:option, value: '', selected: true) { '未選択' }
143
+ else
144
+ tag(:option, value: '') { '未選択' }
145
+ end
146
+
147
+ PLATFORM_GROUPS.each do |group_label, options|
148
+ tag(:optgroup, label: group_label) do
149
+ options.each do |value, label_text|
150
+ if value == props[:platform]
151
+ tag(:option, value: value, selected: true) { label_text }
152
+ else
153
+ tag(:option, value: value) { label_text }
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ def render_vm_select
161
+ label(class: 'build-option') do
162
+ span { 'VM' }
163
+ tag(:select, ref: :vm_select, onchange: :handle_vm_change) { render_vm_options }
164
+ end
165
+ end
166
+
167
+ def render_vm_options
168
+ VM_OPTIONS.each do |value, label_text|
169
+ next unless ProjectSettingsDialog.vm_allowed?(props[:platform], value)
170
+
171
+ if value == props[:vm]
172
+ tag(:option, value: value, selected: true) { label_text }
173
+ else
174
+ tag(:option, value: value) { label_text }
175
+ end
176
+ end
177
+ end
178
+
179
+ def render_usb_console_checkbox
180
+ label(class: 'build-option') do
181
+ render_usb_console_input
182
+ span { 'USB Console(外部UART変換チップなしのボード向け)' }
183
+ end
184
+ end
185
+
186
+ def render_pico_note
187
+ div(class: 'build-option-note') do
188
+ span { PICO_NOTE }
189
+ end
190
+ end
191
+
192
+ def render_usb_console_input
193
+ if props[:usb_console]
194
+ tag(:input, type: 'checkbox', ref: :usb_console_checkbox, checked: true, onchange: :handle_usb_console_change)
195
+ else
196
+ tag(:input, type: 'checkbox', ref: :usb_console_checkbox, onchange: :handle_usb_console_change)
197
+ end
198
+ end
199
+
200
+ def truthy?(js_value)
201
+ js_value.to_s == 'true'
202
+ end
203
+ end
@@ -0,0 +1,76 @@
1
+ # 1行のテキスト入力を伴う汎用的な確認ダイアログ。
2
+ #
3
+ # 「新しいプロジェクト」「mrbgemを追加」「新規ファイル」はどれも
4
+ # 名前(パス)を1つ入力させて確定/キャンセルさせるだけなので、用途ごとに
5
+ # 別コンポーネントを作らずタイトル/ラベル/プレースホルダ/確定ボタンの
6
+ # ラベルをpropsで差し替えて使い回す。
7
+ #
8
+ # 入力欄はref経由でDOM値を直接読む非制御コンポーネントにしてある。
9
+ # ビルドログのポーリング等、ダイアログを開いたまま親(EditorApp)が
10
+ # 再描画される場面があるため、value を props 経由で毎回書き戻す制御方式にすると
11
+ # その再描画のたびに入力中の文字が消えてしまう(他のフォーム系コンポーネントと
12
+ # 同じ理由。project_settings_dialog.rb / editor_app.rb 参照)。
13
+ #
14
+ # props:
15
+ # title: ダイアログの見出し
16
+ # label: 入力欄のラベル
17
+ # placeholder: 入力欄のプレースホルダ
18
+ # confirm_label: 確定ボタンのラベル(省略時 'OK')
19
+ # error: エラーメッセージ(あれば表示)
20
+ # on_confirm: 入力値(String)を渡して呼ばれるlambda
21
+ # on_cancel: キャンセル時に呼ばれるlambda
22
+ class PromptDialog < Funicular::Component
23
+ def render
24
+ div(class: 'dialog-overlay') do
25
+ div(class: 'dialog prompt-dialog') do
26
+ h2 { props[:title].to_s }
27
+ label(class: 'prompt-field') do
28
+ span { props[:label].to_s }
29
+ tag(:input, type: 'text', ref: :value_input, placeholder: props[:placeholder].to_s, onkeydown: :handle_keydown)
30
+ end
31
+ render_error
32
+ div(class: 'dialog-actions') do
33
+ button(class: 'dialog-cancel', onclick: :handle_cancel) { 'キャンセル' }
34
+ button(class: 'dialog-confirm', onclick: :handle_confirm) { confirm_label }
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def handle_cancel(event)
41
+ event.preventDefault
42
+ on_cancel = props[:on_cancel]
43
+ on_cancel.call if on_cancel
44
+ end
45
+
46
+ def handle_confirm(event)
47
+ event.preventDefault
48
+ confirm
49
+ end
50
+
51
+ # Enterキーでも確定できるようにする(<form>で囲んでいないのでブラウザ標準の
52
+ # submit挙動とは衝突しない)。
53
+ def handle_keydown(event)
54
+ confirm if event[:key].to_s == 'Enter'
55
+ end
56
+
57
+ private
58
+
59
+ def confirm
60
+ node = refs[:value_input]
61
+ return unless node
62
+
63
+ on_confirm = props[:on_confirm]
64
+ on_confirm.call(node[:value].to_s) if on_confirm
65
+ end
66
+
67
+ def confirm_label
68
+ label = props[:confirm_label].to_s
69
+ label.empty? ? 'OK' : label
70
+ end
71
+
72
+ def render_error
73
+ return if props[:error].to_s.empty?
74
+ div(class: 'prompt-error') { props[:error].to_s }
75
+ end
76
+ end
@@ -0,0 +1,30 @@
1
+ # エディタ上部のツールバー(ファイル名 / 保存ボタン / ステータス表示)。
2
+ #
3
+ # こちらも表示専用。保存の実処理は親が持ち、props[:on_save] 経由で呼び出す。
4
+ class Toolbar < Funicular::Component
5
+ def render
6
+ div(class: 'toolbar') do
7
+ span(class: 'current-file') { props[:current_path] || 'ファイルを選択してください' }
8
+ render_save_button
9
+ span(class: "status #{props[:status_kind]}") { props[:status].to_s }
10
+ end
11
+ end
12
+
13
+ def handle_save(event)
14
+ event.preventDefault
15
+ on_save = props[:on_save]
16
+ on_save.call if on_save
17
+ end
18
+
19
+ private
20
+
21
+ # disabled は「属性を付けない/付ける」で切り替える。
22
+ # false を渡す書き方に依存しないよう、分岐で二通りの要素を書き分けている。
23
+ def render_save_button
24
+ if props[:dirty]
25
+ button(class: 'save', onclick: :handle_save) { '保存 (Ctrl+S)' }
26
+ else
27
+ button(class: 'save', disabled: true) { '保存 (Ctrl+S)' }
28
+ end
29
+ end
30
+ end