66use crate :: font:: FontManager ;
77use crate :: glyph_atlas:: { BgVertex , GlyphAtlas , TextVertex } ;
88use 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
1111use super :: super :: WgpuState ;
1212use 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}
0 commit comments