@@ -3,10 +3,9 @@ use std::io::{Write, stdout};
33use crossterm:: {
44 cursor:: { self , MoveToColumn } ,
55 event:: { self , Event , KeyCode , KeyEvent , KeyEventKind , KeyModifiers } ,
6- style:: { Attribute , SetAttribute } ,
6+ style:: { Attribute , Color , ResetColor , SetAttribute , SetForegroundColor } ,
77 terminal:: { self , Clear , ClearType } ,
88} ;
9- use owo_colors:: { OwoColorize , Stream } ;
109
1110use crate :: { RenderState , SelectItem , fuzzy:: fuzzy_match} ;
1211
@@ -144,21 +143,21 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho
144143 lines += 1 ;
145144 }
146145
146+ let is_interactive = query. is_some ( ) ;
147+
147148 // Prompt line (interactive only)
148149 if let Some ( q) = query {
149- let bold = SetAttribute ( Attribute :: Bold ) ;
150- let reset = SetAttribute ( Attribute :: Reset ) ;
151150 // Print ": " separator before query only when query is non-empty,
152151 // to avoid a trailing space that Windows ConPTY would strip.
153152 if q. is_empty ( ) {
154153 write ! (
155154 writer,
156- "{bold}Search task{reset} (\u{2191} /\u{2193} to move, enter to select ):{line_ending}" ,
155+ "Select a task (\u{2191} /\u{2193} , Enter to run, Esc to clear ):{line_ending}" ,
157156 ) ?;
158157 } else {
159158 write ! (
160159 writer,
161- "{bold}Search task{reset} (\u{2191} /\u{2193} to move, enter to select ): {q}{line_ending}" ,
160+ "Select a task (\u{2191} /\u{2193} , Enter to run, Esc to clear ): {q}{line_ending}" ,
162161 ) ?;
163162 }
164163 lines += 1 ;
@@ -171,8 +170,12 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho
171170 let is_selected = * selected_in_filtered == Some ( vi) ;
172171
173172 // Truncate description to prevent line wrapping.
174- // Line layout: prefix (2: "> " or " ") + label + ": " (2) + description
175- let prefix_and_label_width = 2 + item. label . chars ( ) . count ( ) + 2 ;
173+ // Line layout:
174+ // - interactive prefix is " › " or " " (4 chars)
175+ // - non-interactive prefix is " " (2 chars)
176+ // then label + ": " + description
177+ let prefix_width = if is_interactive { 4 } else { 2 } ;
178+ let prefix_and_label_width = prefix_width + item. label . chars ( ) . count ( ) + 2 ;
176179 let max_desc_chars = params. max_line_width . saturating_sub ( prefix_and_label_width) ;
177180 let desc_str = item. description . as_str ( ) ;
178181 let desc_char_count = desc_str. chars ( ) . count ( ) ;
@@ -186,18 +189,37 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho
186189 } else {
187190 desc_str
188191 } ;
189- let desc = display_desc. if_supports_color ( Stream :: Stdout , |s| s. cyan ( ) ) ;
190192
191- if is_selected {
193+ if is_selected && is_interactive {
194+ write ! (
195+ writer,
196+ "{blue}{bold} \u{203a} {label}: {desc}{reset}{line_ending}" ,
197+ blue = SetForegroundColor ( Color :: Blue ) ,
198+ bold = SetAttribute ( Attribute :: Bold ) ,
199+ label = item. label,
200+ desc = display_desc,
201+ reset = SetAttribute ( Attribute :: Reset ) ,
202+ ) ?;
203+ } else if is_interactive {
204+ write ! (
205+ writer,
206+ "{marker_color} {reset_color}{}:{command_color} {display_desc}{reset_color}{line_ending}" ,
207+ item. label,
208+ marker_color = SetForegroundColor ( Color :: DarkGrey ) ,
209+ command_color = SetForegroundColor ( Color :: Grey ) ,
210+ reset_color = ResetColor ,
211+ ) ?;
212+ } else if is_selected {
192213 write ! (
193214 writer,
194215 "{bold}> {label}: {desc}{reset}{line_ending}" ,
195216 bold = SetAttribute ( Attribute :: Bold ) ,
196217 label = item. label,
218+ desc = display_desc,
197219 reset = SetAttribute ( Attribute :: Reset ) ,
198220 ) ?;
199221 } else {
200- write ! ( writer, " {}: {desc }{line_ending}" , item. label) ?;
222+ write ! ( writer, " {}: {display_desc }{line_ending}" , item. label) ?;
201223 }
202224 lines += 1 ;
203225 }
@@ -401,6 +423,33 @@ mod tests {
401423 strip_ansi ( & String :: from_utf8 ( buf) . unwrap ( ) )
402424 }
403425
426+ #[ expect( clippy:: disallowed_types, reason = "test helper building arbitrary output string" ) ]
427+ fn render_interactive_to_string (
428+ items : & [ SelectItem ] ,
429+ query : & str ,
430+ max_line_width : usize ,
431+ ) -> String {
432+ let filtered: Vec < usize > = ( 0 ..items. len ( ) ) . collect ( ) ;
433+ let len = filtered. len ( ) ;
434+ let mut buf = Vec :: new ( ) ;
435+ render_items (
436+ & mut buf,
437+ & RenderParams {
438+ items,
439+ filtered : & filtered,
440+ selected_in_filtered : Some ( 0 ) ,
441+ visible_range : 0 ..len,
442+ hidden_count : 0 ,
443+ header : None ,
444+ query : Some ( query) ,
445+ line_ending : "\n " ,
446+ max_line_width,
447+ } ,
448+ )
449+ . unwrap ( ) ;
450+ strip_ansi ( & String :: from_utf8 ( buf) . unwrap ( ) )
451+ }
452+
404453 #[ test]
405454 fn truncates_long_description ( ) {
406455 let items = make_items ( & [ ( "build" , "a]really long command that exceeds the width limit" ) ] ) ;
@@ -463,4 +512,17 @@ mod tests {
463512 let line = output. lines ( ) . next ( ) . unwrap ( ) ;
464513 assert ! ( line. contains( "my-task" ) , "label should always be preserved: {line:?}" ) ;
465514 }
515+
516+ #[ test]
517+ fn interactive_style_matches_vp_selector_marker_and_indent ( ) {
518+ let items = make_items ( & [ ( "build" , "echo build" ) , ( "lint" , "echo lint" ) ] ) ;
519+ let output = render_interactive_to_string ( & items, "" , 80 ) ;
520+ let mut lines = output. lines ( ) ;
521+ let prompt = lines. next ( ) . unwrap ( ) ;
522+ let selected = lines. next ( ) . unwrap ( ) ;
523+ let unselected = lines. next ( ) . unwrap ( ) ;
524+ assert_eq ! ( prompt, "Select a task (↑/↓, Enter to run, Esc to clear):" ) ;
525+ assert_eq ! ( selected, " › build: echo build" ) ;
526+ assert_eq ! ( unselected, " lint: echo lint" ) ;
527+ }
466528}
0 commit comments