ansiform 0.7.9.0 → 0.7.10.3
raw patch · 14 files changed
+553/−21 lines, 14 filesnew-component:exe:ansiform-tutorialPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- ChangeLog.md +17/−0
- README.md +6/−1
- ansiform.cabal +9/−1
- docs/printers.md +2/−2
- docs/releasing.md +4/−4
- docs/roadmap.md +8/−0
- docs/table-format.md +2/−2
- docs/theme.md +2/−2
- docs/tutorial.md +271/−0
- examples/Demo.hs +1/−1
- examples/Tutorial.hs +177/−0
- src/Ansiform/Internal/Printer.hs +33/−5
- src/Ansiform/Theme.hs +1/−1
- test/Spec.hs +20/−2
ChangeLog.md view
@@ -4,6 +4,23 @@ ## Functional Versions +### 0.7.10.3 -- 2026-10-21+* BordersNone now renders a true borderless table with the same row and column layout as the bordered printers, without border characters, horizontal separators or column bands.+* Columns in the borderless mode are separated by two spaces.++### 0.7.10.2 -- 2026-10-21+* Changed defaultTheme.themeMuted from vivid black to italic white for readability on dark terminals.+* Kept defaultTheme.themeRule as bold vivid black.+* Condensed the tutorial form instruction into the footer and removed the extra body line.++### 0.7.10.1 -- 2026-10-21+* Changed the tutorial bookmark URL style from muted black to italic white for readability on dark terminals.+* Kept the tutorial rules on the theme rule style, which is black in the default theme.++### 0.7.10.0 -- 2026-10-21+* Added docs/tutorial.md, a step-by-step guide to a small bookmarks program.+* Added the ansiform-tutorial executable in examples/Tutorial.hs.+ ### 0.7.9.0 -- 2026-10-21 * Added readForm and readFormOn to Ansiform.Input, reading several page prompts with the cursor arithmetic handled by the library. * Added Prompt, promptAt and FormError as public types for page forms.
README.md view
@@ -10,7 +10,7 @@ > **Formerly known as Vis.** Ansiform is a terminal formatting library, unrelated to the JavaScript `vis-network` graph visualization library or to the Hackage package [`Vis`](https://hackage.haskell.org/package/Vis) (3D graphics). See [Naming](https://github.com/Tralen/ansiform/blob/master/docs/naming.md). -Current version: **0.7.9.0**.+Current version: **0.7.10.3**. Repository: <https://github.com/Tralen/ansiform>. @@ -20,6 +20,7 @@ - Immediate single-key input: a key press changes the screen without Enter. - Line input for prompts and numeric selection, including prompts printed inside a page and typed in place. - Forms that draw a page and read several prompts, with the cursor arithmetic handled by the library.+- A step-by-step tutorial and a runnable bookmarks example. - Unicode-aware display widths for alignment, wrapping, lists and tables. - Rendering to any `Handle` or to a `String` for embedding and golden tests. - Per-column table alignment, maximum widths and truncation with an ellipsis.@@ -81,12 +82,16 @@ cabal build cabal test cabal run ansiform-demo+cabal run ansiform-tutorial ``` `ansiform-demo` is an interactive gallery. Press the keys shown in the footer; the screen changes immediately, without a prompt or Enter. `B` returns to the menu, `R` redraws the current screen and `X` exits. +`ansiform-tutorial` is the small bookmarks program from the [tutorial](https://github.com/Tralen/ansiform/blob/master/docs/tutorial.md).+ ## Documentation +- [Tutorial](https://github.com/Tralen/ansiform/blob/master/docs/tutorial.md): build a small bookmarks program step by step. - [Architecture](https://github.com/Tralen/ansiform/blob/master/docs/architecture.md): module layout, invariants and the pure/IO boundary. - [Content](https://github.com/Tralen/ansiform/blob/master/docs/content.md): items, blocks, groups, tables, paragraphs and their builders. - [Styles](https://github.com/Tralen/ansiform/blob/master/docs/style.md): colors, formats and reusable text styles.
ansiform.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: ansiform-version: 0.7.9.0+version: 0.7.10.3 synopsis: Display formatting for command line interfaces. description: Basic format DSL for building an ANSI command line interface with colors, alignment, tables, paragraphs and more.@@ -31,6 +31,7 @@ docs/style.md docs/table-format.md docs/theme.md+ docs/tutorial.md common shared default-language: Haskell2010@@ -87,6 +88,13 @@ import: shared main-is: Demo.hs+ hs-source-dirs: examples+ build-depends: ansiform++executable ansiform-tutorial+ import: shared++ main-is: Tutorial.hs hs-source-dirs: examples build-depends: ansiform
docs/printers.md view
@@ -110,7 +110,7 @@ - `printerGroups` spaces the groups evenly. - `printerGroups'` gives each group the minimum width it needs. - The `Limited` variants stop after a number of rows; `0` means no limit.-- The `Bordered` variants draw a box around the groups. They do not split columns into bands and do not apply a background. Unequal groups are padded automatically with empty blocks, so every column reaches the bottom border. See [Table formatting](table-format.md).+- The `Bordered` variants draw a box around the groups. They do not split columns into bands and do not apply a background. Unequal groups are padded automatically with empty blocks, so every column reaches the bottom border. With `BordersNone` they keep the same aligned rows without border characters, without horizontal separators and with two spaces between columns. See [Table formatting](table-format.md). Groups that exceed the configured overflow ratio are split into extra rows by the layout engine. @@ -137,7 +137,7 @@ - `printerTable'` spaces the columns evenly. - The `Limited` variants stop after a number of rows; `0` means no limit. - The `Format` variants apply per-column alignment, maximum widths and truncation. See [Table formatting](table-format.md).-- The `Bordered` variants draw a box around the table with ASCII, light, heavy or double characters. They do not split columns into bands and do not apply the zebra background.+- The `Bordered` variants draw a box around the table with ASCII, light, heavy or double characters. They do not split columns into bands and do not apply the zebra background. With `BordersNone` they keep the aligned rows without border characters and without horizontal separators. - A `Nothing` table is treated as an empty group, so invalid input still produces a well-defined (empty) result instead of an exception. ## Paragraphs and articles
docs/releasing.md view
@@ -5,7 +5,7 @@ ## Current status - Package: `ansiform`. The name is already registered on Hackage.-- Version: 0.7.9.0.+- Version: 0.7.10.3. - License: MIT. - `cabal sdist` produces a complete source distribution with sources, docs, examples, tests, README, ChangeLog and LICENSE. - Haddock builds with 100% coverage for the public functions and types. The remaining warnings are only about module re-exports.@@ -13,9 +13,9 @@ - GitHub: public at <https://github.com/Tralen/ansiform>; the local remote is `git@github.com:Tralen/ansiform.git`. - `source-repository`, `homepage` and `bug-reports` are set to the GitHub URL. - The history has been rewritten and contains no personal paths.-- Tag `0.7.8.6` and its GitHub release are published. Tag `0.7.9.0` marks this release.-- Hackage: 0.7.8.6 is published at <https://hackage.haskell.org/package/ansiform>; the 0.7.9.0 release follows this checklist.-- The 0.7.9.0 release adds page forms, automatic padding for bordered groups and explicit non-negative arithmetic.+- Tag `0.7.9.0` and its GitHub release are published. Tag `0.7.10.3` marks the next release.+- Hackage: 0.7.9.0 is published at <https://hackage.haskell.org/package/ansiform-0.7.9.0>, with documentation uploaded; 0.7.10.3 follows this checklist.+- The 0.7.10.3 release adds the step-by-step tutorial, the bookmarks example, the readable muted theme and a true borderless table layout for `BordersNone`. ## Credentials
docs/roadmap.md view
@@ -26,6 +26,14 @@ ## Implemented +### Borderless bordered layout++Released in 0.7.10.3. Passing `BordersNone` to the bordered table and group printers keeps every column on one row without border characters, horizontal separators or column bands, instead of falling back to the column-band layout. Columns are separated by two spaces. See [Table formatting](table-format.md).++### Tutorial++Released in 0.7.10.0. `docs/tutorial.md` builds a small bookmarks program step by step, and `examples/Tutorial.hs` is compiled by `cabal build all`. See [Tutorial](tutorial.md).+ ### Page forms Released in 0.7.9.0. `Ansiform.Input` provides `Prompt`, `promptAt`, `readForm` and `readFormOn`, so a screen can draw a page and read several prompts without counting the lines below each one. Prompt positions are section-relative, and errors are reported as `FormError` values. See [Input](input.md).
docs/table-format.md view
@@ -66,7 +66,7 @@ data Borders = BordersNone | BordersAscii | BordersLight | BordersHeavy | BordersDouble ``` -`BordersNone` is the default when no border-specific printer is used. The other constructors select ASCII, light, heavy or double box-drawing characters.+`BordersNone` is the default when no border-specific printer is used. The other constructors select ASCII, light, heavy or double box-drawing characters. When `BordersNone` is passed to a bordered printer, the result keeps the same row and column layout without border characters and without horizontal separators; columns are separated by two spaces. ```haskell printerTableBordered :: Borders -> TableFormat -> Offset -> Padding -> Maybe Table -> Printer@@ -87,7 +87,7 @@ printerGroupsBorderedLimited' :: Borders -> Offset -> Padding -> Number -> [Group] -> Printer ``` -Bordered tables and groups do not split into column bands; they overflow when they are wider than the terminal. They also do not apply the zebra background, leaving the border and cell styles explicit. Unequal groups passed to the bordered group printers are padded with empty blocks to the height of the tallest group, so the border stays rectangular.+Bordered tables and groups do not split into column bands; they overflow when they are wider than the terminal. They also do not apply the zebra background, leaving the border and cell styles explicit. Unequal groups passed to the bordered group printers are padded with empty blocks to the height of the tallest group, so the border stays rectangular. With `BordersNone` the layout is the same, but no border characters or horizontal separators are printed. ## Truncation
docs/theme.md view
@@ -28,8 +28,8 @@ | `themeFooter` | Footer messages. The default theme uses italic white. | | `themeAccent` | Highlighted keys, links and selected values. | | `themeDanger` | Exit and destructive actions. |-| `themeMuted` | Secondary and disabled text. |-| `themeRule` | Horizontal rules. |+| `themeMuted` | Secondary and disabled text. The default theme uses italic white. |+| `themeRule` | Horizontal rules. The default theme uses bold vivid black. | | `themeBar` | Solid bar background. | | `themeTableHeader` | Table header text. | | `themeTableBody` | Default table body text. |
+ docs/tutorial.md view
@@ -0,0 +1,271 @@+# Tutorial: a small bookmarks screen++This tutorial builds **Pocket Bookmarks**, a small but complete program. It keeps a list of bookmarks in memory, moves the selection with the keyboard and reads new entries through a form printed inside the page.++The complete program is in `examples/Tutorial.hs` and is compiled by `cabal build all`. Run it with:++```sh+cabal run ansiform-tutorial+```++The keys are `A` to add, `J` and `K` to move the selection, `D` to delete, and `X` to exit.++## 1. Set up the project++Add `ansiform` to the dependencies of an executable. A minimal Cabal stanza looks like this:++```cabal+executable bookmarks+ main-is: Main.hs+ build-depends: base, ansiform+```++The program has two layers. The application model is a pure value, and the drawing layer writes pages to the terminal. Drawing functions return a `State` that records the page on screen and how many lines each section printed, so the next page can replace the previous one in place.++## 2. Describe the data++Start with the values the program needs. A bookmark has a title and a URL; the model keeps the list and the selected index:++```haskell+data Bookmark = Bookmark+ { bookmarkTitle :: String+ , bookmarkURL :: String+ } deriving (Eq, Show)++data Model = Model+ { modelBookmarks :: [Bookmark]+ , modelSelected :: Int+ } deriving (Eq, Show)++emptyModel :: Model+emptyModel = Model [] 0+```++Updates are pure functions that return a new `Model`. Nothing is mutated:++```haskell+addBookmark :: String -> String -> Model -> Model+addBookmark title url (Model bookmarks _) =+ Model (bookmarks ++ [Bookmark title url]) (length bookmarks)++deleteSelected :: Model -> Model+deleteSelected (Model [] _) = emptyModel+deleteSelected (Model bookmarks selected) =+ Model kept (clampSelection (length kept) selected)+ where+ kept = take selected bookmarks ++ drop (selected + 1) bookmarks++moveSelection :: Int -> Model -> Model+moveSelection delta (Model bookmarks selected) =+ Model bookmarks (clampSelection (length bookmarks) (selected + delta))++clampSelection :: Int -> Int -> Int+clampSelection count index+ | count <= 0 = 0+ | otherwise = max 0 (min (count - 1) index)+```++`Model` is the application state. It is separate from the `State` type in `Ansiform.Draw`, which tracks the terminal page.++## 3. Assemble the shared components++The header, footer and rules are used by both pages, so they live in one place. This keeps spacing, colors and wording consistent:++```haskell+theme :: Theme+theme = defaultTheme++-- The URL stays readable on dark terminals, while rules keep the theme color.+urlStyle :: Style+urlStyle = italic white++bookmarkSettings :: Settings+bookmarkSettings = settings [minSpace 2]++header :: Section+header = sectionTop 1+ [ printerSolidLineCenter 0 1 (themeBar theme)+ [ blockSimple "Pocket Bookmarks" (themeHeader theme) ]+ , printerEmpty 1+ , printerLineCenter 4+ [ blockSimple "A small Ansiform tutorial" (themeSubtitle theme) ]+ ]++bottom :: Section+bottom = sectionRule (ruleDefault (themeRule theme))++ruleBody, ruleFooter :: Rule+ruleBody = rule '%' (themeRule theme)+ruleFooter = rule ':' (themeRule theme)+```++`sectionTop 1` prints the header and then one empty line. `printerSolidLineCenter` fills a whole line with the theme bar color and centers the title on top. A `Theme` is a record of `Style` values, so replacing `defaultTheme` changes every component at once.++The URL uses `italic white` so it stays readable on dark terminals. The rules keep `themeRule`, which is black in the default theme; change `urlStyle` or `themeRule` independently if your terminal has a light background.++## 4. Draw the list++A `Page` is a list of sections. The list page has a header, a body, a footer and a bottom rule:++```haskell+listPage :: Model -> Page+listPage model = page [header, listBody model, listFooter, bottom]++listBody :: Model -> Section+listBody model = sectionBodyRule 1 ruleBody+ [ if null (modelBookmarks model)+ then printerLeft 2+ (blockSimple "No bookmarks yet. Press A to add one." (themeMuted theme))+ else printerColumn 2+ [ bookmarkBlock (index == modelSelected model) index bookmark+ | (index, bookmark) <- zip [0 ..] (modelBookmarks model) ]+ ]++bookmarkBlock :: Bool -> Int -> Bookmark -> Block+bookmarkBlock isSelected index (Bookmark title url) =+ blockCycle+ [ (if isSelected then "> " else " ", style)+ , (show (index + 1) ++ ". ", style)+ , (title, style)+ , (" " ++ url, urlStyle)+ ]+ where+ style = if isSelected then themeAccent theme else styleDefault+```++`sectionBodyRule 1` prints a rule, one empty line, the body and one empty line after it. `printerColumn` prints one block per line. The empty list gets a helpful message instead of an empty screen.++`blockCycle` builds a `Block` from text fragments with their styles. The selected row changes the marker and the style, so the selection is visible without a separate cursor data type.++The footer is a single line of menu entries:++```haskell+listFooter :: Section+listFooter = sectionBodyRule 1 ruleFooter+ [ printerLineCenter 4+ [ blockMenuStyle "A" "Add" (themeAccent theme)+ , blockMenuStyle "J" "Down" (themeAccent theme)+ , blockMenuStyle "K" "Up" (themeAccent theme)+ , blockMenuStyle "D" "Delete" (themeDanger theme)+ , blockMenuStyle "X" "Exit" (themeDanger theme) ]+ ]+```++`printerLineCenter` spaces the entries across the line and keeps at least the configured minimum space between them.++## 5. Build the form++The add flow needs two values. Its page reuses the same header and bottom, and adds a body with two labeled lines:++```haskell+formPage :: Page+formPage = page [header, formBody, formFooter, bottom]++formBody :: Section+formBody = sectionBodyRule 1 ruleBody+ [ printerLeft 2 (blockSimple "Title: " styleDefault)+ , printerEmpty 1+ , printerLeft 2 (blockSimple "URL: " styleDefault)+ ]++formFooter :: Section+formFooter = sectionBodyRule 1 ruleFooter+ [ printerLineCenter 4+ [ blockSimple+ "Type a value and press Enter for each field; empty fields cancel."+ (themeFooter theme) ]+ ]+```++Now describe where the input goes. A `Prompt` is placed by **section index** and by **row inside that section**, which is easier to keep correct than counting all the lines below it:++```haskell+fieldColumn :: String -> Offset+fieldColumn label = formNumber (2 + textWidth label)++formPrompts :: [Prompt]+formPrompts =+ [ promptAt 1 2 (fieldColumn "Title: ")+ , promptAt 1 4 (fieldColumn "URL: ") ]+```++The form body is section `1`. `sectionBodyRule 1` prints a rule on row `0` and an empty line on row `1`, so the title is row `2` and the URL is row `4`. The footer carries the instruction, so the body only reserves the two editable lines. The column is the label offset plus the display width of the label. `textWidth` understands wide characters and combining marks, so the cursor stays aligned even with Unicode labels.++`readForm` draws the page, places the cursor on each prompt in order and returns the typed values:++```haskell+readForm :: Page -> [Prompt] -> State -> IO (State, Either FormError [String])+```++The result is an ADT. `Right values` contains one string per prompt, `Left (PromptOutsidePage prompt)` means the prompt does not fit the drawn page, and `Left FormInputEnded` means stdin ended. Empty field values are still valid strings; this program decides to cancel the add when either value is empty.++Line input is enabled once for the whole form. Do not wrap `readForm` in `withLineInput`.++## 6. Run the interaction loop++The program starts in immediate input mode, draws the first page and reads one key at a time:++```haskell+main :: IO ()+main = withImmediateInput $ do+ state <- start (listPage emptyModel) bookmarkSettings+ loop emptyModel state++loop :: Model -> State -> IO ()+loop model state = do+ key <- readKey+ case fmap toLower key of+ Nothing -> pure ()+ Just 'a' -> addFlow model state+ Just 'j' -> open (moveSelection 1 model) state+ Just 'k' -> open (moveSelection (-1) model) state+ Just 'd' -> open (deleteSelected model) state+ Just 'x' -> pure ()+ _ -> loop model state+ where+ open next st = draw (listPage next) st >>= loop next+```++Each action returns a new `Model`, draws the next page and continues the loop. The next `readKey` needs no Enter.++The add flow combines the form with the model update:++```haskell+addFlow :: Model -> State -> IO ()+addFlow model state = do+ (state', result) <- readForm formPage formPrompts state+ let next = case result of+ Right [title, url]+ | not (null title) && not (null url) ->+ addBookmark title url model+ _ -> model+ draw (listPage next) state' >>= loop next+```++`readForm` restores the immediate input settings when it returns, so the list keeps responding to single keys. If the form is cancelled or stdin ends, the model is unchanged and the list is drawn again.++## 7. Try it++Run the program:++```sh+cabal run ansiform-tutorial+```++Then:++1. Press `A` to open the form.+2. Type a title and press Enter.+3. Type a URL and press Enter.+4. Use `J` and `K` to move the selection.+5. Press `D` to delete the selected bookmark, or `X` to exit.++The program has no persistence and no error dialogs; it is intentionally small. The important part is the boundary: pure builders describe the screen, the drawing layer prints it, and the loop decides what to draw next.++## 8. Where to go next++- Add persistence by reading and writing a file outside the pure model functions. Keep the builders pure.+- Replace `printerColumn` with `printerTable` or `printerSequenceSel` to experiment with tables and highlighted paragraphs. See [Content](content.md) and [Printers](printers.md).+- Use `renderPage` or `renderPrinter` from [Render](render.md) to capture output in a `String` for golden tests.+- Read [Input](input.md) for the full input API and [Numbers](number.md) for checked arithmetic.+- Copy `examples/Tutorial.hs` into your own project and change the model. The example is compiled with the library, so it stays aligned with the documented API.
examples/Demo.hs view
@@ -16,7 +16,7 @@ -- Version of this demo, following the package version. demoVersion :: String-demoVersion = "0.7.9.0"+demoVersion = "0.7.10.3" -- The philosophers shown in the Groups screen. data Philosopher
+ examples/Tutorial.hs view
@@ -0,0 +1,177 @@+-- | Small bookmarks application used by the tutorial.+--+-- Run with:+--+-- > cabal run ansiform-tutorial+module Main where++import Data.Char (toLower)+import Ansiform++--------------------------------------------------------------------------------+-- Data model+--------------------------------------------------------------------------------++data Bookmark = Bookmark+ { bookmarkTitle :: String+ , bookmarkURL :: String+ } deriving (Eq, Show)++data Model = Model+ { modelBookmarks :: [Bookmark]+ , modelSelected :: Int+ } deriving (Eq, Show)++emptyModel :: Model+emptyModel = Model [] 0++addBookmark :: String -> String -> Model -> Model+addBookmark title url (Model bookmarks _) =+ Model (bookmarks ++ [Bookmark title url]) (length bookmarks)++deleteSelected :: Model -> Model+deleteSelected (Model [] _) = emptyModel+deleteSelected (Model bookmarks selected) =+ Model kept (clampSelection (length kept) selected)+ where+ kept = take selected bookmarks ++ drop (selected + 1) bookmarks++moveSelection :: Int -> Model -> Model+moveSelection delta (Model bookmarks selected) =+ Model bookmarks (clampSelection (length bookmarks) (selected + delta))++clampSelection :: Int -> Int -> Int+clampSelection count index+ | count <= 0 = 0+ | otherwise = max 0 (min (count - 1) index)++--------------------------------------------------------------------------------+-- Shared components+--------------------------------------------------------------------------------++theme :: Theme+theme = defaultTheme++-- The URL stays readable on dark terminals, while rules keep the theme color.+urlStyle :: Style+urlStyle = italic white++bookmarkSettings :: Settings+bookmarkSettings = settings [minSpace 2]++header :: Section+header = sectionTop 1+ [ printerSolidLineCenter 0 1 (themeBar theme)+ [ blockSimple "Pocket Bookmarks" (themeHeader theme) ]+ , printerEmpty 1+ , printerLineCenter 4+ [ blockSimple "A small Ansiform tutorial" (themeSubtitle theme) ]+ ]++bottom :: Section+bottom = sectionRule (ruleDefault (themeRule theme))++ruleBody, ruleFooter :: Rule+ruleBody = rule '%' (themeRule theme)+ruleFooter = rule ':' (themeRule theme)++--------------------------------------------------------------------------------+-- List page+--------------------------------------------------------------------------------++listPage :: Model -> Page+listPage model = page [header, listBody model, listFooter, bottom]++listBody :: Model -> Section+listBody model = sectionBodyRule 1 ruleBody+ [ if null (modelBookmarks model)+ then printerLeft 2+ (blockSimple "No bookmarks yet. Press A to add one." (themeMuted theme))+ else printerColumn 2+ [ bookmarkBlock (index == modelSelected model) index bookmark+ | (index, bookmark) <- zip [0 ..] (modelBookmarks model) ]+ ]++bookmarkBlock :: Bool -> Int -> Bookmark -> Block+bookmarkBlock isSelected index (Bookmark title url) =+ blockCycle+ [ (if isSelected then "> " else " ", style)+ , (show (index + 1) ++ ". ", style)+ , (title, style)+ , (" " ++ url, urlStyle)+ ]+ where+ style = if isSelected then themeAccent theme else styleDefault++listFooter :: Section+listFooter = sectionBodyRule 1 ruleFooter+ [ printerLineCenter 4+ [ blockMenuStyle "A" "Add" (themeAccent theme)+ , blockMenuStyle "J" "Down" (themeAccent theme)+ , blockMenuStyle "K" "Up" (themeAccent theme)+ , blockMenuStyle "D" "Delete" (themeDanger theme)+ , blockMenuStyle "X" "Exit" (themeDanger theme) ]+ ]++--------------------------------------------------------------------------------+-- Form page+--------------------------------------------------------------------------------++formPage :: Page+formPage = page [header, formBody, formFooter, bottom]++formBody :: Section+formBody = sectionBodyRule 1 ruleBody+ [ printerLeft 2 (blockSimple "Title: " styleDefault)+ , printerEmpty 1+ , printerLeft 2 (blockSimple "URL: " styleDefault)+ ]++formFooter :: Section+formFooter = sectionBodyRule 1 ruleFooter+ [ printerLineCenter 4+ [ blockSimple+ "Type a value and press Enter for each field; empty fields cancel."+ (themeFooter theme) ]+ ]++fieldColumn :: String -> Offset+fieldColumn label = formNumber (2 + textWidth label)++formPrompts :: [Prompt]+formPrompts =+ [ promptAt 1 2 (fieldColumn "Title: ")+ , promptAt 1 4 (fieldColumn "URL: ") ]++--------------------------------------------------------------------------------+-- Application+--------------------------------------------------------------------------------++main :: IO ()+main = withImmediateInput $ do+ state <- start (listPage emptyModel) bookmarkSettings+ loop emptyModel state++loop :: Model -> State -> IO ()+loop model state = do+ key <- readKey+ case fmap toLower key of+ Nothing -> pure ()+ Just 'a' -> addFlow model state+ Just 'j' -> open (moveSelection 1 model) state+ Just 'k' -> open (moveSelection (-1) model) state+ Just 'd' -> open (deleteSelected model) state+ Just 'x' -> pure ()+ _ -> loop model state+ where+ open next st = draw (listPage next) st >>= loop next++addFlow :: Model -> State -> IO ()+addFlow model state = do+ (state', result) <- readForm formPage formPrompts state+ let next = case result of+ Right [title, url]+ | not (null title) && not (null url) ->+ addBookmark title url model+ _ -> model+ draw (listPage next) state' >>= loop next
src/Ansiform/Internal/Printer.hs view
@@ -373,12 +373,14 @@ -- The table does not split into column bands; it overflows when it does not fit. -- Groups with fewer blocks than the tallest group are padded with empty -- blocks, so every column reaches the bottom border.+-- 'BordersNone' keeps the same row layout without border characters and+-- without horizontal separators. printBorderedGroups :: Borders -> TableFormat -> Offset -> Padding -> Number -> Bool -> [Group] -> Output -> IO Number printBorderedGroups brd fmt o p n bSpace gs out | null gs = return 0 | otherwise = case borderChars brd of- Nothing -> printGroups fmt o p n bSpace False gs out+ Nothing -> printBorderless Just bc -> printBordered bc where h = outputHandle out@@ -418,10 +420,7 @@ where printCells _ [] = return () printCells i ((w,cell):rest) = do- let spec = columnSpecAt fmt i- aligned = formatBlock spec (formNumber w) cell- padded = padBlock (formNumber (w + 2 * p')) AlignCenter aligned- printBlock out padded+ printBlock out (formatCell fmt i w (w + 2 * p') cell) resetStyle h (outputColors out) if null rest then hPutStr h [borderRight bc]@@ -434,6 +433,35 @@ hPutStr h (borderLine first middle lastChar cellWidths (borderHorizontal bc)) hPutStr h "\n" return 1++ printBorderless = printRows+ where+ printRows = go limitedRows+ where+ go [] = return 0+ go (row:rest) = do+ printRow row+ restCount <- go rest+ return (1 + restCount)++ printRow row = do+ indR h (unn o)+ resetStyle h (outputColors out)+ printCells 0 (zip contentWidths row)+ hPutStr h "\n"+ where+ printCells _ [] = return ()+ printCells i ((w,cell):rest) = do+ printBlock out (formatCell fmt i w (w + 2 * p') cell)+ resetStyle h (outputColors out)+ if null rest then return () else hPutStr h " "+ printCells (i + 1) rest++-- Formats a cell to its content width and then centers it in the padded width.+formatCell :: TableFormat -> Int -> Int -> Int -> Block -> Block+formatCell fmt i contentWidth paddedWidth cell =+ padBlock (formNumber paddedWidth) AlignCenter+ (formatBlock (columnSpecAt fmt i) (formNumber contentWidth) cell) -- Pads every group with empty blocks so all groups have the same height. padGroups :: [Group] -> [Group]
src/Ansiform/Theme.hs view
@@ -17,7 +17,7 @@ , themeFooter = italic white , themeAccent = bold cyan , themeDanger = bold red'- , themeMuted = normal black'+ , themeMuted = italic white , themeRule = bold black' , themeBar = blue , themeTableHeader = bold yellow
test/Spec.hs view
@@ -56,10 +56,11 @@ , test "Composite block builder keeps its parts" (let out = sampleShow 0 (blockEnum "01" "Item" blue) in "01" `isInfixOf` out && "Item" `isInfixOf` out)- , test "Default theme provides accent and danger styles"+ , test "Default theme provides readable accent and muted styles" (themeAccent defaultTheme == bold cyan && themeDanger defaultTheme == bold red'- && themeFooter defaultTheme == italic white)+ && themeFooter defaultTheme == italic white+ && themeMuted defaultTheme == italic white) , test "Theme record update changes a style" (themeAccent (defaultTheme { themeAccent = bold magenta }) == bold magenta) , test "Page amendment replaces the selected section"@@ -137,6 +138,23 @@ [] -> False (firstLine:_) -> all ((== length firstLine) . length) outputLines return $ length outputLines == 7 && allSameWidth+ , testIO "BordersNone keeps tall tables in one borderless block" $ do+ let long = replicate 10 'A'+ tall = groupSimple (blockSimple long styleDefault)+ [ blockSimple "1" styleDefault+ , blockSimple "2" styleDefault ]+ short = groupSimple (blockSimple "B" styleDefault) []+ out <- renderPrinter+ (printerGroupsBordered BordersNone 0 0 [tall, short])+ (settings [width 15, neverColors])+ let outputLines = lines out+ allSameWidth = case outputLines of+ [] -> False+ (firstLine:_) -> all ((== length firstLine) . length) outputLines+ return $ length outputLines == 3+ && allSameWidth+ && (long ++ " B") `isInfixOf` out+ && not (any (`elem` "+|") out) , testIO "Form rejects a prompt outside the page" $ do let p = page [section [printerLeft 0 (blockSimple "one" styleDefault)]] prompt = promptAt 9 0 0