diff --git a/ci/check-repo.roc b/ci/check-repo.roc index acef3f5..b272a08 100644 --- a/ci/check-repo.roc +++ b/ci/check-repo.roc @@ -20,4 +20,4 @@ main = repoRes = Decode.fromBytes bytes Rvn.pretty when repoRes is Ok _ -> Stdout.line! "$(greenFg)0$(resetStyle) errors decoding repo" - Err _ -> Task.err (Exit 1 "$(redFg)1$(resetStyle) error decoding repo") \ No newline at end of file + Err _ -> Task.err (Exit 1 "$(redFg)1$(resetStyle) error decoding repo") diff --git a/src/ArgParser.roc b/src/ArgParser.roc index 8e0040e..4715fc6 100644 --- a/src/ArgParser.roc +++ b/src/ArgParser.roc @@ -10,14 +10,16 @@ parseOrDisplayMessage = \args -> Cli.parseOrDisplayMessage cliParser args baseUsage = Help.usageHelp cliParser.config ["roc-start"] cliParser.textStyle -extendedUsage = - ansiCode = when cliParser.textStyle is - Color -> "\u(001b)[1m\u(001b)[4m" - Plain -> "" +extendedUsage = + ansiCode = + when cliParser.textStyle is + Color -> "\u(001b)[1m\u(001b)[4m" + Plain -> "" usageHelpStr = Help.usageHelp cliParser.config ["roc-start"] cliParser.textStyle - extendedUsageStr = when - Help.helpText cliParser.config ["roc-start"] cliParser.textStyle - |> Str.splitFirst "$(ansiCode)Commands:" + extendedUsageStr = + when + Help.helpText cliParser.config ["roc-start"] cliParser.textStyle + |> Str.splitFirst "$(ansiCode)Commands:" is Ok { after } -> "$(ansiCode)Commands:$(after)" Err NotFound -> "" @@ -37,7 +39,7 @@ cliParser = } |> Cli.assertValid -appSubcommand = +appSubcommand = Cli.weave { appName: <- Param.str { name: "app-name", help: "Name your new roc app." }, platform: <- Param.str { name: "platform", help: "The platform to use." }, diff --git a/src/AsciiArt.roc b/src/AsciiArt.roc index fabc818..7f38697 100644 --- a/src/AsciiArt.roc +++ b/src/AsciiArt.roc @@ -2,7 +2,7 @@ module [Art, size, width, height, rocStart, rocSmall, rocLarge, rocStartColored, import ansi.Core -Art : { +Art : { height : I32, width : I32, art : List { text : Str, r : I32, c : I32, color : Core.Color }, @@ -21,12 +21,12 @@ width : List Str -> I32 width = \art -> List.first art |> Result.withDefault "" |> Str.countUtf8Bytes |> Num.toI32 rocStart = [ -" _ _ ", -" _ __ ___ ___ ____| |_ __ _ _ __| |_ ", -"| '__/ _ \\ / __|____/ ___| __/ _` | '__| __|", -"| | | (_) | (_|_____\\___ \\ || (_| | | | |_ ", -"|_| \\___/ \\___| |____/\\__\\__,_|_| \\__|", -" quick start cli ", + " _ _ ", + " _ __ ___ ___ ____| |_ __ _ _ __| |_ ", + "| '__/ _ \\ / __|____/ ___| __/ _` | '__| __|", + "| | | (_) | (_|_____\\___ \\ || (_| | | | |_ ", + "|_| \\___/ \\___| |____/\\__\\__,_|_| \\__|", + " quick start cli ", ] rocStartColored = { @@ -48,25 +48,25 @@ rocStartColored = { { text: "\\___ \\ || (_| | | | |_", r: 3, c: 20, color: B8 93 }, { text: "|_| \\___/ \\___|", r: 4, c: 0, color: B8 93 }, { text: "|____/\\__\\__,_|_| \\__|", r: 4, c: 20, color: B8 93 }, - ] + ], } rocSmall = [ -"___ ", -"\\ ---___ ", -" \\ ---___ ", -" \\ |\\ ", -" \\ | \\ __-\\ ", -" \\ | \\--' /_\\ ", -" \\ | \\ / ", -" \\|---______\\ ", -" /| __/ ", -" | | __/ ", -" | |_/ ", -" / | ", -" | | ", -" | _/ ", -" |/ ", + "___ ", + "\\ ---___ ", + " \\ ---___ ", + " \\ |\\ ", + " \\ | \\ __-\\ ", + " \\ | \\--' /_\\ ", + " \\ | \\ / ", + " \\|---______\\ ", + " /| __/ ", + " | | __/ ", + " | |_/ ", + " / | ", + " | | ", + " | _/ ", + " |/ ", ] rocSmallColored = { @@ -100,8 +100,8 @@ rocSmallColored = { { text: "/ |", r: 11, c: 11, color: B8 93 }, { text: "| |", r: 12, c: 10, color: B8 93 }, { text: "| _/", r: 13, c: 10, color: B8 93 }, - { text: "|/", r: 14, c: 10, color: B8 93 }, - ] + { text: "|/", r: 14, c: 10, color: B8 93 }, + ], } rocLarge = [ @@ -192,7 +192,7 @@ rocLargeColored = { { text: "....", r: 16, c: 36, color: B8 177 }, { text: ":------------------------", r: 16, c: 40, color: B8 93 }, { text: "............", r: 16, c: 65, color: B8 177 }, - { text: "-------", r: 16, c: 77, color: B8 93}, + { text: "-------", r: 16, c: 77, color: B8 93 }, { text: "..", r: 17, c: 38, color: B8 177 }, { text: ":--------------------------", r: 17, c: 40, color: B8 93 }, { text: ".........", r: 17, c: 67, color: B8 177 }, diff --git a/src/Controller.roc b/src/Controller.roc index 5e773f2..123572a 100644 --- a/src/Controller.roc +++ b/src/Controller.roc @@ -1,4 +1,4 @@ -module [UserAction, getActions, applyAction] +module [UserAction, getActions, applyAction, paginate] import Keys exposing [Key] import Model exposing [Model] @@ -20,34 +20,25 @@ UserAction : [ SingleSelect, TextInput, TextBackspace, - TextConfirm, + TextSubmit, Secret, None, ] +## Get the available actions for the current state getActions : Model -> List UserAction getActions = \model -> when model.state is PlatformSelect _ -> [Exit, SingleSelect, CursorUp, CursorDown] - |> \actions -> if - Model.menuIsFiltered model - then - List.append actions ClearFilter - else - List.append actions Search + |> \actions -> List.append actions (if Model.menuIsFiltered model then ClearFilter else Search) |> List.append GoBack |> \actions -> if Model.isNotFirstPage model then List.append actions PrevPage else actions |> \actions -> if Model.isNotLastPage model then List.append actions NextPage else actions PackageSelect _ -> [Exit, MultiSelect, MultiConfirm, CursorUp, CursorDown] - |> \actions -> if - Model.menuIsFiltered model - then - List.append actions ClearFilter - else - List.append actions Search + |> \actions -> List.append actions (if Model.menuIsFiltered model then ClearFilter else Search) |> List.append GoBack |> \actions -> if Model.isNotFirstPage model then List.append actions PrevPage else actions |> \actions -> if Model.isNotLastPage model then List.append actions NextPage else actions @@ -57,8 +48,8 @@ getActions = \model -> |> \actions -> if Model.isNotFirstPage model then List.append actions PrevPage else actions |> \actions -> if Model.isNotLastPage model then List.append actions NextPage else actions - InputAppName { nameBuffer } -> - [Exit, TextConfirm, TextInput] + InputAppName { nameBuffer } -> + [Exit, TextSubmit, TextInput] |> \actions -> List.append actions (if List.isEmpty nameBuffer then GoBack else TextBackspace) Confirmation _ -> [Exit, Finish, GoBack] @@ -66,6 +57,11 @@ getActions = \model -> Splash _ -> [Exit, GoBack] _ -> [Exit] +## Check if the user action is available in the current state +actionIsAvailable : Model, UserAction -> Bool +actionIsAvailable = \model, action -> List.contains (getActions model) action + +## Translate the user action into a state transition by dispatching to the appropriate handler applyAction : { model : Model, action : UserAction, keyPress ? [KeyPress Key, None] } -> [Step Model, Done Model] applyAction = \{ model, action, keyPress ? None } -> if actionIsAvailable model action then @@ -77,120 +73,488 @@ applyAction = \{ model, action, keyPress ? None } -> Confirmation _ -> confirmationHandler model action Search { sender } -> searchHandler model action { sender, keyPress } Splash _ -> splashHandler model action - _ -> Step model + _ -> defaultHandler model action else Step model -actionIsAvailable : Model, UserAction -> Bool -actionIsAvailable = \model, action -> List.contains (getActions model) action +## Default handler ensures program can always be exited +defaultHandler : Model, UserAction -> [Step Model, Done Model] +defaultHandler = \model, action -> + when action is + Exit -> Done (toUserExitedState model) + _ -> Step model +## Map the user action to the appropriate state transition from the TypeSelect state typeSelectHandler : Model, UserAction -> [Step Model, Done Model] typeSelectHandler = \model, action -> when action is - Exit -> Done (Model.toUserExitedState model) - SingleSelect -> + Exit -> Done (toUserExitedState model) + SingleSelect -> type = Model.getHighlightedItem model |> \str -> if str == "App" then App else Pkg when type is - App -> Step (Model.toInputAppNameState model) - Pkg -> Step (Model.toPackageSelectState model) + App -> Step (toInputAppNameState model) + Pkg -> Step (toPackageSelectState model) - CursorUp -> Step (Model.moveCursor model Up) - CursorDown -> Step (Model.moveCursor model Down) - Secret -> Step (Model.toSplashState model) + CursorUp -> Step (moveCursor model Up) + CursorDown -> Step (moveCursor model Down) + Secret -> Step (toSplashState model) _ -> Step model +## Map the user action to the appropriate state transition from the PlatformSelect state platformSelectHandler : Model, UserAction -> [Step Model, Done Model] platformSelectHandler = \model, action -> when action is - Exit -> Done (Model.toUserExitedState model) - Search -> Step (Model.toSearchState model) - SingleSelect -> Step (Model.toPackageSelectState model) - CursorUp -> Step (Model.moveCursor model Up) - CursorDown -> Step (Model.moveCursor model Down) + Exit -> Done (toUserExitedState model) + Search -> Step (toSearchState model) + SingleSelect -> Step (toPackageSelectState model) + CursorUp -> Step (moveCursor model Up) + CursorDown -> Step (moveCursor model Down) GoBack -> if Model.menuIsFiltered model then - Step (Model.clearSearchFilter model) + Step (clearSearchFilter model) else - Step (Model.toInputAppNameState model) + Step (toInputAppNameState model) - ClearFilter -> Step (Model.clearSearchFilter model) - NextPage -> Step (Model.nextPage model) - PrevPage -> Step (Model.prevPage model) + ClearFilter -> Step (clearSearchFilter model) + NextPage -> Step (nextPage model) + PrevPage -> Step (prevPage model) _ -> Step model +## Map the user action to the appropriate state transition from the PackageSelect state packageSelectHandler : Model, UserAction -> [Step Model, Done Model] packageSelectHandler = \model, action -> when action is - Exit -> Done (Model.toUserExitedState model) - Search -> Step (Model.toSearchState model) - MultiConfirm -> Step (Model.toConfirmationState model) - MultiSelect -> Step (Model.toggleSelected model) - CursorUp -> Step (Model.moveCursor model Up) - CursorDown -> Step (Model.moveCursor model Down) + Exit -> Done (toUserExitedState model) + Search -> Step (toSearchState model) + MultiConfirm -> Step (toConfirmationState model) + MultiSelect -> Step (toggleSelected model) + CursorUp -> Step (moveCursor model Up) + CursorDown -> Step (moveCursor model Down) GoBack -> if Model.menuIsFiltered model then - Step (Model.clearSearchFilter model) + Step (clearSearchFilter model) else - type = when model.state is - PackageSelect { config } -> config.type - _ -> App + type = + when model.state is + PackageSelect { config } -> config.type + _ -> App when type is - App -> Step (Model.toPlatformSelectState model) - Pkg -> Step (Model.toTypeSelectState model) + App -> Step (toPlatformSelectState model) + Pkg -> Step (toTypeSelectState model) - ClearFilter -> Step (Model.clearSearchFilter model) - NextPage -> Step (Model.nextPage model) - PrevPage -> Step (Model.prevPage model) + ClearFilter -> Step (clearSearchFilter model) + NextPage -> Step (nextPage model) + PrevPage -> Step (prevPage model) _ -> Step model +## Map the user action to the appropriate state transition from the Search state searchHandler : Model, UserAction, { sender : [Platform, Package], keyPress ? [KeyPress Key, None] } -> [Step Model, Done Model] searchHandler = \model, action, { sender, keyPress ? None } -> when action is - Exit -> Done (Model.toUserExitedState model) + Exit -> Done (toUserExitedState model) SearchGo -> when sender is - Platform -> Step (Model.toPlatformSelectState model) - Package -> Step (Model.toPackageSelectState model) + Platform -> Step (toPlatformSelectState model) + Package -> Step (toPackageSelectState model) - TextBackspace -> Step (Model.backspaceBuffer model) + TextBackspace -> Step (backspaceBuffer model) TextInput -> when keyPress is - KeyPress key -> Step (Model.appendToBuffer model key) + KeyPress key -> Step (appendToBuffer model key) None -> Step model Cancel -> when sender is - Platform -> Step (model |> Model.clearSearchBuffer |> Model.toPlatformSelectState) - Package -> Step (model |> Model.clearSearchBuffer |> Model.toPackageSelectState) + Platform -> Step (model |> clearBuffer |> toPlatformSelectState) + Package -> Step (model |> clearBuffer |> toPackageSelectState) _ -> Step model +## Map the user action to the appropriate state transition from the InputAppName state inputAppNameHandler : Model, UserAction, { keyPress ? [KeyPress Key, None] } -> [Step Model, Done Model] inputAppNameHandler = \model, action, { keyPress ? None } -> when action is - Exit -> Done (Model.toUserExitedState model) - TextConfirm -> Step (Model.toPlatformSelectState model) + Exit -> Done (toUserExitedState model) + TextSubmit -> Step (toPlatformSelectState model) TextInput -> when keyPress is - KeyPress key -> Step (Model.appendToBuffer model key) + KeyPress key -> Step (appendToBuffer model key) None -> Step model - Secret -> Step (Model.toSplashState model) - TextBackspace -> Step (Model.backspaceBuffer model) - GoBack -> Step (Model.toTypeSelectState model) + TextBackspace -> Step (backspaceBuffer model) + GoBack -> Step (toTypeSelectState model) _ -> Step model +## Map the user action to the appropriate state transition from the Confirmation state confirmationHandler : Model, UserAction -> [Step Model, Done Model] confirmationHandler = \model, action -> when action is - Exit -> Done (Model.toUserExitedState model) - Finish -> Done (Model.toFinishedState model) - GoBack -> Step (Model.toPackageSelectState model) + Exit -> Done (toUserExitedState model) + Finish -> Done (toFinishedState model) + GoBack -> Step (toPackageSelectState model) _ -> Step model +## Map the user action to the appropriate state transition from the Splash state splashHandler : Model, UserAction -> [Step Model, Done Model] splashHandler = \model, action -> when action is - Exit -> Done (Model.toUserExitedState model) - GoBack -> Step (Model.toTypeSelectState model) + Exit -> Done (toUserExitedState model) + GoBack -> Step (toTypeSelectState model) _ -> Step model + +## Transition to the UserExited state +toUserExitedState : Model -> Model +toUserExitedState = \model -> { model & state: UserExited } + +## Transition to the TypeSelect state +toTypeSelectState : Model -> Model +toTypeSelectState = \model -> + when model.state is + InputAppName { config, nameBuffer } -> + fileName = nameBuffer |> Str.fromUtf8 |> Result.withDefault "main" + newConfig = { config & fileName } + { model & + cursor: { row: 2, col: 2 }, + fullMenu: ["App", "Package"], + state: TypeSelect { config: newConfig }, + } + + PackageSelect { config } -> + configWithPackages = + when (addSelectedPackagesToConfig model).state is + PackageSelect data -> data.config + _ -> config + if config.type == Pkg then + { model & + fullMenu: ["App", "Package"], + cursor: { row: 2, col: 2 }, + state: TypeSelect { config: configWithPackages }, + } + else + model + + Splash { config } -> + { model & + cursor: { row: 2, col: 2 }, + fullMenu: ["App", "Package"], + state: TypeSelect { config }, + } + + _ -> model + +## Transition to the InputAppName state +toInputAppNameState : Model -> Model +toInputAppNameState = \model -> + when model.state is + TypeSelect { config } -> + type = Model.getHighlightedItem model |> \str -> if str == "App" then App else Pkg + { model & + cursor: { row: 2, col: 2 }, + state: InputAppName { config: { config & type }, nameBuffer: config.fileName |> Str.toUtf8 }, + } + + PlatformSelect { config } -> + { model & + cursor: { row: 2, col: 2 }, + state: InputAppName { config, nameBuffer: config.fileName |> Str.toUtf8 }, + } + + Splash { config } -> + { model & + cursor: { row: 2, col: 2 }, + state: InputAppName { config, nameBuffer: config.fileName |> Str.toUtf8 }, + } + + _ -> model + +## Transition to the Splash state +toSplashState : Model -> Model +toSplashState = \model -> + when model.state is + TypeSelect { config } -> + { model & + state: Splash { config }, + } + + _ -> model + +## Transition to the PlatformSelect state +toPlatformSelectState : Model -> Model +toPlatformSelectState = \model -> + when model.state is + InputAppName { config, nameBuffer } -> + fileName = nameBuffer |> Str.fromUtf8 |> Result.withDefault "main" |> \name -> if Str.isEmpty name then "main" else name + newConfig = { config & fileName } + { model & + pageFirstItem: 0, + menu: model.platformList, + fullMenu: model.platformList, + cursor: { row: 2, col: 2 }, + state: PlatformSelect { config: newConfig }, + } + + Search { config, searchBuffer } -> + filteredMenu = + model.platformList + |> List.keepIf \item -> Str.contains item (searchBuffer |> Str.fromUtf8 |> Result.withDefault "") + { model & + pageFirstItem: 0, + menu: filteredMenu, + fullMenu: filteredMenu, + cursor: { row: 2, col: 2 }, + state: PlatformSelect { config }, + } + + PackageSelect { config } -> + configWithPackages = + when (addSelectedPackagesToConfig model).state is + PackageSelect data -> data.config + _ -> config + { model & + pageFirstItem: 0, + menu: model.platformList, + fullMenu: model.platformList, + cursor: { row: 2, col: 2 }, + state: PlatformSelect { config: configWithPackages }, + } + + _ -> model + +## Transition to the PackageSelect state +toPackageSelectState : Model -> Model +toPackageSelectState = \model -> + when model.state is + TypeSelect { config } -> + type = Model.getHighlightedItem model |> \str -> if str == "App" then App else Pkg + fileName = "main" + { model & + pageFirstItem: 0, + menu: model.packageList, + fullMenu: model.packageList, + cursor: { row: 2, col: 2 }, + selected: config.packages, + state: PackageSelect { config: { config & type, fileName } }, + } + + PlatformSelect { config } -> + platform = Model.getHighlightedItem model + { model & + pageFirstItem: 0, + menu: model.packageList, + fullMenu: model.packageList, + cursor: { row: 2, col: 2 }, + selected: config.packages, + state: PackageSelect { config: { config & platform } }, + } + + Search { config, searchBuffer } -> + filteredMenu = + model.packageList + |> List.keepIf \item -> Str.contains item (searchBuffer |> Str.fromUtf8 |> Result.withDefault "") + { model & + pageFirstItem: 0, + menu: filteredMenu, + fullMenu: filteredMenu, + cursor: { row: 2, col: 2 }, + selected: config.packages, + state: PackageSelect { config }, + } + + Confirmation { config } -> + { model & + pageFirstItem: 0, + menu: model.packageList, + fullMenu: model.packageList, + selected: config.packages, + cursor: { row: 2, col: 2 }, + state: PackageSelect { config }, + } + + _ -> model + +## Transition to the Finished state +toFinishedState : Model -> Model +toFinishedState = \model -> + modelWithPackages = addSelectedPackagesToConfig model + when modelWithPackages.state is + PlatformSelect { config } -> { model & state: Finished { config } } + PackageSelect { config } -> { model & state: Finished { config } } + Confirmation { config } -> { model & state: Finished { config } } + _ -> model + +## Transition to the Confirmation state +toConfirmationState : Model -> Model +toConfirmationState = \model -> + modelWithPackages = addSelectedPackagesToConfig model + when modelWithPackages.state is + PlatformSelect { config } -> { model & state: Confirmation { config } } + PackageSelect { config } -> { model & state: Confirmation { config } } + _ -> model + +## Transition to the Search state +toSearchState : Model -> Model +toSearchState = \model -> + when model.state is + PlatformSelect { config } -> + { model & + cursor: { row: model.menuRow, col: 2 }, + state: Search { config, searchBuffer: [], sender: Platform }, + } + + PackageSelect { config } -> + newConfig = { config & packages: model.selected } + { model & + cursor: { row: model.menuRow, col: 2 }, + state: Search { config: newConfig, searchBuffer: [], sender: Package }, + } + + _ -> model + +## Clear the search filter +clearSearchFilter : Model -> Model +clearSearchFilter = \model -> + when model.state is + PackageSelect _ -> + { model & + fullMenu: model.packageList, + # cursor: { row: model.menuRow, col: 2 }, + } + + PlatformSelect _ -> + { model & + fullMenu: model.platformList, + # cursor: { row: model.menuRow, col: 2 }, + } + + _ -> model + +## Append a key to the name or search buffer +appendToBuffer : Model, Key -> Model +appendToBuffer = \model, key -> + when model.state is + Search { searchBuffer, config, sender } -> + newBuffer = List.concat searchBuffer (Keys.keyToSlugStr key |> Str.toUtf8) + { model & state: Search { config, sender, searchBuffer: newBuffer } } + + InputAppName { nameBuffer, config } -> + newBuffer = List.concat nameBuffer (Keys.keyToSlugStr key |> Str.toUtf8) + { model & state: InputAppName { config, nameBuffer: newBuffer } } + + _ -> model + +## Remove the last character from the name or search buffer +backspaceBuffer : Model -> Model +backspaceBuffer = \model -> + when model.state is + Search { searchBuffer, config, sender } -> + newBuffer = List.dropLast searchBuffer 1 + { model & state: Search { config, sender, searchBuffer: newBuffer } } + + InputAppName { nameBuffer, config } -> + newBuffer = List.dropLast nameBuffer 1 + { model & state: InputAppName { config, nameBuffer: newBuffer } } + + _ -> model + +## Clear the search buffer +clearBuffer : Model -> Model +clearBuffer = \model -> + when model.state is + Search { config, sender } -> + { model & state: Search { config, sender, searchBuffer: [] } } + + InputAppName { config } -> + { model & state: InputAppName { config, nameBuffer: [] } } + + _ -> model + +## Toggle the selected state of an item in a multi-select menu +toggleSelected : Model -> Model +toggleSelected = \model -> + item = Model.getHighlightedItem model + if List.contains model.selected item then + { model & selected: List.dropIf model.selected \i -> i == item } + else + { model & selected: List.append model.selected item } + +## Add the selected packages to the configuration +addSelectedPackagesToConfig : Model -> Model +addSelectedPackagesToConfig = \model -> + when model.state is + PackageSelect data -> + packages = Model.getSelectedItems model + { model & + state: PackageSelect + { data & + config: { + platform: data.config.platform, + fileName: data.config.fileName, + packages, + type: data.config.type, + }, + }, + } + + _ -> model + +## Split the menu into pages, and adjust the cursor position if necessary +paginate : Model -> Model +paginate = \model -> + maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 + pageFirstItem = + if List.len model.menu < maxItems && model.pageFirstItem > 0 then + idx = Num.toI64 (List.len model.fullMenu) - Num.toI64 maxItems + if idx >= 0 then Num.toU64 idx else 0 + else + model.pageFirstItem + menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } + curRow = + if model.cursor.row >= model.menuRow + Num.toI32 (List.len menu) && List.len menu > 0 then + model.menuRow + Num.toI32 (List.len menu) - 1 + else + model.cursor.row + cursor = { row: curRow, col: model.cursor.col } + { model & menu, pageFirstItem, cursor } + +## Move to the next page if possible +nextPage : Model -> Model +nextPage = \model -> + maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 + if Model.isNotLastPage model then + pageFirstItem = model.pageFirstItem + maxItems + menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } + cursor = { row: model.menuRow, col: model.cursor.col } + { model & menu, pageFirstItem, cursor } + else + model + +## Move to the previous page if possible +prevPage : Model -> Model +prevPage = \model -> + maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 + if Model.isNotFirstPage model then + pageFirstItem = if (Num.toI64 model.pageFirstItem - Num.toI64 maxItems) > 0 then model.pageFirstItem - maxItems else 0 + menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } + cursor = { row: model.menuRow, col: model.cursor.col } + { model & menu, pageFirstItem, cursor } + else + model + +## Move the cursor up or down +moveCursor : Model, [Up, Down] -> Model +moveCursor = \model, direction -> + if List.len model.menu > 0 then + when direction is + Up -> + if model.cursor.row <= Num.toI32 (model.menuRow) then + { model & cursor: { row: Num.toI32 (List.len model.menu) + model.menuRow - 1, col: model.cursor.col } } + else + { model & cursor: { row: model.cursor.row - 1, col: model.cursor.col } } + + Down -> + if model.cursor.row >= Num.toI32 (List.len model.menu - 1) + Num.toI32 (model.menuRow) then + { model & cursor: { row: Num.toI32 (model.menuRow), col: model.cursor.col } } + else + { model & cursor: { row: model.cursor.row + 1, col: model.cursor.col } } + else + model diff --git a/src/Model.roc b/src/Model.roc index 16cc1ba..fa5fe60 100644 --- a/src/Model.roc +++ b/src/Model.roc @@ -1,33 +1,14 @@ module [ Model, init, - paginate, - nextPage, - prevPage, isNotFirstPage, isNotLastPage, - moveCursor, getHighlightedIndex, getHighlightedItem, - menuIdxToFullIdx, - appendToBuffer, - backspaceBuffer, - clearSearchBuffer, - toggleSelected, - toInputAppNameState, - toPackageSelectState, - toPlatformSelectState, - toFinishedState, - toSearchState, - toConfirmationState, - toUserExitedState, - toSplashState, - toTypeSelectState, - clearSearchFilter, + getSelectedItems, menuIsFiltered, ] -import Keys exposing [Key] import ansi.Core Model : { @@ -55,7 +36,7 @@ Model : { } Configuration : { - type: [App, Pkg], + type : [App, Pkg], fileName : Str, platform : Str, packages : List Str, @@ -70,59 +51,15 @@ init = \platformList, packageList -> { cursor: { row: 2, col: 2 }, menuRow: 2, pageFirstItem: 0, - menu: ["App", "Package"],#platformList, - fullMenu: ["App", "Package"],#platformList, + menu: ["App", "Package"], + fullMenu: ["App", "Package"], platformList, packageList, selected: [], inputs: List.withCapacity 1000, state: TypeSelect { config: emptyAppConfig }, - #state: InputAppName { nameBuffer: [], config: emptyAppConfig }, } -## Split the menu into pages, and adjust the cursor position if necessary -paginate : Model -> Model -paginate = \model -> - maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 - pageFirstItem = - if List.len model.menu < maxItems && model.pageFirstItem > 0 then - idx = Num.toI64 (List.len model.fullMenu) - Num.toI64 maxItems - if idx >= 0 then Num.toU64 idx else 0 - else - model.pageFirstItem - menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } - curRow = - if model.cursor.row >= model.menuRow + Num.toI32 (List.len menu) && List.len menu > 0 then - model.menuRow + Num.toI32 (List.len menu) - 1 - else - model.cursor.row - cursor = { row: curRow, col: model.cursor.col } - { model & menu, pageFirstItem, cursor } - -## Move to the next page if possible -nextPage : Model -> Model -nextPage = \model -> - maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 - if isNotLastPage model then - pageFirstItem = model.pageFirstItem + maxItems - menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } - cursor = { row: model.menuRow, col: model.cursor.col } - paginate { model & menu, pageFirstItem, cursor } - else - model - -## Move to the previous page if possible -prevPage : Model -> Model -prevPage = \model -> - maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 - if isNotFirstPage model then - pageFirstItem = if (Num.toI64 model.pageFirstItem - Num.toI64 maxItems) > 0 then model.pageFirstItem - maxItems else 0 - menu = List.sublist model.fullMenu { start: pageFirstItem, len: maxItems } - cursor = { row: model.menuRow, col: model.cursor.col } - paginate { model & menu, pageFirstItem, cursor } - else - model - ## Check if the current page is not the first page isNotFirstPage : Model -> Bool isNotFirstPage = \model -> model.pageFirstItem > 0 @@ -133,306 +70,6 @@ isNotLastPage = \model -> maxItems = model.screen.height - (model.menuRow + 1) |> Num.toU64 model.pageFirstItem + maxItems < List.len model.fullMenu -## Move the cursor up or down -moveCursor : Model, [Up, Down] -> Model -moveCursor = \model, direction -> - if List.len model.menu > 0 then - when direction is - Up -> - if model.cursor.row <= Num.toI32 (model.menuRow) then - { model & cursor: { row: Num.toI32 (List.len model.menu) + model.menuRow - 1, col: model.cursor.col } } - else - { model & cursor: { row: model.cursor.row - 1, col: model.cursor.col } } - - Down -> - if model.cursor.row >= Num.toI32 (List.len model.menu - 1) + Num.toI32 (model.menuRow) then - { model & cursor: { row: Num.toI32 (model.menuRow), col: model.cursor.col } } - else - { model & cursor: { row: model.cursor.row + 1, col: model.cursor.col } } - else - model - -## Transition to the UserExited state -toUserExitedState : Model -> Model -toUserExitedState = \model -> { model & state: UserExited } - -toTypeSelectState : Model -> Model -toTypeSelectState = \model -> - when model.state is - InputAppName { config, nameBuffer } -> - fileName = nameBuffer |> Str.fromUtf8 |> Result.withDefault "main" - newConfig = { config & fileName } - { model & - cursor: { row: 2, col: 2 }, - fullMenu: ["App", "Package"], - state: TypeSelect { config: newConfig } - } - - PackageSelect { config } -> - configWithPackages = - when (addSelectedPackagesToConfig model).state is - PackageSelect data -> data.config - _ -> config - if config.type == Pkg then - { model & - fullMenu: ["App", "Package"], - cursor: { row: 2, col: 2 }, - state: TypeSelect { config: configWithPackages } - } - else - model - - Splash { config } -> - { model & - cursor: { row: 2, col: 2 }, - fullMenu: ["App", "Package"], - state: TypeSelect { config } - } - - _ -> model - -toInputAppNameState : Model -> Model -toInputAppNameState = \model -> - when model.state is - TypeSelect { config } -> - type = getHighlightedItem model |> \str -> if str == "App" then App else Pkg - { model & - cursor: { row: 2, col: 2 }, - state: InputAppName { config: { config & type }, nameBuffer: config.fileName |> Str.toUtf8 }, - } - PlatformSelect { config } -> - { model & - cursor: { row: 2, col: 2 }, - state: InputAppName { config, nameBuffer: config.fileName |> Str.toUtf8 }, - } - - Splash { config } -> - { model & - cursor: { row: 2, col: 2 }, - state: InputAppName { config, nameBuffer: config.fileName |> Str.toUtf8 }, - } - - _ -> model - -toSplashState : Model -> Model -toSplashState = \model -> - when model.state is - TypeSelect { config } -> - { model & - state: Splash { config }, - } - - _ -> model - -## Transition to the PlatformSelect state -toPlatformSelectState : Model -> Model -toPlatformSelectState = \model -> - when model.state is - InputAppName { config, nameBuffer } -> - fileName = nameBuffer |> Str.fromUtf8 |> Result.withDefault "main" |> \name -> if Str.isEmpty name then "main" else name - newConfig = { config & fileName } - { model & - pageFirstItem: 0, - fullMenu: model.platformList, - cursor: { row: 2, col: 2 }, - state: PlatformSelect { config: newConfig }, - } - |> paginate - - Search { config, searchBuffer } -> - { model & - fullMenu: model.platformList |> List.keepIf \item -> Str.contains item (searchBuffer |> Str.fromUtf8 |> Result.withDefault ""), - cursor: { row: 2, col: 2 }, - state: PlatformSelect { config }, - } - |> paginate - - PackageSelect { config } -> - configWithPackages = - when (addSelectedPackagesToConfig model).state is - PackageSelect data -> data.config - _ -> config - { model & - pageFirstItem: 0, - fullMenu: model.platformList, - cursor: { row: 2, col: 2 }, - state: PlatformSelect { config: configWithPackages }, - } - |> paginate - - _ -> model - - - -## To the PackageSelect state -toPackageSelectState : Model -> Model -toPackageSelectState = \model -> - when model.state is - TypeSelect { config } -> - type = getHighlightedItem model |> \str -> if str == "App" then App else Pkg - fileName = "main" - { model & - pageFirstItem: 0, - fullMenu: model.packageList, - cursor: { row: 2, col: 2 }, - selected: config.packages, - state: PackageSelect { config: { config & type, fileName } }, - } - PlatformSelect { config } -> - platform = getHighlightedItem model - { model & - pageFirstItem: 0, - fullMenu: model.packageList, - cursor: { row: 2, col: 2 }, - selected: config.packages, - state: PackageSelect { config: { config & platform } }, - } - |> paginate - - Search { config, searchBuffer } -> - { model & - pageFirstItem: 0, - fullMenu: model.packageList |> List.keepIf \item -> Str.contains item (searchBuffer |> Str.fromUtf8 |> Result.withDefault ""), - cursor: { row: 2, col: 2 }, - selected: config.packages, - state: PackageSelect { config }, - } - |> paginate - - Confirmation { config } -> - { model & - pageFirstItem: 0, - fullMenu: model.packageList, - selected: config.packages, - cursor: { row: 2, col: 2 }, - state: PackageSelect { config }, - } - |> paginate - - _ -> model - -## Transition to the Finished state -toFinishedState : Model -> Model -toFinishedState = \model -> - modelWithPackages = addSelectedPackagesToConfig model - when modelWithPackages.state is - PlatformSelect { config } -> { model & state: Finished { config } } - PackageSelect { config } -> { model & state: Finished { config } } - Confirmation { config } -> { model & state: Finished { config } } - _ -> model - -## Transition to the Confirmation state -toConfirmationState : Model -> Model -toConfirmationState = \model -> - modelWithPackages = addSelectedPackagesToConfig model - when modelWithPackages.state is - PlatformSelect { config } -> { model & state: Confirmation { config } } - PackageSelect { config } -> { model & state: Confirmation { config } } - _ -> model - -## Transition to the Search state -toSearchState : Model -> Model -toSearchState = \model -> - when model.state is - PlatformSelect { config } -> - { model & - cursor: { row: model.menuRow, col: 2 }, - state: Search { config, searchBuffer: [], sender: Platform }, - } - - PackageSelect { config } -> - newConfig = { config & packages: model.selected } - { model & - cursor: { row: model.menuRow, col: 2 }, - state: Search { config: newConfig, searchBuffer: [], sender: Package }, - } - - _ -> model - -## Clear the search filter -clearSearchFilter : Model -> Model -clearSearchFilter = \model -> - when model.state is - PackageSelect _ -> - { model & - fullMenu: model.packageList, - # cursor: { row: model.menuRow, col: 2 }, - } - |> paginate - - PlatformSelect _ -> - { model & - fullMenu: model.platformList, - # cursor: { row: model.menuRow, col: 2 }, - } - |> paginate - - _ -> model - -## Append a key to the name or search buffer -appendToBuffer : Model, Key -> Model -appendToBuffer = \model, key -> - when model.state is - Search { searchBuffer, config, sender } -> - newBuffer = List.concat searchBuffer (Keys.keyToSlugStr key |> Str.toUtf8) - { model & state: Search { config, sender, searchBuffer: newBuffer } } - - InputAppName { nameBuffer, config } -> - newBuffer = List.concat nameBuffer (Keys.keyToSlugStr key |> Str.toUtf8) - { model & state: InputAppName { config, nameBuffer: newBuffer } } - - _ -> model - -## Remove the last character from the name or search buffer -backspaceBuffer : Model -> Model -backspaceBuffer = \model -> - when model.state is - Search { searchBuffer, config, sender } -> - newBuffer = List.dropLast searchBuffer 1 - { model & state: Search { config, sender, searchBuffer: newBuffer } } - - InputAppName { nameBuffer, config } -> - newBuffer = List.dropLast nameBuffer 1 - { model & state: InputAppName { config, nameBuffer: newBuffer } } - - _ -> model - -## Clear the search buffer -clearSearchBuffer : Model -> Model -clearSearchBuffer = \model -> - when model.state is - Search { config, sender } -> - { model & state: Search { config, sender, searchBuffer: [] } } - - _ -> model - -## Toggle the selected state of an item in a multi-select menu -toggleSelected : Model -> Model -toggleSelected = \model -> - item = getHighlightedItem model - if List.contains model.selected item then - { model & selected: List.dropIf model.selected \i -> i == item } - else - { model & selected: List.append model.selected item } - -## Add the selected packages to the configuration -addSelectedPackagesToConfig : Model -> Model -addSelectedPackagesToConfig = \model -> - when model.state is - PackageSelect data -> - packages = getSelectedItems model - { model & state: PackageSelect { data & - config: { - platform: data.config.platform, - fileName: data.config.fileName, - packages, - type: data.config.type, - } - } - } - - _ -> model - ## Get the index of the highlighted item getHighlightedIndex : Model -> U64 getHighlightedIndex = \model -> Num.toU64 model.cursor.row - Num.toU64 model.menuRow @@ -441,14 +78,11 @@ getHighlightedIndex = \model -> Num.toU64 model.cursor.row - Num.toU64 model.men getHighlightedItem : Model -> Str getHighlightedItem = \model -> List.get model.menu (getHighlightedIndex model) |> Result.withDefault "" -## Convert the index of an item in the menu to the index in the full menu -menuIdxToFullIdx : U64, Model -> U64 -menuIdxToFullIdx = \idx, model -> idx + model.pageFirstItem - ## Get the selected items in a multi-select menu getSelectedItems : Model -> List Str getSelectedItems = \model -> model.selected +## Check if the menu is currently filtered menuIsFiltered : Model -> Bool menuIsFiltered = \model -> when model.state is diff --git a/src/Tests.roc b/src/Tests.roc index c7bb571..611d6af 100644 --- a/src/Tests.roc +++ b/src/Tests.roc @@ -1,263 +1,360 @@ module [stubForMain] import Model +import Controller stubForMain = {} emptyAppConfig = { fileName: "", platform: "", packages: [], type: App } -# ==================== -# TEST MODEL -# ==================== +typeSelectModel = Model.init ["pf1", "pf2"] ["pk1", "pk2", "pk3"] -expect - # TEST: Model.init - model = Model.init [] [] - model.menuRow - == 2 - && model.pageFirstItem - == 0 - && model.cursor - == { row: 2, col: 2 } - && model.state - == TypeSelect { config: emptyAppConfig } +inputAppNameModel = + when Controller.applyAction { model: typeSelectModel, action: SingleSelect } is + Step model -> model + Done model -> model -expect - # TEST: InputAppName to PlatformSelect w/ empty buffer - model = Model.init [] [] - newModel = model |> Model.toInputAppNameState |> Model.toPlatformSelectState - newModel.state - == PlatformSelect { config: { emptyAppConfig & fileName: "main" } } - && newModel.cursor.row - == newModel.menuRow +platformSelectModel = + when Controller.applyAction { model: inputAppNameModel, action: TextSubmit } is + Step model -> model + Done model -> model -expect - # TEST: InputAppName to PlatformSelect w/ non-empty buffer - initModel = Model.init [] [] |> Model.toInputAppNameState - model = { initModel & - state: InputAppName { nameBuffer: ['h', 'e', 'l', 'l', 'o'], config: emptyAppConfig }, - } - newModel = Model.toPlatformSelectState model - newModel.state - == PlatformSelect { config: { emptyAppConfig & fileName: "hello" } } - && newModel.cursor.row - == newModel.menuRow +packageSelectModel = + when Controller.applyAction { model: platformSelectModel, action: SingleSelect } is + Step model -> model + Done model -> model -expect - # TEST: InputAppName to PlatformSelect w/ non-empty config - initModel = Model.init [] [] |> Model.toInputAppNameState - model = { initModel & - state: InputAppName { nameBuffer: [], config: { fileName: "main", platform: "test", packages: ["test"], type: App } }, - } - newModel = Model.toPlatformSelectState model - newModel.state - == PlatformSelect { config: { fileName: "main", platform: "test", packages: ["test"], type: App } } - && newModel.cursor.row - == newModel.menuRow +confirmationModel = + when Controller.applyAction { model: packageSelectModel, action: MultiConfirm } is + Step model -> model + Done model -> model -expect - # TEST: InputAppName to PlatformSelect w/ empty buffer & existing fileName in config - initModel = Model.init [] [] |> Model.toInputAppNameState - model = { initModel & - state: InputAppName { nameBuffer: [], config: { emptyAppConfig & fileName: "hello" } }, - } - newModel = Model.toPlatformSelectState model - newModel.state - == PlatformSelect { config: { emptyAppConfig & fileName: "main" } } - && newModel.cursor.row - == newModel.menuRow +finishedModel = + when Controller.applyAction { model: confirmationModel, action: Finish } is + Step model -> model + Done model -> model -expect - # TEST: InputAppName to UserExited - model = Model.init [] [] |> Model.toInputAppNameState - newModel = Model.toUserExitedState model - newModel.state == UserExited +## ========================= +## TEST STATE TRANSITIONS +## ========================= expect - # TEST: paginate InputAppName - initModel = Model.init [] [] |> Model.toInputAppNameState - model = { initModel & state: InputAppName { nameBuffer: ['a'], config: { fileName: "b", platform: "c", packages: ["d", "e"], type: App } } } - newModel = Model.paginate model - model == newModel + model = typeSelectModel + (model.state == TypeSelect { config: emptyAppConfig }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.menu == ["App", "Package"]) + && (model.fullMenu == ["App", "Package"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) expect - # TEST: InputAppName - appendToBuffer w/ legal Key - model = Model.init [] [] |> Model.toInputAppNameState - newModel = - model - |> Model.appendToBuffer LowerA - |> Model.appendToBuffer UpperA - |> Model.appendToBuffer LowerZ - |> Model.appendToBuffer UpperZ - |> Model.appendToBuffer Space - |> Model.appendToBuffer Hyphen - |> Model.appendToBuffer Underscore - |> Model.appendToBuffer Number0 - |> Model.appendToBuffer Number9 - newModel.state == InputAppName { nameBuffer: ['a', 'A', 'z', 'Z', '_', '-', '_', '0', '9'], config: emptyAppConfig } + model = inputAppNameModel + (model.state == InputAppName { nameBuffer: [], config: emptyAppConfig }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) expect - # TEST: InputAppName - appendToBuffer w/ illegal Key - model = Model.init [] [] |> Model.toInputAppNameState - newModel = - model - |> Model.appendToBuffer Up - |> Model.appendToBuffer Down - |> Model.appendToBuffer Left - |> Model.appendToBuffer Right - |> Model.appendToBuffer Escape - |> Model.appendToBuffer Enter - |> Model.appendToBuffer ExclamationMark - |> Model.appendToBuffer QuotationMark - |> Model.appendToBuffer NumberSign - |> Model.appendToBuffer DollarSign - |> Model.appendToBuffer PercentSign - |> Model.appendToBuffer Ampersand - |> Model.appendToBuffer Apostrophe - |> Model.appendToBuffer RoundOpenBracket - |> Model.appendToBuffer RoundCloseBracket - |> Model.appendToBuffer Asterisk - |> Model.appendToBuffer PlusSign - |> Model.appendToBuffer Comma - |> Model.appendToBuffer FullStop - |> Model.appendToBuffer ForwardSlash - |> Model.appendToBuffer Colon - |> Model.appendToBuffer SemiColon - |> Model.appendToBuffer LessThanSign - |> Model.appendToBuffer EqualsSign - |> Model.appendToBuffer GreaterThanSign - |> Model.appendToBuffer QuestionMark - |> Model.appendToBuffer AtSign - |> Model.appendToBuffer SquareOpenBracket - |> Model.appendToBuffer Backslash - |> Model.appendToBuffer SquareCloseBracket - |> Model.appendToBuffer Caret - |> Model.appendToBuffer GraveAccent - |> Model.appendToBuffer CurlyOpenBrace - |> Model.appendToBuffer VerticalBar - |> Model.appendToBuffer CurlyCloseBrace - |> Model.appendToBuffer Tilde - |> Model.appendToBuffer Delete - newModel == model + model = platformSelectModel + (model.state == PlatformSelect { config: { emptyAppConfig & fileName: "main" } }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.menu == ["pf1", "pf2"]) + && (model.fullMenu == ["pf1", "pf2"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) expect - # TEST: InputAppName - backspaceBuffer - model = Model.init [] [] |> Model.toInputAppNameState - newModel = - model - |> Model.appendToBuffer LowerA - |> Model.backspaceBuffer - newModel.state == InputAppName { nameBuffer: [], config: emptyAppConfig } + model = packageSelectModel + (model.state == PackageSelect { config: { emptyAppConfig & fileName: "main", platform: "pf1" } }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.menu == ["pk1", "pk2", "pk3"]) + && (model.fullMenu == ["pk1", "pk2", "pk3"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) expect - # TEST: PlatformSelect - moveCursor Up w/ only one item - initModel = Model.init ["platform1"] [] |> Model.toInputAppNameState - model = Model.toPlatformSelectState initModel - newModel = model |> Model.moveCursor Up - newModel == model + model = confirmationModel + (model.state == Confirmation { config: { emptyAppConfig & fileName: "main", platform: "pf1", packages: [] } }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) expect - # TEST: PlatformSelect - moveCursor Down w/ only one item - initModel = Model.init ["platform1"] [] |> Model.toInputAppNameState - model = Model.toPlatformSelectState initModel - newModel = model |> Model.moveCursor Down - newModel == model + model = finishedModel + (model.state == Finished { config: { emptyAppConfig & fileName: "main", platform: "pf1", packages: [] } }) + && (model.platformList == ["pf1", "pf2"]) + && (model.packageList == ["pk1", "pk2", "pk3"]) + && (model.cursor == { row: 2, col: 2 }) + && (model.screen == { height: 0, width: 0 }) + && (model.selected == []) + && (model.pageFirstItem == 0) + && (model.menuRow == 2) -expect - # TEST: PlatformSelect - moveCursor Up w/ cursor starting at bottom - initModel = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - model = { initModel & - cursor: { row: initModel.menuRow + 2, col: 2 }, - } - newModel = model |> Model.moveCursor Up - newModel.cursor.row == model.menuRow + 1 -expect - # TEST: PlatformSelect - moveCursor Down w/ cursor starting at top - model = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - newModel = model |> Model.moveCursor Down - newModel.cursor.row == model.menuRow + 1 +# expect +# # TEST: Model.init +# model = Model.init [] [] +# (model.menuRow == 2) +# && (model.pageFirstItem == 0) +# && (model.cursor == { row: 2, col: 2 }) +# && (model.state == TypeSelect { config: emptyAppConfig }) -expect - # TEST: PlatformSelect - moveCursor Up w/ cursor starting at top - model = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - newModel = model |> Model.moveCursor Up - newModel.cursor.row == model.menuRow + 2 +# expect +# # TEST: InputAppName to PlatformSelect w/ empty buffer +# model = Model.init [] [] +# newModel = model |> Model.toInputAppNameState |> Model.toPlatformSelectState +# newModel.state +# == PlatformSelect { config: { emptyAppConfig & fileName: "main" } } +# && newModel.cursor.row +# == newModel.menuRow -expect - # TEST: PlatformSelect - moveCursor Down w/ cursor starting at bottom - initModel = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toPlatformSelectState - |> Model.toPlatformSelectState - model = { initModel & - cursor: { row: initModel.menuRow + 2, col: 2 }, - } - newModel = model |> Model.moveCursor Down - newModel.cursor.row == model.menuRow +# expect +# # TEST: InputAppName to PlatformSelect w/ non-empty buffer +# initModel = Model.init [] [] |> Model.toInputAppNameState +# model = { initModel & +# state: InputAppName { nameBuffer: ['h', 'e', 'l', 'l', 'o'], config: emptyAppConfig }, +# } +# newModel = Model.toPlatformSelectState model +# newModel.state +# == PlatformSelect { config: { emptyAppConfig & fileName: "hello" } } +# && newModel.cursor.row +# == newModel.menuRow -expect - # TEST: PlatformSelect to InputAppName - initModel = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - model = { initModel & - state: PlatformSelect { config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } }, - } - newModel = Model.toInputAppNameState model - newModel.state - == InputAppName { nameBuffer: ['a'], config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } } - && newModel.cursor.row - == newModel.menuRow +# expect +# # TEST: InputAppName to PlatformSelect w/ non-empty config +# initModel = Model.init [] [] |> Model.toInputAppNameState +# model = { initModel & +# state: InputAppName { nameBuffer: [], config: { fileName: "main", platform: "test", packages: ["test"], type: App } }, +# } +# newModel = Model.toPlatformSelectState model +# newModel.state +# == PlatformSelect { config: { fileName: "main", platform: "test", packages: ["test"], type: App } } +# && newModel.cursor.row +# == newModel.menuRow -expect - # TEST: PlatformSelect to Search - initModel = - Model.init ["platform1", "platform2", "platform3"] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - model = { initModel & - state: PlatformSelect { config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } }, - } - newModel = Model.toSearchState model - newModel.state - == Search { searchBuffer: [], sender: Platform, config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } } - && newModel.cursor.row - == newModel.menuRow +# expect +# # TEST: InputAppName to PlatformSelect w/ empty buffer & existing fileName in config +# initModel = Model.init [] [] |> Model.toInputAppNameState +# model = { initModel & +# state: InputAppName { nameBuffer: [], config: { emptyAppConfig & fileName: "hello" } }, +# } +# newModel = Model.toPlatformSelectState model +# newModel.state +# == PlatformSelect { config: { emptyAppConfig & fileName: "main" } } +# && newModel.cursor.row +# == newModel.menuRow -expect - # TEST: PlatformSelect to UserExited - model = - Model.init [] [] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - newModel = Model.toUserExitedState model - newModel.state == UserExited +# expect +# # TEST: InputAppName to UserExited +# model = Model.init [] [] |> Model.toInputAppNameState +# newModel = Model.toUserExitedState model +# newModel.state == UserExited -expect - # TEST: PlatformSelect to PackageSelect - initModel = - Model.init ["b"] ["c", "d"] - |> Model.toInputAppNameState - |> Model.toPlatformSelectState - model = { initModel & - cursor: { row: initModel.menuRow, col: 2 }, - state: PlatformSelect { config: { fileName: "a", platform: "", packages: ["c"], type: App } }, - } - newModel = Model.toPackageSelectState model - newModel.state - == PackageSelect { config: { fileName: "a", platform: "b", packages: ["c"], type: App } } - && newModel.cursor.row - == newModel.menuRow - && newModel.selected - == ["c"] +# expect +# # TEST: paginate InputAppName +# initModel = Model.init [] [] |> Model.toInputAppNameState +# model = { initModel & state: InputAppName { nameBuffer: ['a'], config: { fileName: "b", platform: "c", packages: ["d", "e"], type: App } } } +# newModel = Model.paginate model +# model == newModel + +# expect +# # TEST: InputAppName - appendToBuffer w/ legal Key +# model = Model.init [] [] |> Model.toInputAppNameState +# newModel = +# model +# |> Model.appendToBuffer LowerA +# |> Model.appendToBuffer UpperA +# |> Model.appendToBuffer LowerZ +# |> Model.appendToBuffer UpperZ +# |> Model.appendToBuffer Space +# |> Model.appendToBuffer Hyphen +# |> Model.appendToBuffer Underscore +# |> Model.appendToBuffer Number0 +# |> Model.appendToBuffer Number9 +# newModel.state == InputAppName { nameBuffer: ['a', 'A', 'z', 'Z', '_', '-', '_', '0', '9'], config: emptyAppConfig } + +# expect +# # TEST: InputAppName - appendToBuffer w/ illegal Key +# model = Model.init [] [] |> Model.toInputAppNameState +# newModel = +# model +# |> Model.appendToBuffer Up +# |> Model.appendToBuffer Down +# |> Model.appendToBuffer Left +# |> Model.appendToBuffer Right +# |> Model.appendToBuffer Escape +# |> Model.appendToBuffer Enter +# |> Model.appendToBuffer ExclamationMark +# |> Model.appendToBuffer QuotationMark +# |> Model.appendToBuffer NumberSign +# |> Model.appendToBuffer DollarSign +# |> Model.appendToBuffer PercentSign +# |> Model.appendToBuffer Ampersand +# |> Model.appendToBuffer Apostrophe +# |> Model.appendToBuffer RoundOpenBracket +# |> Model.appendToBuffer RoundCloseBracket +# |> Model.appendToBuffer Asterisk +# |> Model.appendToBuffer PlusSign +# |> Model.appendToBuffer Comma +# |> Model.appendToBuffer FullStop +# |> Model.appendToBuffer ForwardSlash +# |> Model.appendToBuffer Colon +# |> Model.appendToBuffer SemiColon +# |> Model.appendToBuffer LessThanSign +# |> Model.appendToBuffer EqualsSign +# |> Model.appendToBuffer GreaterThanSign +# |> Model.appendToBuffer QuestionMark +# |> Model.appendToBuffer AtSign +# |> Model.appendToBuffer SquareOpenBracket +# |> Model.appendToBuffer Backslash +# |> Model.appendToBuffer SquareCloseBracket +# |> Model.appendToBuffer Caret +# |> Model.appendToBuffer GraveAccent +# |> Model.appendToBuffer CurlyOpenBrace +# |> Model.appendToBuffer VerticalBar +# |> Model.appendToBuffer CurlyCloseBrace +# |> Model.appendToBuffer Tilde +# |> Model.appendToBuffer Delete +# newModel == model + +# expect +# # TEST: InputAppName - backspaceBuffer +# model = Model.init [] [] |> Model.toInputAppNameState +# newModel = +# model +# |> Model.appendToBuffer LowerA +# |> Model.backspaceBuffer +# newModel.state == InputAppName { nameBuffer: [], config: emptyAppConfig } + +# expect +# # TEST: PlatformSelect - moveCursor Up w/ only one item +# initModel = Model.init ["platform1"] [] |> Model.toInputAppNameState +# model = Model.toPlatformSelectState initModel +# newModel = model |> Model.moveCursor Up +# newModel == model + +# expect +# # TEST: PlatformSelect - moveCursor Down w/ only one item +# initModel = Model.init ["platform1"] [] |> Model.toInputAppNameState +# model = Model.toPlatformSelectState initModel +# newModel = model |> Model.moveCursor Down +# newModel == model + +# expect +# # TEST: PlatformSelect - moveCursor Up w/ cursor starting at bottom +# initModel = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# model = { initModel & +# cursor: { row: initModel.menuRow + 2, col: 2 }, +# } +# newModel = model |> Model.moveCursor Up +# newModel.cursor.row == model.menuRow + 1 + +# expect +# # TEST: PlatformSelect - moveCursor Down w/ cursor starting at top +# model = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# newModel = model |> Model.moveCursor Down +# newModel.cursor.row == model.menuRow + 1 + +# expect +# # TEST: PlatformSelect - moveCursor Up w/ cursor starting at top +# model = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# newModel = model |> Model.moveCursor Up +# newModel.cursor.row == model.menuRow + 2 + +# expect +# # TEST: PlatformSelect - moveCursor Down w/ cursor starting at bottom +# initModel = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toPlatformSelectState +# |> Model.toPlatformSelectState +# model = { initModel & +# cursor: { row: initModel.menuRow + 2, col: 2 }, +# } +# newModel = model |> Model.moveCursor Down +# newModel.cursor.row == model.menuRow + +# expect +# # TEST: PlatformSelect to InputAppName +# initModel = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# model = { initModel & +# state: PlatformSelect { config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } }, +# } +# newModel = Model.toInputAppNameState model +# newModel.state +# == InputAppName { nameBuffer: ['a'], config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } } +# && newModel.cursor.row +# == newModel.menuRow + +# expect +# # TEST: PlatformSelect to Search +# initModel = +# Model.init ["platform1", "platform2", "platform3"] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# model = { initModel & +# state: PlatformSelect { config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } }, +# } +# newModel = Model.toSearchState model +# newModel.state +# == Search { searchBuffer: [], sender: Platform, config: { fileName: "a", platform: "b", packages: ["c", "d"], type: App } } +# && newModel.cursor.row +# == newModel.menuRow + +# expect +# # TEST: PlatformSelect to UserExited +# model = +# Model.init [] [] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# newModel = Model.toUserExitedState model +# newModel.state == UserExited + +# expect +# # TEST: PlatformSelect to PackageSelect +# initModel = +# Model.init ["b"] ["c", "d"] +# |> Model.toInputAppNameState +# |> Model.toPlatformSelectState +# model = { initModel & +# cursor: { row: initModel.menuRow, col: 2 }, +# state: PlatformSelect { config: { fileName: "a", platform: "", packages: ["c"], type: App } }, +# } +# newModel = Model.toPackageSelectState model +# newModel.state +# == PackageSelect { config: { fileName: "a", platform: "b", packages: ["c"], type: App } } +# && newModel.cursor.row +# == newModel.menuRow +# && newModel.selected +# == ["c"] diff --git a/src/View.roc b/src/View.roc index 5a921bc..d6c8cd3 100644 --- a/src/View.roc +++ b/src/View.roc @@ -19,7 +19,7 @@ controlPromptsDict = |> Dict.insert SingleSelect "ENTER : SELECT" |> Dict.insert MultiSelect "SPACE : SELECT" |> Dict.insert MultiConfirm "ENTER : CONFIRM" - |> Dict.insert TextConfirm "ENTER : CONFIRM" + |> Dict.insert TextSubmit "ENTER : CONFIRM" |> Dict.insert GoBack "BKSP : GO BACK" |> Dict.insert Search "S : SEARCH" |> Dict.insert ClearFilter "ESC : FULL LIST" @@ -43,7 +43,7 @@ controlPromptsShortDict = |> Dict.insert SingleSelect "ENTER" |> Dict.insert MultiSelect "SPACE" |> Dict.insert MultiConfirm "ENTER" - |> Dict.insert TextConfirm "ENTER" + |> Dict.insert TextSubmit "ENTER" |> Dict.insert GoBack "BKSP" |> Dict.insert Search "S" |> Dict.insert ClearFilter "ESC" diff --git a/src/main.roc b/src/main.roc index 16b57de..49fe84b 100644 --- a/src/main.roc +++ b/src/main.roc @@ -30,7 +30,7 @@ Configuration : { fileName : Str, platform : Str, packages : List Str, - type: [App, Pkg], + type : [App, Pkg], } greenCheck = "✔" |> Core.withFg (Standard Green) @@ -83,7 +83,7 @@ runCliApp = \type, fileName, platform, packages, forceUpdate -> else createRocFile! { fileName, platform, packages, type } repos Stdout.line! "Created $(fileName).roc $(greenCheck)" - + ## Run the TUI application. ## Load the repository data, run the main tui loop, and create the roc file when the user confirms their selections. runTuiApp : Bool -> Task {} _ @@ -118,6 +118,7 @@ runUpdates = \doPfs, doPkgs, doStubs -> Task.ok (Step (List.dropFirst updateList 1)) else Task.ok (Step (List.dropFirst updateList 1)) + _ -> Task.ok (Done {}) ## The main loop for running the TUI. @@ -125,20 +126,12 @@ runUpdates = \doPfs, doPkgs, doStubs -> runUiLoop : Model -> Task [Step Model, Done Model] _ runUiLoop = \prevModel -> terminalSize = getTerminalSize! - model = Model.paginate { prevModel & screen: terminalSize } + model = Controller.paginate { prevModel & screen: terminalSize } Core.drawScreen model (render model) |> Stdout.write! input = Stdin.bytes |> Task.map! Core.parseRawStdin modelWithInput = { model & inputs: List.append model.inputs input } - when model.state is - TypeSelect _ -> handleTypeSelectInput modelWithInput input - InputAppName _ -> handleInputAppNameInput modelWithInput input - PlatformSelect _ -> handlePlatformSelectInput modelWithInput input - PackageSelect _ -> handlePackageSelectInput modelWithInput input - Search _ -> handleSearchInput modelWithInput input - Confirmation _ -> handleConfirmationInput modelWithInput input - Splash _ -> handleSplashInput modelWithInput input - _ -> handleBasicInput modelWithInput input + handleInput modelWithInput input ## Get the size of the terminal window. ## Author: Luke Boswell @@ -165,14 +158,29 @@ render = \model -> Splash _ -> View.renderSplash model _ -> [] -## Basic input handler which ensures that the program can always be exited. -## This ensures that even if forget to handle input for a state, or end up +## Dispatch the input to the input handler for the current state. +handleInput : Model, Core.Input -> Task [Step Model, Done Model] _ +handleInput = \model, input -> + when model.state is + TypeSelect _ -> handleTypeSelectInput model input + InputAppName _ -> handleInputAppNameInput model input + PlatformSelect _ -> handlePlatformSelectInput model input + PackageSelect _ -> handlePackageSelectInput model input + Search _ -> handleSearchInput model input + Confirmation _ -> handleConfirmationInput model input + Splash _ -> handleSplashInput model input + _ -> handleDefaultInput model input + +## Default input handler which ensures that the program can always be exited. +## This ensures that even if you forget to handle input for a state, or end up ## in a state that doesn't have an input handler, the program can still be exited. -handleBasicInput : Model, Core.Input -> Task [Step Model, Done Model] _ -handleBasicInput = \model, input -> - when input is - CtrlC -> Task.ok (Done (Model.toUserExitedState model)) - _ -> Task.ok (Step model) +handleDefaultInput : Model, Core.Input -> Task [Step Model, Done Model] _ +handleDefaultInput = \model, input -> + action = + when input is + CtrlC -> Exit + _ -> None + Task.ok (Controller.applyAction { model, action }) handleTypeSelectInput : Model, Core.Input -> Task [Step Model, Done Model] _ handleTypeSelectInput = \model, input -> @@ -253,13 +261,14 @@ handleSearchInput = \model, input -> ## The input handler for the InputAppName state. handleInputAppNameInput : Model, Core.Input -> Task [Step Model, Done Model] _ handleInputAppNameInput = \model, input -> - bufferLen = when model.state is - InputAppName { nameBuffer } -> List.len nameBuffer - _ -> 0 + bufferLen = + when model.state is + InputAppName { nameBuffer } -> List.len nameBuffer + _ -> 0 (action, keyPress) = when input is CtrlC -> (Exit, None) - KeyPress Enter -> (TextConfirm, None) + KeyPress Enter -> (TextSubmit, None) KeyPress Delete -> if bufferLen == 0 then (GoBack, None) else (TextBackspace, None) KeyPress key -> (TextInput, KeyPress key) _ -> (None, None) @@ -549,9 +558,10 @@ getAndCreateDir = \dirPath -> createRocFile : Configuration, { packages : Dict Str RepositoryEntry, platforms : Dict Str RepositoryEntry } -> Task {} _ createRocFile = \config, repos -> appStub = getAppStub! config.platform - bytes = when config.type is - App -> buildRocApp config.platform config.packages repos appStub - Pkg -> buildRocPackage config.packages repos.packages + bytes = + when config.type is + App -> buildRocApp config.platform config.packages repos appStub + Pkg -> buildRocPackage config.packages repos.packages File.writeBytes "$(config.fileName).roc" bytes ## Build the raw byte representation of a roc file from the given platform, packageList, and repositories.