diff --git a/psakefile.ps1 b/psakefile.ps1 index 3d11d696..1caaa09f 100644 --- a/psakefile.ps1 +++ b/psakefile.ps1 @@ -42,7 +42,7 @@ Task Build -depends Clean { Task UnitTest { Remove-Item ./src/pocof.Test/TestResults/* -Recurse -ErrorAction SilentlyContinue - dotnet test --collect:"XPlat Code Coverage" --nologo + dotnet test --collect:"XPlat Code Coverage" --nologo --logger:"console;verbosity=detailed" if (-not $?) { throw 'dotnet test failed.' } diff --git a/src/pocof.Test/PocofData.fs b/src/pocof.Test/PocofData.fs index f9448fe5..354d78d3 100644 --- a/src/pocof.Test/PocofData.fs +++ b/src/pocof.Test/PocofData.fs @@ -156,8 +156,8 @@ module initConfig = Prompt = "prompt" Layout = "TopDown" Keymaps = Map [ ({ Modifier = 7; Key = ConsoleKey.X }, Cancel) ] } - |> shouldEqual - <| ({ Prompt = "prompt" + |> shouldEqual ( + { Prompt = "prompt" Layout = Layout.TopDown Keymaps = Map [ ({ Modifier = 7; Key = ConsoleKey.X }, Cancel) ] NotInteractive = true }, @@ -170,7 +170,8 @@ module initConfig = PropertySearch = Search "name" Notification = "" SuppressProperties = true }, - { X = 5; Y = 0 }) + { X = 5; Y = 0 } + ) [] let ``should fail due to unknown Matcher.`` () = @@ -238,12 +239,13 @@ module invokeAction = [] let ``should return a property search state and position.x = 1 when the char is colon.`` () = invokeAction state { X = 0; Y = 0 } (AddChar ":") - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":" PropertySearch = Search "" }, { X = 1; Y = 0 }, - Required) + Required + ) [] let ``should return a non-search state and position.X = 6 when the char is space.`` () = @@ -253,12 +255,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } (AddChar " ") - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name " PropertySearch = NonSearch }, { X = 6; Y = 0 }, - Required) + Required + ) module ``with BackwardChar`` = [] @@ -271,8 +274,7 @@ module invokeAction = let position: Position = { X = 0; Y = 0 } invokeAction state position BackwardChar - |> shouldEqual - <| (state, position, NotRequired) + |> shouldEqual (state, position, NotRequired) [] let ``should return state with position.X=4 when moving forward on ':name' with position.X=5.`` () = @@ -282,12 +284,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } BackwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "nam" }, { X = 4; Y = 0 }, - Required) + Required + ) module ``with ForwardChar`` = [] @@ -298,12 +301,13 @@ module invokeAction = PropertySearch = Search "" } { X = 1; Y = 0 } ForwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "n" }, { X = 2; Y = 0 }, - Required) + Required + ) [] let ``should return state with pos unmodified when moving forward on ':name' with position.X=5 and query.Length=3.`` @@ -315,12 +319,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } ForwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "name" }, { X = 5; Y = 0 }, - NotRequired) + NotRequired + ) module ``with BeginningOfLine`` = [] @@ -331,12 +336,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } BeginningOfLine - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = NonSearch }, { X = 0; Y = 0 }, - Required) + Required + ) [] let ``should return state with pos unmodified`` () = @@ -346,12 +352,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 0; Y = 0 } BeginningOfLine - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = NonSearch }, { X = 0; Y = 0 }, - NotRequired) + NotRequired + ) [] let ``should return no change with pos modified when NonSearch`` () = @@ -361,12 +368,13 @@ module invokeAction = PropertySearch = NonSearch } { X = 5; Y = 0 } BeginningOfLine - |> (shouldEqual - <| ({ state with - Query = "query" - PropertySearch = NonSearch }, - { X = 0; Y = 0 }, - NotRequired)) + |> (shouldEqual ( + { state with + Query = "query" + PropertySearch = NonSearch }, + { X = 0; Y = 0 }, + NotRequired + )) module ``with EndOfLine`` = [] @@ -377,12 +385,13 @@ module invokeAction = PropertySearch = Search "n" } { X = 2; Y = 0 } EndOfLine - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "name" }, { X = 5; Y = 0 }, - Required) + Required + ) [] let ``should return state with pos unmodified`` () = @@ -392,12 +401,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } EndOfLine - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "name" }, { X = 5; Y = 0 }, - NotRequired) + NotRequired + ) [] let ``should return no change with pos modified when NonSearch`` () = @@ -407,12 +417,13 @@ module invokeAction = PropertySearch = NonSearch } { X = 0; Y = 0 } EndOfLine - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = "query" PropertySearch = NonSearch }, { X = 5; Y = 0 }, - NotRequired) + NotRequired + ) module ``with DeleteBackwardChar`` = [] @@ -423,12 +434,13 @@ module invokeAction = PropertySearch = NonSearch } { X = 6; Y = 0 } DeleteBackwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "name" }, { X = 5; Y = 0 }, - Required) + Required + ) [] let ``should not change state if the cursor position is at the begin of line.`` () = @@ -438,12 +450,13 @@ module invokeAction = PropertySearch = NonSearch } { X = 0; Y = 0 } DeleteBackwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = NonSearch }, { X = 0; Y = 0 }, - NotRequired) + NotRequired + ) module ``with DeleteForwardChar`` = [] @@ -454,12 +467,13 @@ module invokeAction = PropertySearch = Search "name" } { X = 0; Y = 0 } DeleteForwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = "name " PropertySearch = NonSearch }, { X = 0; Y = 0 }, - Required) + Required + ) [] let ``should not change state if the cursor position is at the end of line.`` () = @@ -469,42 +483,39 @@ module invokeAction = PropertySearch = Search "name" } { X = 5; Y = 0 } DeleteForwardChar - |> shouldEqual - <| ({ state with + |> shouldEqual ( + { state with Query = ":name" PropertySearch = Search "name" }, { X = 5; Y = 0 }, - NotRequired) + NotRequired + ) module ``with KillBeginningOfLine`` = [] let ``should remove all characters before the specified position.`` () = invokeAction { state with Query = "examplequery" } { X = 7; Y = 0 } KillBeginningOfLine - |> shouldEqual - <| ({ state with Query = "query" }, { X = 0; Y = 0 }, Required) + |> shouldEqual ({ state with Query = "query" }, { X = 0; Y = 0 }, Required) [] let ``should not change state if the cursor position is at the begin of line.`` () = invokeAction { state with Query = "query" } { X = 0; Y = 0 } KillBeginningOfLine - |> shouldEqual - <| ({ state with Query = "query" }, { X = 0; Y = 0 }, NotRequired) + |> shouldEqual ({ state with Query = "query" }, { X = 0; Y = 0 }, NotRequired) module ``with KillEndOfLine`` = [] let ``should remove characters after the current cursor position.`` () = invokeAction { state with Query = "examplequery" } { X = 7; Y = 0 } KillEndOfLine - |> shouldEqual - <| ({ state with Query = "example" }, { X = 7; Y = 0 }, Required) + |> shouldEqual ({ state with Query = "example" }, { X = 7; Y = 0 }, Required) [] let ``should not change state if the cursor position is at the end of line.`` () = invokeAction { state with Query = "example" } { X = 7; Y = 0 } KillEndOfLine - |> shouldEqual - <| ({ state with Query = "example" }, { X = 7; Y = 0 }, NotRequired) + |> shouldEqual ({ state with Query = "example" }, { X = 7; Y = 0 }, NotRequired) let testStateOnly action state expected = - invokeAction state position action |> shouldEqual - <| (expected, position, Required) + invokeAction state position action + |> shouldEqual (expected, position, Required) module ``with RotateMatcher`` = let test before after = @@ -578,8 +589,8 @@ module invokeAction = let ``should return a disabled suppress property.`` () = test true false let noop action = - invokeAction state position action |> shouldEqual - <| (state, position, NotRequired) // TODO: change to false when the action is implemented. + invokeAction state position action + |> shouldEqual (state, position, NotRequired) // TODO: change to false when the action is implemented. module ``with SelectUp`` = [] diff --git a/src/pocof.Test/PocofUI.fs b/src/pocof.Test/PocofUI.fs new file mode 100644 index 00000000..d69beefa --- /dev/null +++ b/src/pocof.Test/PocofUI.fs @@ -0,0 +1,94 @@ +module PocofUI + +open Xunit +open FsUnitTyped +open System +open pocof.PocofData +open pocof.PocofScreen + +let generateLine x y = List.replicate y <| String.replicate x " " +type MockRawUI = + val caAsInput: bool + val mutable x: int + val mutable y: int + val mutable screen: string list + + static xx = 50 + static yy = 30 + new() = + // NOTE: accessing Console.TreatControlCAsInput will raise System.IO.IOException when running on GitHub Actions windows runner. + { caAsInput = true + x = MockRawUI.xx + y = MockRawUI.yy + screen = generateLine MockRawUI.xx MockRawUI.yy + } + + interface IRawUI with + member __.SetCursorPosition (x: int) (y: int) = + __.x <- x + __.y <- y + + member __.GetCursorPositionX (_: string) (x: int) = x + + member __.GetWindowWidth() = 50 + member __.GetWindowHeight() = 30 + member __.Write x y s = + __.screen <- __.screen |>List.mapi (fun i ss -> + match i with + | ii when ii = y -> + ss.Substring(0, x) + s + | _ -> ss) + + interface IDisposable with + member __.Dispose() = () + +module ``Buff writeScreen`` = + [] + let ``should render top down.`` () = + let rui = new MockRawUI() + let buff = new Buff(rui, "query", (fun _ -> Seq.empty)) + + let state: InternalState = + { Query = "foo" + QueryState = + { Matcher = MATCH + Operator = AND + CaseSensitive = true + Invert = false } + PropertySearch = NonSearch + Notification = "" + SuppressProperties = false } + + buff.writeTopDown state 0 [] <| Ok [] + + let expected = + "query>foo cmatch and [0]" :: (generateLine MockRawUI.xx (MockRawUI.yy - 1)) + rui.screen + |> shouldEqual expected + + [] + let ``should render bottom up.`` () = + let rui = new MockRawUI() + let buff = new Buff(rui, "prompt", (fun _ -> Seq.empty)) + + let state: InternalState = + { Query = "hello*world*" + QueryState = + { Matcher = LIKE + Operator = OR + CaseSensitive = false + Invert = true } + PropertySearch = NonSearch + Notification = "" + SuppressProperties = false } + + buff.writeBottomUp state 0 [] <| Ok [] + + let expected = + "prompt>hello*world* notlike or [0]" :: (generateLine MockRawUI.xx (MockRawUI.yy - 1)) + |> List.rev + rui.screen + |> shouldEqual expected + + // TODO: test notification rendering. + // TODO: test entries rendering. diff --git a/src/pocof.Test/pocof.Test.fsproj b/src/pocof.Test/pocof.Test.fsproj index 300fd34b..317e8614 100644 --- a/src/pocof.Test/pocof.Test.fsproj +++ b/src/pocof.Test/pocof.Test.fsproj @@ -11,6 +11,7 @@ + diff --git a/src/pocof/UI.fs b/src/pocof/UI.fs index ef697cf5..b61de478 100644 --- a/src/pocof/UI.fs +++ b/src/pocof/UI.fs @@ -1,8 +1,5 @@ namespace pocof -open System -open System.Management.Automation.Host - module PocofDebug = // for debugging. open System.IO @@ -12,54 +9,71 @@ module PocofDebug = res |> List.iter (fprintfn sw "<%A>") module PocofScreen = - let private anchor = ">" + open System + open System.Management.Automation.Host + + type IRawUI = + inherit IDisposable + abstract member SetCursorPosition: int -> int -> unit + abstract member GetCursorPositionX: string -> int -> int + abstract member GetWindowWidth: unit -> int + abstract member GetWindowHeight: unit -> int + abstract member Write: int -> int -> string -> unit + + type RawUI(rui) = + let rui: PSHostRawUserInterface = rui + + let buf: BufferCell [,] = + rui.GetBufferContents(Rectangle(0, 0, rui.WindowSize.Width, rui.CursorPosition.Y)) + + let caAsInput: bool = Console.TreatControlCAsInput + + do + Console.TreatControlCAsInput <- true + Console.Clear() + + interface IRawUI with + member __.SetCursorPosition (x: int) (y: int) = rui.CursorPosition <- Coordinates(x, y) - type Buff = - val rui: PSHostRawUserInterface - val prompt: string - val invoke: list -> seq + member __.GetCursorPositionX (prompt: string) (x: int) = + rui.LengthInBufferCells(prompt.Substring(0, x)) - val buf: BufferCell [,] - val promptLength: int - val caAsInput: bool + member __.GetWindowWidth() = rui.WindowSize.Width + member __.GetWindowHeight() = rui.WindowSize.Height - new(r, p, i, b) = - { rui = r - buf = r.GetBufferContents(Rectangle(0, 0, r.WindowSize.Width, r.CursorPosition.Y)) - prompt = p - promptLength = p.Length + anchor.Length - invoke = i - caAsInput = b } + member __.Write (x: int) (y: int) (s: string) = + (__ :> IRawUI).SetCursorPosition x y + Console.Write s interface IDisposable with member __.Dispose() = - Console.TreatControlCAsInput <- __.caAsInput + Console.TreatControlCAsInput <- caAsInput Console.Clear() let origin = Coordinates(0, 0) - __.rui.SetBufferContents(origin, __.buf) - __.setCursorPosition 0 <| __.buf.GetUpperBound 0 + rui.SetBufferContents(origin, buf) - member private __.setCursorPosition (x: int) (y: int) = - __.rui.CursorPosition <- Coordinates(x, y) + (__ :> IRawUI).SetCursorPosition 0 + <| buf.GetUpperBound 0 - member private __.getCursorPositionX (filter: string) (x: int) = - __.rui.LengthInBufferCells( - (__.prompt + anchor + filter) - .Substring(0, __.promptLength + x) - ) + let private anchor = ">" + let private note = "note>" - member private __.writeRightInfo (state: PocofData.InternalState) (length: int) (row: int) = - let info = sprintf "%O [%d]" <| state.QueryState <| length + type Buff(r, p, i) = + let rui: IRawUI = r + let prompt: string = p + let invoke: obj list -> string seq = i - let x = __.rui.WindowSize.Width - info.Length - __.setCursorPosition x row - Console.Write info + interface IDisposable with + member __.Dispose() = (rui :> IDisposable).Dispose() - member private __.writeScreenLine (height: int) (line: string) = - __.setCursorPosition 0 height + member private __.writeRightInfo (state: PocofData.InternalState) (length: int) (row: int) = + let info = $"%O{state.QueryState} [%d{length}]" + let x = (rui.GetWindowWidth()) - info.Length + rui.Write x row info - line.PadRight __.rui.WindowSize.Width - |> Console.Write + member private __.writeScreenLine (height: int) (line: string) = + line.PadRight(rui.GetWindowWidth()) + |> rui.Write 0 height member __.writeScreen (layout: PocofData.Layout) @@ -72,32 +86,31 @@ module PocofScreen = match layout with | PocofData.TopDown -> 0, 1, (+) 2 | PocofData.BottomUp -> - let basePosition = __.rui.WindowSize.Height - 1 + let basePosition = rui.GetWindowHeight() - 1 basePosition, basePosition - 1, (-) (basePosition - 2) - __.writeScreenLine basePosition - <| __.prompt + ">" + state.Query - + let topLine = prompt + anchor + state.Query + __.writeScreenLine basePosition topLine __.writeRightInfo state entries.Length basePosition - // PocofDebug.logFile "./debug.log" [ List.length entries ] + // PocofDebug.logFile "./debug.log" [ List.length entries ] // TODO: add debug flag. __.writeScreenLine firstLine <| match state.Notification with | "" -> match props with - | Ok (p) -> (String.concat " " p).[.. __.rui.WindowSize.Width - 1] - | Error (e) -> "note>" + e - | _ -> "note>" + state.Notification + | Ok (p) -> (String.concat " " p).[.. (rui.GetWindowWidth()) - 1] + | Error (e) -> note + e + | _ -> note + state.Notification - let h = __.rui.WindowSize.Height - 3 + let h = rui.GetWindowHeight() - 3 let out = match List.length entries < h with | true -> entries | _ -> List.take h entries |> PocofData.unwrap - |> __.invoke + |> invoke |> Seq.fold (fun acc s -> s.Split Environment.NewLine @@ -111,21 +124,19 @@ module PocofScreen = <| toHeight i <| match List.tryItem i out with | Some s -> - // logFile "./debug.log" [ s ] + // logFile "./debug.log" [ s ] // TODO: add debug flag. s | None -> String.Empty) - __.setCursorPosition - <| __.getCursorPositionX state.Query x + rui.SetCursorPosition + <| rui.GetCursorPositionX topLine (prompt.Length + anchor.Length + x) <| basePosition - member __.writeTopDown = __.writeScreen PocofData.TopDown member __.writeBottomUp = __.writeScreen PocofData.BottomUp - let init (rui: PSHostRawUserInterface) (prompt: string) (invoke: list -> seq) = - let buf = new Buff(rui, prompt, invoke, Console.TreatControlCAsInput) - Console.Clear() - Console.TreatControlCAsInput <- true + let init (rui: PSHostRawUserInterface) (prompt: string) (invoke: obj list -> string seq) = + let r = new RawUI(rui) + let buf = new Buff(r, prompt, invoke) buf