Skip to content

Commit c8d8e31

Browse files
committed
feat(client): the SFTP dialog is measured and translated (UI/UX v3 N-4c)
転送ダイアログは 5 つの英語リテラルを直接描いており、8 locale のどれにも キーが無かった。同時にフィールド列は `cell_w * 8.0` 固定で、`Remote:` (7 セル) が収まるだけの幅しかない。翻訳だけ先に入れると最初の非英語 locale で溢れ、測定だけ先に入れても 3 文字列は ramp の恩恵を受けない。 両方を同時に行う。 - 6 キーを 8 locale に追加。キーボードヒントはタイトルから分離した (3 つのショートカットを列挙するものはタイトルではなく、ramp 上も別段) - ラベル列は 3 ラベルの実測最大。パネル幅はそこから導出し、`sw - 4 cells` で clamp する(従来は 56 桁固定で、狭い窓では両端がはみ出していた) - パネル高さも導出に変えた。タイトルを ramp の `title` (28/36) で描くため、 7 行固定のままでは小さいフォントで見切れる - 値(パス)を `truncate_run_to_width` で切る。従来は無制限で、長いパスが フィールドとパネルを突き抜けて描かれていた 副作用として picker.rs から add_string_verts が未使用になった。3 つのリスト ピッカーは P4e で移行済みのため、SFTP がこのファイル最後の cell path 利用者 だった。 Spec: docs/plans/2026-08-30-n4-menus-and-dialogs.md §2 D3/D4/D6, §5.4
1 parent f994132 commit c8d8e31

10 files changed

Lines changed: 227 additions & 45 deletions

File tree

docs/plans/2026-08-30-n4-menus-and-dialogs.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,29 @@ damage; it does not explain the cause, and this devcontainer cannot reproduce it
547547
(`fc-list :lang=ja` resolves to zero faces here, so CJK never reaches a real CJK
548548
face). Carried to §8.
549549

550+
### 5.4 As built (N-4c)
551+
552+
The six keys landed, the panel derives its width, and the value truncates.
553+
Three notes:
554+
555+
- **The title's ramp step forced the panel's height to be derived too.** §2 D6
556+
put the title at `title` (28/36), which is nearly two terminal cells tall, so
557+
a panel declared as `7.0` rows would have clipped it on a small font. Height
558+
is now `title_lh + 3 rows + hint_lh + padding`, read from
559+
`font.chrome_metrics`. The width was always going to be derived; the height
560+
came along because the ramp made the old constant wrong.
561+
- **`picker.rs` left the cell path.** As with `dialog.rs` in N-4b, removing the
562+
transfer dialog's `add_string_verts` calls made the import unused — the three
563+
list pickers had moved in P4e, so SFTP was the file's last cell-path
564+
consumer.
565+
- **The gates needed scoping twice**, and the second time is the interesting
566+
one. A file-wide scan for `cell_w * 8.0` failed on a clean tree, because that
567+
expression is a legitimate `min_detail_w` in the host-manager builder next
568+
door. The gate now reads only `build_file_transfer_verts`, in the shape
569+
`tab_layout`'s `tab_region` established for the same reason. (The first
570+
scoping was duller: the test scans its own file, so the literals naming what
571+
must not return matched themselves.)
572+
550573
---
551574

552575
## 6. Verification

nexterm-client-gpu/src/renderer/overlay/picker.rs

Lines changed: 156 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use crate::font::FontManager;
77
use crate::glyph_atlas::{BgVertex, GlyphAtlas, TextVertex};
88
use crate::state::ClientState;
9-
use crate::vertex_util::{add_px_rect, add_run_verts, add_string_verts, measure_run};
9+
use crate::vertex_util::{add_px_rect, add_run_verts, measure_run, truncate_run_to_width};
1010

