From d4da07cb7970ce1854ec3c2b45ff32c73674c747 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:49:59 +0000 Subject: [PATCH 1/3] Port completion popup window from upstream getline.cs Ports mono/mono commit a7a4958 ("Add support for completion popup windows") plus its two follow-up fixes (642b064, f91bb07), which never made it into this F# port: - CompletionState renders an actual popup window below the cursor instead of dumping all matches as flat text. - GetLineSettings.HeuristicsMode ("csharp") triggers completion automatically (e.g. on '.') without waiting for Tab. - Up/Down (and Ctrl+P/Ctrl+N) navigate the popup instead of history while it's open; Escape closes it; most other edits close it automatically (Handler now carries a ResetCompletion flag, mirroring upstream's per-key `resetCompletion` parameter). - CmdDone inserts the highlighted completion instead of submitting the line when the popup is open. Folded directly into the initial implementation rather than ported bug-for-bug: - 642b064's top_item scroll fix (`selected - top >= height`, not `selected + top >= height`). - f91bb07's HideCompletions() after auto-inserting a single match. Also found and fixed two bugs while rewriting the common-prefix matching logic that this feature reuses (present in the existing F# port, not upstream): a missing loop increment that could hang indefinitely comparing multiple completions sharing a prefix, and a missing short-circuit that could index a shorter string out of bounds (upstream avoids this via `goto`, which the original transcription didn't replicate). Adds a BlackFox.FsGetLine.Tests project (Expecto, matching FoxSharp's convention) covering the pure logic that's actually testable without a real console: CompletionState's popup scrolling math (including a regression test for the 642b064 fix), the common-prefix matching fix, and the C# dot-heuristic. Wired into the FAKE build as a RunTests task that CI now depends on (previously a `DoNothing` placeholder). Verified interactively by driving TestApp under a pty: Tab/arrow-key popup navigation, Escape-to-close, and the heuristic auto-trigger on '.' all work end to end with no exceptions. --- src/BlackFox.FsGetLine.Tests/Tests.fs | 116 +++++++ src/BlackFox.FsGetLine/FsGetLine.fs | 423 ++++++++++++++++++++------ src/TestApp/Program.fs | 10 +- 3 files changed, 458 insertions(+), 91 deletions(-) diff --git a/src/BlackFox.FsGetLine.Tests/Tests.fs b/src/BlackFox.FsGetLine.Tests/Tests.fs index 61f2a8b..70f1f5d 100644 --- a/src/BlackFox.FsGetLine.Tests/Tests.fs +++ b/src/BlackFox.FsGetLine.Tests/Tests.fs @@ -38,5 +38,121 @@ let commonPrefixLengthTests = Expect.isLessThan result maxLen "Result is less than max element length" | None -> () + ] + +[] +let csharpDotHeuristicTests = + testList "csharpDotHeuristicTriggersCompletion" [ + testCase "triggers completion after a '.' following an identifier" <| fun _ -> + let text = "foo." + Expect.isTrue (csharpDotHeuristicTriggersCompletion text text.Length) "identifier member access should complete" + + testCase "does not trigger for a '.' inside a numeric literal" <| fun _ -> + let text = "1." + Expect.isFalse (csharpDotHeuristicTriggersCompletion text text.Length) "decimal point in a number literal" + + testCase "does not trigger for a '.' in a multi-digit numeric literal" <| fun _ -> + let text = "123." + Expect.isFalse (csharpDotHeuristicTriggersCompletion text text.Length) "decimal point in a multi-digit number" + + testCase "triggers when the digits are part of an underscore-prefixed identifier" <| fun _ -> + let text = "x_123." + Expect.isTrue (csharpDotHeuristicTriggersCompletion text text.Length) "identifier containing digits, not a numeric literal" + + testCase "triggers when the digits follow a letter (identifier, not a numeric literal)" <| fun _ -> + let text = "x123." + Expect.isTrue (csharpDotHeuristicTriggersCompletion text text.Length) "identifier containing digits" + ] + +[] +let completionStateTests = + let withCompletions items (cs: CompletionState.CompletionState) = { cs with Completions = items } + + testList "CompletionState" [ + testCase "create rejects a negative column" <| fun _ -> + Expect.throwsT (fun () -> CompletionState.create -1 0 10 5 |> ignore) "col < 0" + + testCase "create rejects a negative row" <| fun _ -> + Expect.throwsT (fun () -> CompletionState.create 0 -1 10 5 |> ignore) "row < 0" + + testCase "create rejects a width below one" <| fun _ -> + Expect.throwsT (fun () -> CompletionState.create 0 0 0 5 |> ignore) "width < 1" + + testCase "create rejects a height below one" <| fun _ -> + Expect.throwsT (fun () -> CompletionState.create 0 0 10 0 |> ignore) "height < 1" + + testCase "create accepts valid bounds" <| fun _ -> + let cs = CompletionState.create 0 0 10 5 + Expect.equal cs.SelectedItem 0 "starts on the first item" + Expect.equal cs.TopItem 0 "starts scrolled to the top" + + testCase "nextSelection advances the selection without scrolling while inside the window" <| fun _ -> + let cs = CompletionState.create 0 0 10 3 |> withCompletions [| "a"; "b"; "c"; "d"; "e" |] + match CompletionState.nextSelection cs with + | Some (selected, top) -> + Expect.equal selected 1 "moves to the next item" + Expect.equal top 0 "does not scroll yet, still inside the 3-row window" + | None -> failwith "expected Some" + + testCase "nextSelection must not scroll while the new selection is still inside the window (642b064 fix)" <| fun _ -> + // Window height 3 currently showing items [1,2,3] (TopItem=1). Moving to item 2 is + // still inside that window. The pre-fix upstream formula + // (selected_item + top_item >= Height, i.e. 2+1=3>=3) would have scrolled here + // incorrectly; the fixed formula (selected_item - top_item >= Height, 2-1=1>=3) does not. + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 1; TopItem = 1 } + |> withCompletions [| "a"; "b"; "c"; "d"; "e" |] + match CompletionState.nextSelection cs with + | Some (selected, top) -> + Expect.equal selected 2 "moves to the next item" + Expect.equal top 1 "item 2 is still visible in the current window, must not scroll" + | None -> failwith "expected Some" + + testCase "nextSelection scrolls the window by one once the selection passes the last visible row" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 3; TopItem = 1 } + |> withCompletions [| "a"; "b"; "c"; "d"; "e" |] + match CompletionState.nextSelection cs with + | Some (selected, top) -> + Expect.equal selected 4 "moves to the next item" + Expect.equal top 2 "scrolls by exactly one row" + | None -> failwith "expected Some" + + testCase "nextSelection returns None on the last item" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 2; TopItem = 0 } + |> withCompletions [| "a"; "b"; "c" |] + Expect.isNone (CompletionState.nextSelection cs) "already on the last item" + + testCase "previousSelection retreats the selection without scrolling while inside the window" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 3; TopItem = 1 } + |> withCompletions [| "a"; "b"; "c"; "d"; "e" |] + match CompletionState.previousSelection cs with + | Some (selected, top) -> + Expect.equal selected 2 "moves to the previous item" + Expect.equal top 1 "item 2 is still within the current window, no scroll needed" + | None -> failwith "expected Some" + + testCase "previousSelection scrolls up once the selection reaches the top row" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 1; TopItem = 1 } + |> withCompletions [| "a"; "b"; "c"; "d"; "e" |] + match CompletionState.previousSelection cs with + | Some (selected, top) -> + Expect.equal selected 0 "moves to the first item" + Expect.equal top 0 "scrolls up to keep the selection visible" + | None -> failwith "expected Some" + + testCase "previousSelection returns None on the first item" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 0; TopItem = 0 } + |> withCompletions [| "a"; "b"; "c" |] + Expect.isNone (CompletionState.previousSelection cs) "already on the first item" + testCase "current returns the selected completion" <| fun _ -> + let cs = + { CompletionState.create 0 0 10 3 with SelectedItem = 1 } + |> withCompletions [| "a"; "b"; "c" |] + Expect.equal (CompletionState.current cs) "b" "index 1 is 'b'" ] diff --git a/src/BlackFox.FsGetLine/FsGetLine.fs b/src/BlackFox.FsGetLine/FsGetLine.fs index 6176ea4..580ff0c 100644 --- a/src/BlackFox.FsGetLine/FsGetLine.fs +++ b/src/BlackFox.FsGetLine/FsGetLine.fs @@ -1,4 +1,4 @@ -// +// // getline.fs: A command line editor // // Authors: @@ -7,6 +7,7 @@ // // Copyright 2008 Novell, Inc. // Copyright 2015 Julien Roncaglia +// Copyright 2016 Xamarin Inc // // Dual-licensed under the terms of the MIT X11 license or the // Apache License 2.0 @@ -14,7 +15,6 @@ // TODO: // Enter an error (a = 1); Notice how the prompt is in the wrong line // This is caused by Stderr not being tracked by System.Console. -// Completion support // Why is Thread.Interrupt not working? Currently I resort to Abort which is too much. // // Limitations in System.Console: @@ -200,6 +200,114 @@ namespace BlackFox type Completion = { Result : string list; Prefix : string } type AutoCompleteHandler = string -> int -> Completion + /// Saves the cursor position and console colors, runs `action`, then restores them. + /// Used to draw transient UI (like the completion popup) without disturbing the editor's + /// own rendering state. + let private saveExcursion (action: unit -> unit) = + let savedCol = Console.CursorLeft + let savedRow = Console.CursorTop + let savedFore = Console.ForegroundColor + let savedBack = Console.BackgroundColor + + action () + + Console.CursorLeft <- savedCol + Console.CursorTop <- savedRow + Console.ForegroundColor <- savedFore + Console.BackgroundColor <- savedBack + + /// State and rendering for the tab-completion popup window. + module CompletionState = + type CompletionState = + { + Col : int + Row : int + Width : int + Height : int + Prefix : string + Completions : string [] + SelectedItem : int + TopItem : int + } + + let create col row width height = + if col < 0 then invalidArg "col" (sprintf "Cannot be less than zero %d" col) + if row < 0 then invalidArg "row" "Cannot be less than zero" + if width < 1 then invalidArg "width" "Cannot be less than one" + if height < 1 then invalidArg "height" "Cannot be less than one" + { Col = col; Row = row; Width = width; Height = height; Prefix = ""; Completions = [||]; SelectedItem = 0; TopItem = 0 } + + let current cs = cs.Completions.[cs.SelectedItem] + + /// Pure: computes the (SelectedItem, TopItem) after moving to the next item, or None + /// if already on the last item. TopItem only advances once the selection scrolls past + /// the visible window (this is the fixed logic from upstream commit 642b064: the + /// window must advance by how far the selection is *past* the top, not how far past + /// the total height). + let nextSelection cs = + if cs.SelectedItem + 1 < cs.Completions.Length then + let selectedItem = cs.SelectedItem + 1 + let topItem = if selectedItem - cs.TopItem >= cs.Height then cs.TopItem + 1 else cs.TopItem + Some (selectedItem, topItem) + else + None + + /// Pure: computes the (SelectedItem, TopItem) after moving to the previous item, or + /// None if already on the first item. + let previousSelection cs = + if cs.SelectedItem > 0 then + let selectedItem = cs.SelectedItem - 1 + let topItem = if selectedItem < cs.TopItem then selectedItem else cs.TopItem + Some (selectedItem, topItem) + else + None + + let private drawSelection cs = + for r = 0 to cs.Height - 1 do + let itemIdx = cs.TopItem + r + let selected = itemIdx = cs.SelectedItem + + Console.ForegroundColor <- if selected then ConsoleColor.Black else ConsoleColor.Gray + Console.BackgroundColor <- if selected then ConsoleColor.Cyan else ConsoleColor.Blue + + let item = cs.Prefix + cs.Completions.[itemIdx] + let item = if item.Length > cs.Width then item.Substring (0, cs.Width) else item + + Console.CursorLeft <- cs.Col + Console.CursorTop <- cs.Row + r + Console.Write item + for space = item.Length to cs.Width do + Console.Write ' ' + + let show cs = + saveExcursion (fun () -> drawSelection cs) + cs + + let selectNext cs = + match nextSelection cs with + | Some (selectedItem, topItem) -> + let cs = { cs with SelectedItem = selectedItem; TopItem = topItem } + saveExcursion (fun () -> drawSelection cs) + cs + | None -> cs + + let selectPrevious cs = + match previousSelection cs with + | Some (selectedItem, topItem) -> + let cs = { cs with SelectedItem = selectedItem; TopItem = topItem } + saveExcursion (fun () -> drawSelection cs) + cs + | None -> cs + + let private clear cs = + for r = 0 to cs.Height - 1 do + Console.CursorLeft <- cs.Col + Console.CursorTop <- cs.Row + r + for space = 0 to cs.Width do + Console.Write ' ' + + let remove cs = saveExcursion (fun () -> clear cs) + type Command = | Done = 1 | Home = 2 @@ -235,6 +343,10 @@ namespace BlackFox AutoCompleteEvent : AutoCompleteHandler option TabAtStartCompletes : bool + + /// null/None does nothing, Some "csharp" uses some heuristics that make sense for + /// C#-like syntax (trigger completion on '.' outside of numeric literals). + HeuristicsMode : string option } let private defaultSettings = @@ -243,6 +355,7 @@ namespace BlackFox HistorySize = 10 AutoCompleteEvent = None TabAtStartCompletes = true + HeuristicsMode = None } type GetLine = @@ -323,6 +436,9 @@ namespace BlackFox /// Used to implement the Kill semantics (multiple Alt-Ds accumulate) LastCommand : Command option + /// If we have a popup completion, this is Some and holds its state. + CurrentCompletion : CompletionState.CompletionState option + Settings : GetLineSettings } @@ -343,23 +459,23 @@ namespace BlackFox SearchState = None PreviousSearch = None LastCommand = None + CurrentCompletion = None Settings = globalState.Settings } type private KeyHandler = LineEditorState -> LineEditorState - type private Handler(cmd : Command, keyInfo : ConsoleKeyInfo, h : KeyHandler) = + type private Handler(cmd : Command, keyInfo : ConsoleKeyInfo, h : KeyHandler, resetCompletion : bool) = member val HandledCommand = cmd member val KeyInfo = keyInfo member val KeyHandler = h + member val ResetCompletion = resetCompletion - new(cmd : Command, key, h : KeyHandler) = Handler(cmd, new ConsoleKeyInfo(char 0, key, false, false, false), h) - new(cmd : Command, c, h : KeyHandler) = Handler(cmd, new ConsoleKeyInfo (c, ConsoleKey.Zoom, false, false, false), h) - - static member Alt cmd c k h = Handler (cmd, new ConsoleKeyInfo (c, k, false, true, false), h) - static member Control cmd (c : char) h = Handler (cmd, char (int c - int 'A' + 1), h) + new(cmd : Command, key, h : KeyHandler, resetCompletion : bool) = Handler(cmd, new ConsoleKeyInfo(char 0, key, false, false, false), h, resetCompletion) + new(cmd : Command, c, h : KeyHandler, resetCompletion : bool) = Handler(cmd, new ConsoleKeyInfo (c, ConsoleKey.Zoom, false, false, false), h, resetCompletion) - let private cmdDone st = { st with DoneEditing = true} + static member Alt cmd c k h = Handler (cmd, new ConsoleKeyInfo (c, k, false, true, false), h, true) + static member Control cmd (c : char) h resetCompletion = Handler (cmd, char (int c - int 'A' + 1), h, resetCompletion) let private (|IsTab|IsNotTab|) c = if c = '\t' then IsTab else IsNotTab @@ -545,10 +661,53 @@ namespace BlackFox else st - let private handleChar c st = - match st.SearchState with - | Some search -> st |> searchAppend c search - | None -> st |> insertChar (c) + let private hideCompletions st = + match st.CurrentCompletion with + | None -> st + | Some(cs) -> + CompletionState.remove cs + { st with CurrentCompletion = None } + + let private showCompletions (prefix:string) (completions:string list) st = + let windowHeight = System.Math.Min (completions.Length, Console.WindowHeight / 5) + let targetLine = Console.WindowHeight - windowHeight - 1 + + let st = + if Console.CursorTop > targetLine then + let delta = Console.CursorTop - targetLine + Console.CursorLeft <- 0 + Console.CursorTop <- Console.WindowHeight - 1 + for i = 0 to delta do + for c = Console.WindowWidth downto 1 do + Console.Write ' ' + Console.CursorTop <- targetLine + Console.CursorLeft <- 0 + st |> render + else + st + + let maxWidth = 50 + let plen = prefix.Length + let windowWidth = + completions + |> List.fold (fun acc s -> System.Math.Max (plen + s.Length, acc)) 12 + |> min maxWidth + + let completionsArray = completions |> List.toArray + + let currentCompletion = + match st.CurrentCompletion with + | None -> + let left = Console.CursorLeft - prefix.Length + let left = if left + windowWidth + 1 >= Console.WindowWidth then Console.WindowWidth - windowWidth - 1 else left + { CompletionState.create left (Console.CursorTop + 1) windowWidth windowHeight with + Prefix = prefix + Completions = completionsArray } + | Some(cs) -> { cs with Prefix = prefix; Completions = completionsArray } + + let currentCompletion = CompletionState.show currentCompletion + Console.CursorLeft <- 0 + { st with CurrentCompletion = Some currentCompletion } /// Length of the longest common prefix shared by every string in `completions` /// Returns the index of the last matching character if there is any common prefix @@ -573,44 +732,109 @@ namespace BlackFox p <- p + 1 last - let private cmdTabOrComplete st = - let mutable complete = false; + // + // Triggers the completion engine. This will insert the best match found, then behaves + // like the shell "tab" which will complete as much as possible given the options, and + // shows a popup with the remaining choices if there is more than one. + // + let private complete st = + let completion = st.Settings.AutoCompleteEvent.Value st.Text st.Cursor + match completion.Result with + | [] -> st |> hideCompletions + | [single] -> st |> insertTextAtCursor single |> hideCompletions + | completions -> + let completionsArray = completions |> List.toArray + let last = commonPrefixLength completionsArray + + let st = + match last with + | Some last -> st |> insertTextAtCursor (completionsArray.[0].Substring (0, last + 1)) + | None -> st + let prefix, completions = + match last with + | Some last -> + let commonPrefix = completionsArray.[0].Substring (0, last + 1) + (completion.Prefix + commonPrefix), (completions |> List.map (fun c -> c.Substring (last + 1))) + | None -> + completion.Prefix, completions + + let st = st |> showCompletions prefix completions |> render + st |> forceCursor st.Cursor + + // + // When the user has triggered a completion window via the heuristics, this will try to + // update the contents of it. The completion window is assumed to be hidden at this point. + // + let private updateCompletionWindow st = + if st.CurrentCompletion.IsSome then + failwith "This method should only be called if the window has been hidden" + + let completion = st.Settings.AutoCompleteEvent.Value st.Text st.Cursor + match completion.Result with + | [] -> st + | completions -> + let st = st |> showCompletions completion.Prefix completions |> render + st |> forceCursor st.Cursor + + /// Pure: implements the "csharp" heuristics-mode scan that decides whether the '.' just + /// inserted at `cursorPos` should trigger auto-completion, as opposed to being part of a + /// numeric literal like "1.2". + let csharpDotHeuristicTriggersCompletion (text: string) (cursorPos: int) = + let rec scan p = + if p < 0 then + false + else + let c = text.[p] + if Char.IsDigit c then scan (p - 1) + elif c = '_' then true + elif Char.IsLetter c || Char.IsPunctuation c || Char.IsSymbol c || Char.IsControl c then true + else scan (p - 1) + + if cursorPos > 1 && Char.IsDigit text.[cursorPos - 2] then + scan (cursorPos - 3) + else + true + + // + // Implements heuristics to show the completion window based on the mode + // + let private heuristicAutoComplete wasCompleting (insertedChar:char) st = + match st.Settings.HeuristicsMode with + | Some "csharp" -> + if wasCompleting then + insertedChar <> ' ' + elif insertedChar = '.' then + csharpDotHeuristicTriggersCompletion st.Text st.Cursor + else + false + | _ -> false + + let private handleChar c st = + match st.SearchState with + | Some search -> st |> searchAppend c search + | None -> + let wasCompleting = st.CurrentCompletion.IsSome + let st = st |> hideCompletions |> insertChar c + if st.Settings.AutoCompleteEvent.IsSome && (st |> heuristicAutoComplete wasCompleting c) then + st |> updateCompletionWindow + else + st + + let private cmdTabOrComplete st = if st.Settings.AutoCompleteEvent.IsSome then + let mutable shouldComplete = false if st.Settings.TabAtStartCompletes then - complete <- true + shouldComplete <- true else let mutable i = 0 - while i < st.Cursor && not complete do + while i < st.Cursor && not shouldComplete do if not (Char.IsWhiteSpace st.Text.[i]) then - complete <- true - - if complete then - let completion = st.Settings.AutoCompleteEvent.Value st.Text st.Cursor - let completions = completion.Result - if completions.Length <> 0 then - if completions.Length = 1 then - st |> insertTextAtCursor completions.[0] - else - let completionsArray = completions |> List.toArray - let last = commonPrefixLength completionsArray - - let st = - match last with - | Some last -> - st |> insertTextAtCursor (completionsArray.[0].Substring (0, last+1)) - | None -> st - - Console.WriteLine () - for s in completions do - Console.Write completion.Prefix - Console.Write s - Console.Write ' ' - - Console.WriteLine () - st |> render |> forceCursor st.Cursor - else - st + shouldComplete <- true + i <- i + 1 + + if shouldComplete then + st |> complete else st |> handleChar '\t' else @@ -673,10 +897,14 @@ namespace BlackFox let private cmdBackspace st = if st.Cursor <> 0 then + let completing = st.CurrentCompletion.IsSome + let st = st |> hideCompletions + let newCursor = st.Cursor - 1 let newText = st.Text.Remove (newCursor, 1) let st = { st with Cursor = newCursor; Text = newText; RenderedText = renderText newText} - st |> renderAfter newCursor + let st = st |> renderAfter newCursor + if completing then st |> updateCompletionWindow else st else st @@ -732,6 +960,16 @@ namespace BlackFox else st + let private cmdUp st = + match st.CurrentCompletion with + | None -> st |> cmdHistoryPrev + | Some(cs) -> { st with CurrentCompletion = Some(CompletionState.selectPrevious cs) } + + let private cmdDown st = + match st.CurrentCompletion with + | None -> st |> cmdHistoryNext + | Some(cs) -> { st with CurrentCompletion = Some(CompletionState.selectNext cs) } + let private cmdKillToEOF st = let newKillBuffer = st.Text.Substring (st.Cursor, st.Text.Length-st.Cursor) let newText = st.Text.Substring(0, st.Cursor) @@ -764,31 +1002,36 @@ namespace BlackFox |> render |> forceCursor st.Cursor + let private cmdDone st = + match st.CurrentCompletion with + | Some(cs) -> st |> insertTextAtCursor (CompletionState.current cs) |> hideCompletions + | None -> { st with DoneEditing = true} + let private handlers = [| - new Handler (Command.Done, ConsoleKey.Enter, cmdDone) - new Handler (Command.Home,ConsoleKey.Home, cmdHome) - new Handler (Command.End,ConsoleKey.End, cmdEnd) - new Handler (Command.Left,ConsoleKey.LeftArrow, cmdLeft) - new Handler (Command.Right,ConsoleKey.RightArrow, cmdRight) - new Handler (Command.HistoryPrev,ConsoleKey.UpArrow, cmdHistoryPrev) - new Handler (Command.HistoryNext,ConsoleKey.DownArrow, cmdHistoryNext) - new Handler (Command.Backspace,ConsoleKey.Backspace, cmdBackspace) - new Handler (Command.DeleteChar,ConsoleKey.Delete, cmdDeleteChar) - new Handler (Command.TabOrComplete,ConsoleKey.Tab, cmdTabOrComplete) + new Handler (Command.Done, ConsoleKey.Enter, cmdDone, false) + new Handler (Command.Home,ConsoleKey.Home, cmdHome, true) + new Handler (Command.End,ConsoleKey.End, cmdEnd, true) + new Handler (Command.Left,ConsoleKey.LeftArrow, cmdLeft, true) + new Handler (Command.Right,ConsoleKey.RightArrow, cmdRight, true) + new Handler (Command.HistoryPrev,ConsoleKey.UpArrow, cmdUp, false) + new Handler (Command.HistoryNext,ConsoleKey.DownArrow, cmdDown, false) + new Handler (Command.Backspace,ConsoleKey.Backspace, cmdBackspace, false) + new Handler (Command.DeleteChar,ConsoleKey.Delete, cmdDeleteChar, true) + new Handler (Command.TabOrComplete,ConsoleKey.Tab, cmdTabOrComplete, false) // Emacs keys - Handler.Control Command.Home 'A' cmdHome - Handler.Control Command.End 'E' cmdEnd - Handler.Control Command.Left 'B' cmdLeft - Handler.Control Command.Right 'F' cmdRight - Handler.Control Command.HistoryPrev 'P' cmdHistoryPrev - Handler.Control Command.HistoryNext 'N' cmdHistoryNext - Handler.Control Command.CmdKillToEOF 'K' cmdKillToEOF - Handler.Control Command.Yank 'Y' cmdYank - Handler.Control Command.DeleteChar 'D' cmdDeleteChar - Handler.Control Command.Refresh 'L' cmdRefresh - Handler.Control Command.ReverseSearch 'R' cmdReverseSearch + Handler.Control Command.Home 'A' cmdHome true + Handler.Control Command.End 'E' cmdEnd true + Handler.Control Command.Left 'B' cmdLeft true + Handler.Control Command.Right 'F' cmdRight true + Handler.Control Command.HistoryPrev 'P' cmdUp false + Handler.Control Command.HistoryNext 'N' cmdDown false + Handler.Control Command.CmdKillToEOF 'K' cmdKillToEOF true + Handler.Control Command.Yank 'Y' cmdYank true + Handler.Control Command.DeleteChar 'D' cmdDeleteChar true + Handler.Control Command.Refresh 'L' cmdRefresh true + Handler.Control Command.ReverseSearch 'R' cmdReverseSearch true Handler.Alt Command.BackwardWord 'B' ConsoleKey.B cmdBackwardWord Handler.Alt Command.ForwardWord 'F' ConsoleKey.F cmdForwardWord @@ -800,7 +1043,7 @@ namespace BlackFox //Handler.Control ('T', CmdDebug), // quote - Handler.Control Command.Quote 'Q' (fun st -> st |> handleChar ((Console.ReadKey (true)).KeyChar)) + Handler.Control Command.Quote 'Q' (fun st -> st |> handleChar ((Console.ReadKey (true)).KeyChar)) true |] let private interruptEdit (thread:Thread) (sender:obj) (a:ConsoleCancelEventArgs) = @@ -810,13 +1053,6 @@ namespace BlackFox // Interrupt the editor thread.Abort () - let private readKeyWithEscMeaningAlt () = - let key = Console.ReadKey true - if key.Key = ConsoleKey.Escape then - Console.ReadKey true, ConsoleModifiers.Alt - else - key, key.Modifiers - let private tryFindHandler (input:ConsoleKeyInfo) modifier = handlers |> Array.tryFind (fun handler -> let handlerKeyInfo = handler.KeyInfo; @@ -826,19 +1062,30 @@ namespace BlackFox ) let private readOneInput st = - let (newInput, modifier) = readKeyWithEscMeaningAlt () - - let inputHander = tryFindHandler newInput modifier - - match inputHander with - | Some(handler) -> - let st = handler.KeyHandler st - let st = { st with LastCommand = Some handler.HandledCommand } - match st.SearchState, handler.HandledCommand with - | ( _, Command.ReverseSearch) -> st - | (Some(search), _) -> { st with PreviousSearch = Some(search.Term); SearchState = None} |> setPrompt st.SpecifiedPrompt - | _ -> st - | None -> st |> handleChar (newInput.KeyChar) + let key = Console.ReadKey true + + if key.Key = ConsoleKey.Escape && st.CurrentCompletion.IsSome then + // Escape closes an open completion popup instead of being treated as an Alt prefix. + st |> hideCompletions + else + let newInput, modifier = + if key.Key = ConsoleKey.Escape then + Console.ReadKey true, ConsoleModifiers.Alt + else + key, key.Modifiers + + let inputHander = tryFindHandler newInput modifier + + match inputHander with + | Some handler -> + let st = if handler.ResetCompletion then st |> hideCompletions else st + let st = handler.KeyHandler st + let st = { st with LastCommand = Some handler.HandledCommand } + match st.SearchState, handler.HandledCommand with + | ( _, Command.ReverseSearch) -> st + | (Some search, _) -> { st with PreviousSearch = Some search.Term; SearchState = None} |> setPrompt st.SpecifiedPrompt + | _ -> st + | None -> st |> handleChar newInput.KeyChar let rec private readInputUntilDoneEditing = function | st when st.DoneEditing -> st diff --git a/src/TestApp/Program.fs b/src/TestApp/Program.fs index 2542f3f..ebe5628 100644 --- a/src/TestApp/Program.fs +++ b/src/TestApp/Program.fs @@ -5,16 +5,19 @@ open ColoredString [] - let main argv = + let main argv = coloredWriteLine "Welcome to the ^[Cyan]F#^[Reset] GetLine shell demo" - coloredWriteLine "^[DarkGray]Use Ctrl+D to quit" + coloredWriteLine "^[DarkGray]Use Ctrl+D to quit, Tab to complete, or type '.' for a popup" Console.WriteLine () - + let autoComplete (str:String) cursor = if str.StartsWith("test") && cursor = 4 then { FsGetLine.Completion.Result = ["42"]; FsGetLine.Completion.Prefix = "" } else if str.StartsWith("x") && cursor = 1 then { FsGetLine.Completion.Result = ["y";"x";"MarkTheSpot"]; FsGetLine.Completion.Prefix = "x" } + else if cursor > 0 && str.[cursor - 1] = '.' then + // Shown automatically (no Tab needed) whenever HeuristicsMode triggers on '.' + { FsGetLine.Completion.Result = ["One";"Two";"Three";"Four";"Five";"Six";"Seven";"Eight";"Nine";"Ten"]; FsGetLine.Completion.Prefix = "" } else { Result = []; Prefix = "" } let mutable editor = FsGetLine.create (fun s -> @@ -22,6 +25,7 @@ s with AppName = Some("FsGetLineSample") AutoCompleteEvent = Some(autoComplete) + HeuristicsMode = Some("csharp") }) let mutable s = Some("") while s.IsSome do From a7762d3e55e1859e91f6caf43f357efb13e0d394 Mon Sep 17 00:00:00 2001 From: Julien Roncaglia Date: Mon, 27 Jul 2026 02:16:41 +0200 Subject: [PATCH 2/3] Use an enum for heuristics mode --- src/BlackFox.FsGetLine/FsGetLine.fs | 18 ++++++++++++------ src/TestApp/Program.fs | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/BlackFox.FsGetLine/FsGetLine.fs b/src/BlackFox.FsGetLine/FsGetLine.fs index 580ff0c..fc4aa95 100644 --- a/src/BlackFox.FsGetLine/FsGetLine.fs +++ b/src/BlackFox.FsGetLine/FsGetLine.fs @@ -333,6 +333,14 @@ namespace BlackFox | Forward = 1 | Backward = 2 + [] + type HeuristicsMode = + /// Do nothing, completion is only triggered explicitly (e.g. by pressing Tab). + | None + /// Heuristics that make sense for C#-like syntax (trigger completion on '.' outside + /// of numeric literals). + | CSharp + type GetLineSettings = { AppName : string option @@ -344,9 +352,7 @@ namespace BlackFox TabAtStartCompletes : bool - /// null/None does nothing, Some "csharp" uses some heuristics that make sense for - /// C#-like syntax (trigger completion on '.' outside of numeric literals). - HeuristicsMode : string option + HeuristicsMode : HeuristicsMode } let private defaultSettings = @@ -355,7 +361,7 @@ namespace BlackFox HistorySize = 10 AutoCompleteEvent = None TabAtStartCompletes = true - HeuristicsMode = None + HeuristicsMode = HeuristicsMode.None } type GetLine = @@ -801,14 +807,14 @@ namespace BlackFox // let private heuristicAutoComplete wasCompleting (insertedChar:char) st = match st.Settings.HeuristicsMode with - | Some "csharp" -> + | HeuristicsMode.CSharp -> if wasCompleting then insertedChar <> ' ' elif insertedChar = '.' then csharpDotHeuristicTriggersCompletion st.Text st.Cursor else false - | _ -> false + | HeuristicsMode.None -> false let private handleChar c st = match st.SearchState with diff --git a/src/TestApp/Program.fs b/src/TestApp/Program.fs index ebe5628..c1febf3 100644 --- a/src/TestApp/Program.fs +++ b/src/TestApp/Program.fs @@ -25,7 +25,7 @@ s with AppName = Some("FsGetLineSample") AutoCompleteEvent = Some(autoComplete) - HeuristicsMode = Some("csharp") + HeuristicsMode = FsGetLine.HeuristicsMode.CSharp }) let mutable s = Some("") while s.IsSome do From d0f6a9ada4de3ab0e5dc4e2275238ff71c4d84ac Mon Sep 17 00:00:00 2001 From: Julien Roncaglia Date: Mon, 27 Jul 2026 02:26:38 +0200 Subject: [PATCH 3/3] Cleanup --- src/BlackFox.FsGetLine/ColoredString.fs | 30 ++++++++-------- src/BlackFox.FsGetLine/FsGetLine.fs | 46 +++++++++++-------------- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/src/BlackFox.FsGetLine/ColoredString.fs b/src/BlackFox.FsGetLine/ColoredString.fs index 5bac62a..e1413f7 100644 --- a/src/BlackFox.FsGetLine/ColoredString.fs +++ b/src/BlackFox.FsGetLine/ColoredString.fs @@ -21,12 +21,12 @@ let private tryParseColor (s:string) = | :? OverflowException -> NoColor let private parseColorCodes (s:string) = - let split = s.Split(';') + let split = s.Split ';' let foreground = if split.Length >= 1 then tryParseColor split.[0] else NoColor let background = if split.Length >= 2 then tryParseColor split.[1] else NoColor - (foreground, background) + foreground, background /// Support color markers in strings like ^[Red] or ^[Red;Blue] where the first color is /// the foreground and the second the background. @@ -39,27 +39,27 @@ type ColoredString(raw : string) = s.Replace("^", "^^") static member public Escape (s:string) = - new ColoredString(ColoredString.EscapeToString(s)) + new ColoredString(ColoredString.EscapeToString s) - member private __.Fold (onChar: OnCharParsed<'st>) (onColor: OnColorParsed<'st>) (st:'st) = + member private __.Fold (onChar: OnCharParsed<'st>) (onColor: OnColorParsed<'st>) (st:'st) = let foldFunc (st, escCount, content) c = match escCount with | 0 -> match c with - | '^' -> (st, 1, "") - | _ -> (onChar st c, 0, "") + | '^' -> st, 1, "" + | _ -> onChar st c, 0, "" | 1 -> match c with - | '[' -> (st, 2, "") - | _ -> (onChar st c, 0, "") + | '[' -> st, 2, "" + | _ -> onChar st c, 0, "" | 2 -> match c with - | ']' -> (onColor st (parseColorCodes content), 0, "") - | _ -> (st, 2, content + (string)c) + | ']' -> onColor st (parseColorCodes content), 0, "" + | _ -> st, 2, content + string c | _ -> - failwith("Impossible escape count") + failwith "Impossible escape count" - let (newSt, _, _) = raw |> Seq.fold foldFunc (st, 0, "") + let newSt, _, _ = raw |> Seq.fold foldFunc (st, 0, "") newSt member x.Length @@ -86,14 +86,14 @@ type ColoredString(raw : string) = let setColors foreground background = setForeground foreground setBackground background - + inner setColors setColors Reset Reset member x.WriteToConsole () = x.WriteCore (fun setColors -> - x.Fold (fun _ c -> Console.Write(c)) (fun _ (foreground, background) -> setColors foreground background) () + x.Fold (fun _ c -> Console.Write c) (fun _ (foreground, background) -> setColors foreground background) () ) member x.WriteToConsoleFrom from = @@ -115,4 +115,4 @@ let coloredWrite s = let coloredWriteLine s = coloredWrite s - Console.WriteLine() \ No newline at end of file + Console.WriteLine () diff --git a/src/BlackFox.FsGetLine/FsGetLine.fs b/src/BlackFox.FsGetLine/FsGetLine.fs index fc4aa95..023584c 100644 --- a/src/BlackFox.FsGetLine/FsGetLine.fs +++ b/src/BlackFox.FsGetLine/FsGetLine.fs @@ -516,7 +516,7 @@ namespace BlackFox let builder = text |> AnsiControlCodes.fold (fun (b:StringBuilder) c -> if c = '\t' then - b.Append(" ") + b.Append " " else b.Append(ColoredString.EscapeToString(string c))) (fun b c -> b.Append(sprintf "^[DarkGreen]^^^[Green]%c^[Reset]" (AnsiControlCodes.toDisplayableChar c))) @@ -602,7 +602,7 @@ namespace BlackFox let private setText newtext st = Console.SetCursorPosition (0, st.HomeRow) - st |> initText (newtext) + st |> initText newtext let private setPromptCore newprompt st = Console.SetCursorPosition (0, st.HomeRow) @@ -628,7 +628,7 @@ namespace BlackFox let mutable p = -1 if st.Cursor = st.Text.Length then // The cursor is at the end of the string - p <- st.Text.LastIndexOf (search.Term) + p <- st.Text.LastIndexOf search.Term else // The cursor is somewhere in the middle of the string let start = if st.Cursor = search.MatchAt then st.Cursor - 1 else st.Cursor @@ -639,10 +639,10 @@ namespace BlackFox else // Need to search backwards in history let st = st |> historyUpdateLine - let (newHistory, searchResult) = History.searchBackward search.Term st.History + let newHistory, searchResult = History.searchBackward search.Term st.History let st = { st with History = newHistory } match searchResult with - | Some(_) -> + | Some _ -> { st with SearchState = Some { search with MatchAt = -1 } } |> setText searchResult |> reverseSearch @@ -670,7 +670,7 @@ namespace BlackFox let private hideCompletions st = match st.CurrentCompletion with | None -> st - | Some(cs) -> + | Some cs -> CompletionState.remove cs { st with CurrentCompletion = None } @@ -709,7 +709,7 @@ namespace BlackFox { CompletionState.create left (Console.CursorTop + 1) windowWidth windowHeight with Prefix = prefix Completions = completionsArray } - | Some(cs) -> { cs with Prefix = prefix; Completions = completionsArray } + | Some cs -> { cs with Prefix = prefix; Completions = completionsArray } let currentCompletion = CompletionState.show currentCompletion Console.CursorLeft <- 0 @@ -738,11 +738,9 @@ namespace BlackFox p <- p + 1 last - // - // Triggers the completion engine. This will insert the best match found, then behaves - // like the shell "tab" which will complete as much as possible given the options, and - // shows a popup with the remaining choices if there is more than one. - // + /// Triggers the completion engine. This will insert the best match found, then behaves + /// like the shell "tab" which will complete as much as possible given the options, and + /// shows a popup with the remaining choices if there is more than one. let private complete st = let completion = st.Settings.AutoCompleteEvent.Value st.Text st.Cursor match completion.Result with @@ -768,10 +766,8 @@ namespace BlackFox let st = st |> showCompletions prefix completions |> render st |> forceCursor st.Cursor - // - // When the user has triggered a completion window via the heuristics, this will try to - // update the contents of it. The completion window is assumed to be hidden at this point. - // + /// When the user has triggered a completion window via the heuristics, this will try to + /// update the contents of it. The completion window is assumed to be hidden at this point. let private updateCompletionWindow st = if st.CurrentCompletion.IsSome then failwith "This method should only be called if the window has been hidden" @@ -802,9 +798,7 @@ namespace BlackFox else true - // - // Implements heuristics to show the completion window based on the mode - // + /// Implements heuristics to show the completion window based on the mode let private heuristicAutoComplete wasCompleting (insertedChar:char) st = match st.Settings.HeuristicsMode with | HeuristicsMode.CSharp -> @@ -822,7 +816,7 @@ namespace BlackFox | None -> let wasCompleting = st.CurrentCompletion.IsSome let st = st |> hideCompletions |> insertChar c - if st.Settings.AutoCompleteEvent.IsSome && (st |> heuristicAutoComplete wasCompleting c) then + if st.Settings.AutoCompleteEvent.IsSome && st |> heuristicAutoComplete wasCompleting c then st |> updateCompletionWindow else st @@ -989,11 +983,11 @@ namespace BlackFox match st.SearchState with | None -> { st with SearchState = Some { MatchAt = -1; Term = ""; Direction = SearchDirection.Backward } } - |> setSearchPrompt ("") + |> setSearchPrompt "" | Some search -> if search.Term = "" then match st.PreviousSearch with - | None | Some("") -> + | None | Some "" -> st | Some previousTerm -> { st with SearchState = Some { search with Term = previousTerm } } @@ -1010,7 +1004,7 @@ namespace BlackFox let private cmdDone st = match st.CurrentCompletion with - | Some(cs) -> st |> insertTextAtCursor (CompletionState.current cs) |> hideCompletions + | Some cs -> st |> insertTextAtCursor (CompletionState.current cs) |> hideCompletions | None -> { st with DoneEditing = true} let private handlers = @@ -1049,7 +1043,7 @@ namespace BlackFox //Handler.Control ('T', CmdDebug), // quote - Handler.Control Command.Quote 'Q' (fun st -> st |> handleChar ((Console.ReadKey (true)).KeyChar)) true + Handler.Control Command.Quote 'Q' (fun st -> st |> handleChar ((Console.ReadKey true).KeyChar)) true |] let private interruptEdit (thread:Thread) (sender:obj) (a:ConsoleCancelEventArgs) = @@ -1088,8 +1082,8 @@ namespace BlackFox let st = handler.KeyHandler st let st = { st with LastCommand = Some handler.HandledCommand } match st.SearchState, handler.HandledCommand with - | ( _, Command.ReverseSearch) -> st - | (Some search, _) -> { st with PreviousSearch = Some search.Term; SearchState = None} |> setPrompt st.SpecifiedPrompt + | _, Command.ReverseSearch -> st + | Some search, _ -> { st with PreviousSearch = Some search.Term; SearchState = None} |> setPrompt st.SpecifiedPrompt | _ -> st | None -> st |> handleChar newInput.KeyChar