1111
use super::super::WgpuState;
1212
use super::util::{draw_overlay_panel, semantic_fill};
@@ -240,14 +240,44 @@ impl WgpuState {
240240
text_idx: &mut Vec<u16>,
241241
) {
242242
let ft = &state.file_transfer;
243-
let panel_cols: f32 = 56.0;
244-
let panel_rows: f32 = 7.0; // title + host + local + remote + hint
245-
246-
let pw = panel_cols * cell_w;
247-
let ph = panel_rows * cell_h;
243+
let metrics = nexterm_config::MetricTokens::default();
244+
let title_style = &metrics.type_ramp.title;
245+
let body_style = &metrics.type_ramp.body;
246+
let hint_style = &metrics.type_ramp.caption;
247+
248+
// UI/UX v3 N-4c. The panel used to declare its width in columns and
249+
// put its fields at a fixed eight-cell offset, which fits `Remote:`
250+
// (7 cells) and nothing longer. The labels are localised now —
251+
// `Entfernt:`, `リモート:` — so the column is measured and the panel is
252+
// derived from it rather than the other way round.
253+
let labels = [
254+
nexterm_i18n::fl!("sftp-field-host"),
255+
nexterm_i18n::fl!("sftp-field-local"),
256+
nexterm_i18n::fl!("sftp-field-remote"),
257+
];
258+
let label_col_w = labels
259+
.iter()
260+
.map(|l| measure_run(l, body_style, font))
261+
.fold(0.0_f32, f32::max);
262+
263+
let pad = cell_w;
264+
let gap = cell_w * 0.5;
265+
// A path field wants room; this is the floor, not the width. The panel
266+
// grows past it whenever the label column does.
267+
let field_min_w = cell_w * 40.0;
268+
let (_, title_lh, _) = font.chrome_metrics(title_style);
269+
let (_, hint_lh, _) = font.chrome_metrics(hint_style);
270+
let row_pitch = cell_h * 1.5;
271+
272+
let pw = (pad + label_col_w + gap + field_min_w + pad).min(sw - cell_w * 4.0);
273+
let ph = title_lh + cell_h * 0.4 + row_pitch * 3.0 + hint_lh + cell_h * 0.6;
248274
let px = (sw - pw) / 2.0;
249275
let py = (sh - ph) / 2.0;
250276

277+
let field_x = px + pad + label_col_w + gap;
278+
let field_w = (px + pw - pad - field_x).max(cell_w);
279+
let fields_top = py + title_lh + cell_h * 0.4;
280+
251281
// Panel chrome: drop-shadow + border ring + rounded background.
252282
let elevation = nexterm_config::ElevationScale::default().flyout;
253283
draw_overlay_panel(
@@ -272,33 +302,34 @@ impl WgpuState {
272302
};
273303
add_px_rect(px, py, pw, 2.0, accent, sw, sh, bg_verts, bg_idx);
274304

275-
// Title
305+
// Title. The keyboard shortcuts used to be concatenated onto the end of
306+
// it; they are their own line now (`sftp-hint`, drawn at the bottom),
307+
// because a title that also documents three shortcuts is not a title
308+
// and the two belong at different steps of the ramp.
276309
let title = if ft.mode == "upload" {
277-
"SFTP Upload (Tab=next, Enter=send, Esc=cancel)"
310+
nexterm_i18n::fl!("sftp-title-upload")
278311
} else {
279-
"SFTP Download (Tab=next, Enter=send, Esc=cancel)"
312+
nexterm_i18n::fl!("sftp-title-download")
280313
};
281-
add_string_verts(
282-
title,
283-
px + cell_w,
314+
add_run_verts(
315+
&title,
316+
title_style,
317+
px + pad,
284318
py + cell_h * 0.1,
285319
accent,
286-
true,
287320
sw,
288321
sh,
289-
cell_w,
290322
font,
291323
atlas,
292324
&self.queue,
293325
text_verts,
294326
text_idx,
295327
);
296328

297-
let field_labels = ["Host:", "Local:", "Remote:"];
298329
let field_values = [&ft.host_name, &ft.local_path, &ft.remote_path];
299330

300-
for (i, (label, value)) in field_labels.iter().zip(field_values.iter()).enumerate() {
301-
let row_y = py + cell_h * (i as f32 * 1.5 + 1.3);
331+
for (i, (label, value)) in labels.iter().zip(field_values.iter()).enumerate() {
332+
let row_y = fields_top + row_pitch * i as f32;
302333
let is_active = i == ft.field;
303334

304335
// Field background: surface_2 when active (highlighted), surface_1 otherwise.
@@ -308,64 +339,71 @@ impl WgpuState {
308339
tokens.surface_1
309340
};
310341
add_px_rect(
311-
px + cell_w * 8.0,
312-
row_y,
313-
pw - cell_w * 9.0,
314-
cell_h,
315-
field_bg,
316-
sw,
317-
sh,
318-
bg_verts,
319-
bg_idx,
342+
field_x, row_y, field_w, cell_h, field_bg, sw, sh, bg_verts, bg_idx,
320343
);
321344

345+
let fg = if is_active {
346+
tokens.text_on(SurfaceLevel::S2).primary
347+
} else {
348+
tokens.text_on(SurfaceLevel::S2).secondary
349+
};
350+
322351
// Label
323-
add_string_verts(
352+
add_run_verts(
324353
label,
325-
px + cell_w,
354+
body_style,
355+
px + pad,
326356
row_y,
327-
if is_active {
328-
tokens.text_on(SurfaceLevel::S2).primary
329-
} else {
330-
tokens.text_on(SurfaceLevel::S2).secondary
331-
},
332-
is_active,
357+
fg,
333358
sw,
334359
sh,
335-
cell_w,
336360
font,
337361
atlas,
338362
&self.queue,
339363
text_verts,
340364
text_idx,
341365
);
342366

343-
// Input value + cursor
367+
// Input value + cursor. A path is the one string here that has no
368+
// bound, and it used to draw straight past the field and the panel.
369+
// Truncation shares the measurement the field width came from.
344370
let display = if is_active {
345371
format!("{}_", value)
346372
} else {
347373
value.to_string()
348374
};
349-
add_string_verts(
375+
let display = truncate_run_to_width(&display, body_style, field_w - gap, font);
376+
add_run_verts(
350377
&display,
351-
px + cell_w * 8.5,
378+
body_style,
379+
field_x + gap * 0.5,
352380
row_y,
353-
if is_active {
354-
tokens.text_on(SurfaceLevel::S2).primary
355-
} else {
356-
tokens.text_on(SurfaceLevel::S2).secondary
357-
},
358-
false,
381+
fg,
359382
sw,
360383
sh,
361-
cell_w,
362384
font,
363385
atlas,
364386
&self.queue,
365387
text_verts,
366388
text_idx,
367389
);
368390
}
391+
392+
// Keyboard hint, on its own line at the foot of the panel.
393+
add_run_verts(
394+
&nexterm_i18n::fl!("sftp-hint"),
395+
hint_style,
396+
px + pad,
397+
py + ph - hint_lh - cell_h * 0.3,
398+
tokens.text_on(SurfaceLevel::S2).muted,
399+
sw,
400+
sh,
401+
font,
402+
atlas,
403+
&self.queue,
404+
text_verts,
405+
text_idx,
406+
);
369407
}
370408

371409
/// Build vertices for the Lua macro picker (center floating list)
@@ -885,4 +923,77 @@ mod tests {
885923
name_column_width owns that alignment"
886924
);
887925
}
926+
927+
/// G-i18n (SFTP half): the transfer dialog's strings are localised.
928+
///
929+
/// Five English literals used to be built into this builder with no key in
930+
/// any of the eight locales — the title (both modes) and the three field
931+
/// labels — while the panel put its fields at a fixed `cell_w * 8.0`, wide
932+
/// enough for `Remote:` and nothing longer. Translating without measuring
933+
/// would have overrun the field on the first non-English locale, so the
934+
/// two landed together (UI/UX v3 N-4c, spec §1.3).
935+
/// The file's own body, excluding this test module.
936+
///
937+
/// The gates below scan `picker.rs` for literals that must not come back,
938+
/// and the literals naming them live in this module — so a whole-file scan
939+
/// would match itself and fail on a clean tree.
940+
fn builder_src() -> &'static str {
941+
include_str!("picker.rs")
942+
.split("#[cfg(test)]")
943+
.next()
944+
.expect("the file has a body before its tests")
945+
}
946+
947+
/// Just the transfer dialog's builder.
948+
///
949+
/// Bounded deliberately: `cell_w * 8.0` is a legitimate `min_detail_w` in
950+
/// the host-manager builder next door, so a file-wide scan for it would
951+
/// fail on code N-4c has no quarrel with.
952+
fn transfer_builder_src() -> &'static str {
953+
let src = builder_src();
954+
let start = src
955+
.find("fn build_file_transfer_verts")
956+
.expect("the transfer builder exists");
957+
let rest = &src[start..];
958+
let end = rest
959+
.find("fn build_macro_picker_verts")
960+
.expect("the macro picker follows it");
961+
&rest[..end]
962+
}
963+
964+
#[test]
965+
fn the_transfer_dialog_holds_no_untranslated_string() {
966+
let src = builder_src();
967+
for literal in [
968+
"\"SFTP Upload",
969+
"\"SFTP Download",
970+
"\"Host:\"",
971+
"\"Local:\"",
972+
"\"Remote:\"",
973+
"Tab=next",
974+
] {
975+
assert!(
976+
!src.contains(literal),
977+
"picker.rs draws the literal {literal}; SFTP strings go \
978+
through fl! and all eight locales"
979+
);
980+
}
981+
}
982+
983+
/// The field column is measured, not declared. `cell_w * 8.0` fits
984+
/// `Remote:` (7 cells) and overflows on `Entfernter Pfad:` or
985+
/// `リモート:`; the panel derives its width from the widest label now.
986+
#[test]
987+
fn the_transfer_dialog_does_not_place_its_fields_at_a_fixed_column() {
988+
let src = transfer_builder_src();
989+
assert!(
990+
!src.contains("cell_w * 8.0"),
991+
"the transfer dialog pins its field column to a cell count again"
992+
);
993+
assert!(
994+
!src.contains("panel_cols: f32 = 56.0"),
995+
"the transfer dialog declares a panel width again; it is derived \
996+
from the measured label column and clamped to the window"
997+
);
998+
}
888999
}

nexterm-i18n/locales/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Lua-Makro-Auswahl",
4949
"palette-sftp-upload": "SFTP-Upload...",
5050
"palette-sftp-download": "SFTP-Download...",
51+
"sftp-title-upload": "SFTP-Upload",
52+
"sftp-title-download": "SFTP-Download",
53+
"sftp-hint": "Tab: nächstes Feld Enter: senden Esc: abbrechen",
54+
"sftp-field-host": "Host:",
55+
"sftp-field-local": "Lokal:",
56+
"sftp-field-remote": "Entfernt:",
5157
"palette-show-settings": "Einstellungen öffnen",
5258
"palette-jump-prev-prompt": "Zur vorherigen Eingabeaufforderung springen",
5359
"palette-jump-next-prompt": "Zur nächsten Eingabeaufforderung springen",

nexterm-i18n/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Lua Macro Picker",
4949
"palette-sftp-upload": "SFTP Upload...",
5050
"palette-sftp-download": "SFTP Download...",
51+
"sftp-title-upload": "SFTP Upload",
52+
"sftp-title-download": "SFTP Download",
53+
"sftp-hint": "Tab: next field Enter: send Esc: cancel",
54+
"sftp-field-host": "Host:",
55+
"sftp-field-local": "Local:",
56+
"sftp-field-remote": "Remote:",
5157
"palette-show-settings": "Open Settings",
5258
"palette-jump-prev-prompt": "Jump to previous prompt",
5359
"palette-jump-next-prompt": "Jump to next prompt",

nexterm-i18n/locales/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Selector de macros Lua",
4949
"palette-sftp-upload": "Subir por SFTP...",
5050
"palette-sftp-download": "Descargar por SFTP...",
51+
"sftp-title-upload": "Subir por SFTP",
52+
"sftp-title-download": "Descargar por SFTP",
53+
"sftp-hint": "Tab: siguiente campo Enter: enviar Esc: cancelar",
54+
"sftp-field-host": "Host:",
55+
"sftp-field-local": "Local:",
56+
"sftp-field-remote": "Remoto:",
5157
"palette-show-settings": "Abrir configuración",
5258
"palette-jump-prev-prompt": "Saltar al prompt anterior",
5359
"palette-jump-next-prompt": "Saltar al prompt siguiente",

nexterm-i18n/locales/fr.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Sélecteur de macros Lua",
4949
"palette-sftp-upload": "Upload SFTP...",
5050
"palette-sftp-download": "Télécharger SFTP...",
51+
"sftp-title-upload": "Upload SFTP",
52+
"sftp-title-download": "Téléchargement SFTP",
53+
"sftp-hint": "Tab : champ suivant Entrée : envoyer Échap : annuler",
54+
"sftp-field-host": "Hôte :",
55+
"sftp-field-local": "Local :",
56+
"sftp-field-remote": "Distant :",
5157
"palette-show-settings": "Ouvrir les paramètres",
5258
"palette-jump-prev-prompt": "Aller à l'invite précédente",
5359
"palette-jump-next-prompt": "Aller à l'invite suivante",

nexterm-i18n/locales/it.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Selettore macro Lua",
4949
"palette-sftp-upload": "Carica SFTP...",
5050
"palette-sftp-download": "Scarica SFTP...",
51+
"sftp-title-upload": "Carica SFTP",
52+
"sftp-title-download": "Scarica SFTP",
53+
"sftp-hint": "Tab: campo successivo Invio: invia Esc: annulla",
54+
"sftp-field-host": "Host:",
55+
"sftp-field-local": "Locale:",
56+
"sftp-field-remote": "Remoto:",
5157
"palette-show-settings": "Apri impostazioni",
5258
"palette-jump-prev-prompt": "Vai al prompt precedente",
5359
"palette-jump-next-prompt": "Vai al prompt successivo",

nexterm-i18n/locales/ja.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"palette-show-macro-picker": "Lua マクロピッカー",
4949
"palette-sftp-upload": "SFTP アップロード...",
5050
"palette-sftp-download": "SFTP ダウンロード...",
51+
"sftp-title-upload": "SFTP アップロード",
52+
"sftp-title-download": "SFTP ダウンロード",
53+
"sftp-hint": "Tab: 次の項目 Enter: 送信 Esc: キャンセル",
54+
"sftp-field-host": "ホスト:",
55+
"sftp-field-local": "ローカル:",
56+
"sftp-field-remote": "リモート:",
5157
"palette-show-settings": "設定を開く",
5258
"palette-jump-prev-prompt": "前のプロンプトへジャンプ",
5359
"palette-jump-next-prompt": "次のプロンプトへジャンプ",

0 commit comments

Comments
 (0)