packages feed

layoutz (empty) → 0.1.0.0

raw patch · 7 files changed

+1336/−0 lines, 7 filesdep +basedep +layoutzdep +tastybinary-added

Dependencies added: base, layoutz, tasty, tasty-hunit

Files

+ LICENSE view
@@ -0,0 +1,201 @@+                                 Apache License+                           Version 2.0, January 2004+                        http://www.apache.org/licenses/++   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION++   1. Definitions.++      "License" shall mean the terms and conditions for use, reproduction,+      and distribution as defined by Sections 1 through 9 of this document.++      "Licensor" shall mean the copyright owner or entity authorized by+      the copyright owner that is granting the License.++      "Legal Entity" shall mean the union of the acting entity and all+      other entities that control, are controlled by, or are under common+      control with that entity. For the purposes of this definition,+      "control" means (i) the power, direct or indirect, to cause the+      direction or management of such entity, whether by contract or+      otherwise, or (ii) ownership of fifty percent (50%) or more of the+      outstanding shares, or (iii) beneficial ownership of such entity.++      "You" (or "Your") shall mean an individual or Legal Entity+      exercising permissions granted by this License.++      "Source" form shall mean the preferred form for making modifications,+      including but not limited to software source code, documentation+      source, and configuration files.++      "Object" form shall mean any form resulting from mechanical+      transformation or translation of a Source form, including but+      not limited to compiled object code, generated documentation,+      and conversions to other media types.++      "Work" shall mean the work of authorship, whether in Source or+      Object form, made available under the License, as indicated by a+      copyright notice that is included in or attached to the work+      (an example is provided in the Appendix below).++      "Derivative Works" shall mean any work, whether in Source or Object+      form, that is based on (or derived from) the Work and for which the+      editorial revisions, annotations, elaborations, or other modifications+      represent, as a whole, an original work of authorship. For the purposes+      of this License, Derivative Works shall not include works that remain+      separable from, or merely link (or bind by name) to the interfaces of,+      the Work and Derivative Works thereof.++      "Contribution" shall mean any work of authorship, including+      the original version of the Work and any modifications or additions+      to that Work or Derivative Works thereof, that is intentionally+      submitted to Licensor for inclusion in the Work by the copyright owner+      or by an individual or Legal Entity authorized to submit on behalf of+      the copyright owner. For the purposes of this definition, "submitted"+      means any form of electronic, verbal, or written communication sent+      to the Licensor or its representatives, including but not limited to+      communication on electronic mailing lists, source code control systems,+      and issue tracking systems that are managed by, or on behalf of, the+      Licensor for the purpose of discussing and improving the Work, but+      excluding communication that is conspicuously marked or otherwise+      designated in writing by the copyright owner as "Not a Contribution."++      "Contributor" shall mean Licensor and any individual or Legal Entity+      on behalf of whom a Contribution has been received by Licensor and+      subsequently incorporated within the Work.++   2. Grant of Copyright License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      copyright license to reproduce, prepare Derivative Works of,+      publicly display, publicly perform, sublicense, and distribute the+      Work and such Derivative Works in Source or Object form.++   3. Grant of Patent License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      (except as stated in this section) patent license to make, have made,+      use, offer to sell, sell, import, and otherwise transfer the Work,+      where such license applies only to those patent claims licensable+      by such Contributor that are necessarily infringed by their+      Contribution(s) alone or by combination of their Contribution(s)+      with the Work to which such Contribution(s) was submitted. If You+      institute patent litigation against any entity (including a+      cross-claim or counterclaim in a lawsuit) alleging that the Work+      or a Contribution incorporated within the Work constitutes direct+      or contributory patent infringement, then any patent licenses+      granted to You under this License for that Work shall terminate+      as of the date such litigation is filed.++   4. Redistribution. You may reproduce and distribute copies of the+      Work or Derivative Works thereof in any medium, with or without+      modifications, and in Source or Object form, provided that You+      meet the following conditions:++      (a) You must give any other recipients of the Work or+          Derivative Works a copy of this License; and++      (b) You must cause any modified files to carry prominent notices+          stating that You changed the files; and++      (c) You must retain, in the Source form of any Derivative Works+          that You distribute, all copyright, patent, trademark, and+          attribution notices from the Source form of the Work,+          excluding those notices that do not pertain to any part of+          the Derivative Works; and++      (d) If the Work includes a "NOTICE" text file as part of its+          distribution, then any Derivative Works that You distribute must+          include a readable copy of the attribution notices contained+          within such NOTICE file, excluding those notices that do not+          pertain to any part of the Derivative Works, in at least one+          of the following places: within a NOTICE text file distributed+          as part of the Derivative Works; within the Source form or+          documentation, if provided along with the Derivative Works; or,+          within a display generated by the Derivative Works, if and+          wherever such third-party notices normally appear. The contents+          of the NOTICE file are for informational purposes only and+          do not modify the License. You may add Your own attribution+          notices within Derivative Works that You distribute, alongside+          or as an addendum to the NOTICE text from the Work, provided+          that such additional attribution notices cannot be construed+          as modifying the License.++      You may add Your own copyright statement to Your modifications and+      may provide additional or different license terms and conditions+      for use, reproduction, or distribution of Your modifications, or+      for any such Derivative Works as a whole, provided Your use,+      reproduction, and distribution of the Work otherwise complies with+      the conditions stated in this License.++   5. Submission of Contributions. Unless You explicitly state otherwise,+      any Contribution intentionally submitted for inclusion in the Work+      by You to the Licensor shall be under the terms and conditions of+      this License, without any additional terms or conditions.+      Notwithstanding the above, nothing herein shall supersede or modify+      the terms of any separate license agreement you may have executed+      with Licensor regarding such Contributions.++   6. Trademarks. This License does not grant permission to use the trade+      names, trademarks, service marks, or product names of the Licensor,+      except as required for reasonable and customary use in describing the+      origin of the Work and reproducing the content of the NOTICE file.++   7. Disclaimer of Warranty. Unless required by applicable law or+      agreed to in writing, Licensor provides the Work (and each+      Contributor provides its Contributions) on an "AS IS" BASIS,+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or+      implied, including, without limitation, any warranties or conditions+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A+      PARTICULAR PURPOSE. You are solely responsible for determining the+      appropriateness of using or redistributing the Work and assume any+      risks associated with Your exercise of permissions under this License.++   8. Limitation of Liability. In no event and under no legal theory,+      whether in tort (including negligence), contract, or otherwise,+      unless required by applicable law (such as deliberate and grossly+      negligent acts) or agreed to in writing, shall any Contributor be+      liable to You for damages, including any direct, indirect, special,+      incidental, or consequential damages of any character arising as a+      result of this License or out of the use or inability to use the+      Work (including but not limited to damages for loss of goodwill,+      work stoppage, computer failure or malfunction, or any and all+      other commercial damages or losses), even if such Contributor+      has been advised of the possibility of such damages.++   9. Accepting Warranty or Additional Liability. While redistributing+      the Work or Derivative Works thereof, You may choose to offer,+      and charge a fee for, acceptance of support, warranty, indemnity,+      or other liability obligations and/or rights consistent with this+      License. However, in accepting such obligations, You may act only+      on Your own behalf and on Your sole responsibility, not on behalf+      of any other Contributor, and only if You agree to indemnify,+      defend, and hold each Contributor harmless for any liability+      incurred by, or claims asserted against, such Contributor by reason+      of your accepting any such warranty or additional liability.++   END OF TERMS AND CONDITIONS++   APPENDIX: How to apply the Apache License to your work.++      To apply the Apache License to your work, attach the following+      boilerplate notice, with the fields enclosed by brackets "[]"+      replaced with your own identifying information. (Don't include+      the brackets!)  The text should be enclosed in the appropriate+      comment syntax for the file format. We also recommend that a+      file or class name and description of purpose be included on the+      same "printed page" as the copyright notice for easier+      identification within third-party archives.++   Copyright [yyyy] [name of copyright owner]++   Licensed under the Apache License, Version 2.0 (the "License");+   you may not use this file except in compliance with the License.+   You may obtain a copy of the License at++       http://www.apache.org/licenses/LICENSE-2.0++   Unless required by applicable law or agreed to in writing, software+   distributed under the License is distributed on an "AS IS" BASIS,+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+   See the License for the specific language governing permissions and+   limitations under the License.
+ Layoutz.hs view
@@ -0,0 +1,560 @@+{-# LANGUAGE OverloadedStrings, ExistentialQuantification, FlexibleInstances #-}++{- | +Module      : Layoutz+Description : Friendly, expressive print-layout DSL for Haskell+Copyright   : (c) 2025 Matthieu Court+License     : Apache-2.0++A simple Haskell port of the layoutz library for creating structured terminal layouts.+-}++module Layoutz+  ( -- * Core Types+    Element(..)+  , Border(..)+  , L+  , Tree(..)+    -- * Basic Elements+  , layout+  , text+  , br+    -- * Layout Functions  +  , center, center'+  , row+  , underline, underline'+    -- * Containers+  , box, box'+  , statusCard, statusCard'+    -- * Widgets+  , ul+  , inlineBar+  , table, table'+  , section, section', section''+  , kv+  , tree, leaf, branch+    -- * Visual Elements+  , margin, marginError, marginWarn, marginSuccess, marginInfo+  , hr, hr', hr''+  , pad+  , chart+    -- * Rendering+  , render+  ) where++import Data.List (intercalate, transpose)+import Text.Printf (printf)++-- Core Element typeclass+class Element a where+  renderElement :: a -> String+  +  -- Calculate element width (longest line)+  width :: a -> Int+  width element = +    let rendered = renderElement element+        renderedLines = lines rendered+    in if null renderedLines then 0+       else maximum $ 0 : map length renderedLines+  +  -- Calculate element height (number of lines)+  height :: a -> Int+  height element =+    let rendered = renderElement element+    in if null rendered then 1+       else length (lines rendered)++render :: Element a => a -> String+render = renderElement++-- | L is the universal layout element type - a type-erased wrapper for the DSL.+-- +-- This allows mixing different element types in layouts while providing a common interface.+-- Uses existential quantification to store any Element type inside L.+--+-- Constructors:+--   * L a          - Wraps any Element (Text, Box, Table, etc.)+--   * UL [L]       - Special case for unordered lists (allows nesting)  +--   * AutoCenter L - Smart centering that adapts to layout context width+--+-- Example usage:+--   layout [text "title", box "content" [...], center (text "footer")]+--   All different types unified as L, so they can be composed together.+data L = forall a. Element a => L a | UL [L] | AutoCenter L++instance Element L where+  renderElement (L x) = render x+  renderElement (UL items) = render (UnorderedList items)+  renderElement (AutoCenter element) = render element  -- Will be handled by Layout+  +  width (L x) = width x+  width (UL items) = width (UnorderedList items)+  width (AutoCenter element) = width element+  +  height (L x) = height x  +  height (UL items) = height (UnorderedList items)+  height (AutoCenter element) = height element++instance Show L where+  show = render++-- Border styles+data Border = NormalBorder | DoubleBorder | ThickBorder | RoundBorder+  deriving (Show, Eq)++borderChars :: Border -> (String, String, String, String, String, String, String, String, String)+borderChars NormalBorder = ("┌", "┐", "└", "┘", "─", "│", "├", "┤", "┼")+borderChars DoubleBorder = ("╔", "╗", "╚", "╝", "═", "║", "╠", "╣", "╬") +borderChars ThickBorder  = ("┏", "┓", "┗", "┛", "━", "┃", "┣", "┫", "╋")+borderChars RoundBorder  = ("╭", "╮", "╰", "╯", "─", "│", "├", "┤", "┼")++-- Elements+newtype Text = Text String+instance Element Text where renderElement (Text s) = s++newtype LineBreak = LineBreak ()  +instance Element LineBreak where renderElement _ = ""++data Layout = Layout [L]+instance Element Layout where+  renderElement (Layout elements) = +    let -- Calculate max width of all non-AutoCenter elements+        nonAutoCenterElements = [e | e <- elements, not (isAutoCenter e)]+        maxWidth = if null nonAutoCenterElements +                  then 80  -- fallback+                  else maximum (0 : map width nonAutoCenterElements)+        +        -- Render elements, providing context width to AutoCenter elements+        renderedElements = map (renderWithContext maxWidth) elements+    in intercalate "\n" renderedElements+    where+      isAutoCenter (AutoCenter _) = True+      isAutoCenter _ = False+      +      renderWithContext contextWidth (AutoCenter element) = +        render (Centered (render element) contextWidth)+      renderWithContext _ element = render element++-- | Centered element with custom width+data Centered = Centered String Int  -- content, target_width+instance Element Centered where+  renderElement (Centered content targetWidth) = +    let contentLines = lines content+    in intercalate "\n" $ map centerLine contentLines+    where+      centerLine line = +        let lineLength = length line+        in if lineLength >= targetWidth+           then line+           else let totalPadding = targetWidth - lineLength+                    leftPadding = totalPadding `div` 2+                    rightPadding = totalPadding - leftPadding+                in replicate leftPadding ' ' ++ line ++ replicate rightPadding ' '++-- | Underlined element with custom character+data Underlined = Underlined String String  -- content, underline_char+instance Element Underlined where+  renderElement (Underlined content underlineChar) = +    let contentLines = lines content+        maxWidth = if null contentLines then 0 +                   else maximum (map length contentLines)+        underlinePattern = underlineChar+        underline = if length underlinePattern >= maxWidth+                   then take maxWidth underlinePattern+                   else let repeats = maxWidth `div` length underlinePattern+                            remainder = maxWidth `mod` length underlinePattern+                        in concat (replicate repeats underlinePattern) ++ take remainder underlinePattern+    in content ++ "\n" ++ underline++data Row = Row [L]+instance Element Row where  +  renderElement (Row elements) = +    if null elements then ""+    else let elementStrings = map render elements+             elementLines = map lines elementStrings+             maxHeight = maximum (map length elementLines)+             elementWidths = map (maximum . map length) elementLines+            -- Pad each element to maxHeight and proper width, then align at top+             paddedElements = zipWith (padElementToSize maxHeight) elementWidths elementLines+             -- Transpose to get rows, then join each row with spaces+         in intercalate "\n" $ map (intercalate " ") (transpose paddedElements)+    where+      padElementToSize maxH width linesList = +        let currentLines = linesList ++ replicate (maxH - length linesList) ""+            paddedLines = map (\line -> line ++ replicate (max 0 (width - length line)) ' ') currentLines+        in paddedLines++data Box = Box String [L] Border+instance Element Box where+  renderElement (Box title elements border) =+    let elementStrings = map render elements+        content = intercalate "\n" elementStrings+        contentLines = if null content then [""] else lines content  +        contentWidth = if null contentLines then 0 else maximum (map length contentLines)+        titleWidth = if null title then 0 else length title + 2+        innerWidth = max contentWidth titleWidth+        totalWidth = innerWidth + 4+        (topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, _, _, _) = borderChars border+        +        topBorder = if null title+          then topLeft ++ replicate (totalWidth - 2) (head horizontal) ++ topRight+          else let titlePadding = totalWidth - length title - 2+                   leftPad = titlePadding `div` 2  +                   rightPad = titlePadding - leftPad+               in topLeft ++ replicate leftPad (head horizontal) ++ title ++ replicate rightPad (head horizontal) ++ topRight+               +        bottomBorder = bottomLeft ++ replicate (totalWidth - 2) (head horizontal) ++ bottomRight+        +        paddedContent = map (\line -> +          vertical ++ " " ++ line ++ replicate (innerWidth - length line) ' ' ++ " " ++ vertical) contentLines+          +    in intercalate "\n" (topBorder : paddedContent ++ [bottomBorder])++data StatusCard = StatusCard String String Border+instance Element StatusCard where+  renderElement (StatusCard label content border) =+    let labelLines = lines label+        contentLines = lines content+        allLines = labelLines ++ contentLines+        maxWidth = if null allLines then 0 else maximum (map length allLines)+        contentWidth = maxWidth + 2+        (topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, _, _, _) = borderChars border+        +        topBorder = topLeft ++ replicate (contentWidth + 2) (head horizontal) ++ topRight+        bottomBorder = bottomLeft ++ replicate (contentWidth + 2) (head horizontal) ++ bottomRight+        +        createCardLines ls = map (\line ->+          vertical ++ " " ++ line ++ replicate (contentWidth - length line) ' ' ++ " " ++ vertical) ls+        labelCardLines = createCardLines labelLines  +        contentCardLines = createCardLines contentLines+        +    in intercalate "\n" ([topBorder] ++ labelCardLines ++ contentCardLines ++ [bottomBorder])++-- | Margin element that adds prefix to each line+data Margin = Margin String [L]  -- prefix, elements+instance Element Margin where+  renderElement (Margin prefix elements) = +    let content = if length elements == 1 +                  then render (head elements)+                  else render (Layout elements)+        contentLines = lines content+    in intercalate "\n" $ map (\line -> prefix ++ " " ++ line) contentLines++-- | Horizontal rule with custom character and width  +data HorizontalRule = HorizontalRule String Int  -- char, width+instance Element HorizontalRule where+  renderElement (HorizontalRule char width) = concat (replicate width char)+++-- | Padded element with padding around all sides+data Padded = Padded String Int  -- content, padding+instance Element Padded where+  renderElement (Padded content padding) = +    let contentLines = lines content+        maxWidth = if null contentLines then 0 else maximum (0 : map length contentLines)+        horizontalPad = replicate padding ' '+        verticalPad = replicate (maxWidth + padding * 2) ' '+        paddedLines = map (\line -> horizontalPad ++ line ++ replicate (maxWidth - length line) ' ' ++ horizontalPad) contentLines+        verticalLines = replicate padding verticalPad+    in intercalate "\n" (verticalLines ++ paddedLines ++ verticalLines)++-- | Chart for data visualization+data Chart = Chart [(String, Double)]  -- (label, value) pairs+instance Element Chart where+  renderElement (Chart dataPoints) = +    if null dataPoints then "No data"+    else let maxValue = maximum (0 : map snd dataPoints)+             maxLabelWidth = minimum [15, maximum (0 : map (length . fst) dataPoints)]+             chartWidth = 40+         in intercalate "\n" $ map (renderBar maxValue maxLabelWidth chartWidth) dataPoints+    where+      renderBar maxVal labelWidth barWidth (label, value) = +        let truncatedLabel = if length label > labelWidth +                            then take (labelWidth - 3) label ++ "..."+                            else label+            paddedLabel = truncatedLabel ++ replicate (labelWidth - length truncatedLabel) ' '+            percentage = value / maxVal+            barLength = floor (percentage * fromIntegral barWidth)+            bar = replicate barLength '█'+            emptyBar = replicate (barWidth - barLength) '─'+            valueStr = if value == fromInteger (round value) +                      then show (round value)+                      else printf "%.1f" value+        in paddedLabel ++ " │" ++ bar ++ emptyBar ++ "│ " ++ valueStr++-- | Table with headers and borders (fixed alignment)+data Table = Table [String] [[L]] Border  -- headers, rows, border+instance Element Table where+  renderElement (Table headers rows border) = +    let normalizedRows = map (normalizeRow (length headers)) rows+        columnWidths = calculateColumnWidths headers normalizedRows+        (topLeft, topRight, bottomLeft, bottomRight, horizontal, vertical, leftTee, rightTee, cross) = borderChars border+        +        -- Calculate actual table width based on content and separators+        totalContentWidth = sum columnWidths+        totalSeparatorWidth = (length columnWidths - 1) * 3  -- " | " between columns+        totalWidth = totalContentWidth + totalSeparatorWidth + 4  -- 4 for outer borders and padding+        hChar = head horizontal+        +        -- Fixed border construction with proper connectors+        topConnector = case border of+          RoundBorder -> "┬"+          NormalBorder -> "┬"+          DoubleBorder -> "╦" +          ThickBorder -> "┳"+        topParts = map (\w -> replicate w hChar) columnWidths+        topBorder = topLeft ++ [hChar] ++ intercalate ([hChar] ++ topConnector ++ [hChar]) topParts ++ [hChar] ++ topRight+        +        -- Create proper separator with tee connectors+        separatorParts = map (\w -> replicate w hChar) columnWidths+        separatorBorder = leftTee ++ [hChar] ++ intercalate ([hChar] ++ cross ++ [hChar]) separatorParts ++ [hChar] ++ rightTee+        +        -- Create proper bottom border with bottom connectors+        bottomConnector = case border of+          RoundBorder -> "┴"  -- Special case for round borders+          NormalBorder -> "┴"+          DoubleBorder -> "╩" +          ThickBorder -> "┻"+        bottomParts = map (\w -> replicate w hChar) columnWidths  +        bottomBorder = bottomLeft ++ [hChar] ++ intercalate ([hChar] ++ bottomConnector ++ [hChar]) bottomParts ++ [hChar] ++ bottomRight+        +        -- Create header row+        headerCells = zipWith padToWidth columnWidths headers+        headerRow = vertical ++ " " ++ intercalate (" " ++ vertical ++ " ") headerCells ++ " " ++ vertical+        +        -- Create data rows+        dataRows = concatMap (renderTableRow columnWidths vertical) normalizedRows+        +    in intercalate "\n" ([topBorder, headerRow, separatorBorder] ++ dataRows ++ [bottomBorder])+    where+      normalizeRow expectedLen row = +        let currentLen = length row+        in if currentLen >= expectedLen +           then take expectedLen row+           else row ++ replicate (expectedLen - currentLen) (text "")+      +      calculateColumnWidths hdrs rws = +        let headerWidths = map length hdrs+            rowWidths = map (map (safeMaxWidth . lines . render)) rws+            allWidths = headerWidths : rowWidths+        in map (maximum . (0:)) (transpose allWidths)+        where+          safeMaxWidth [] = 0+          safeMaxWidth linesList = maximum (0 : map length linesList)+      +      padToWidth width str = str ++ replicate (max 0 (width - length str)) ' '+      +      renderTableRow widths vChars row = +        let cellContents = map render row+            cellLines = map lines cellContents+            maxCellHeight = if null cellLines then 1 else maximum (1 : map length cellLines)+            paddedCells = zipWith (padCellToSize maxCellHeight) widths cellLines+            tableRows = [[paddedCells !! j !! i | j <- [0..length paddedCells - 1]] | i <- [0..maxCellHeight - 1]]+        in map (\rowCells -> vChars ++ " " ++ intercalate (" " ++ vChars ++ " ") rowCells ++ " " ++ vChars) tableRows+      +      padCellToSize height width cellLines =+        let paddedLines = cellLines ++ replicate (height - length cellLines) ""+        in map (\line -> line ++ replicate (max 0 (width - length line)) ' ') paddedLines++-- | Section with decorative header+data Section = Section String [L] String Int  -- title, content, glyph, flanking_chars+instance Element Section where+  renderElement (Section title content glyph flankingChars) = +    let header = replicate flankingChars (head glyph) ++ " " ++ title ++ " " ++ replicate flankingChars (head glyph)+        body = render (Layout content)+    in header ++ "\n" ++ body++-- | Key-value pairs with alignment+data KeyValue = KeyValue [(String, String)]+instance Element KeyValue where+  renderElement (KeyValue pairs) = +    if null pairs then ""+    else let maxKeyLength = maximum (map (length . fst) pairs)+             alignmentPosition = maxKeyLength + 2+         in intercalate "\n" $ map (renderPair alignmentPosition) pairs+    where+      renderPair alignPos (key, value) = +        let keyWithColon = key ++ ":"+            spacesNeeded = alignPos - length keyWithColon+            padding = replicate (max 1 spacesNeeded) ' '+        in keyWithColon ++ padding ++ value++-- | Tree structure for hierarchical data+data Tree = Tree String [Tree]+instance Element Tree where+  renderElement tree = renderTree tree "" True []+    where+      renderTree (Tree name children) prefix isLast parentPrefixes =+        let nodeLine = if null parentPrefixes +                      then name+                      else prefix ++ (if isLast then "└── " else "├── ") ++ name+            childPrefix = if null parentPrefixes+                         then ""+                         else prefix ++ (if isLast then "    " else "│   ")+            childLines = zipWith (\child idx -> +                          renderTree child childPrefix (idx == length children - 1) (parentPrefixes ++ [not isLast])+                        ) children [0..]+        in if null children+           then nodeLine+           else nodeLine ++ "\n" ++ intercalate "\n" childLines+++isUnorderedList :: L -> Bool+isUnorderedList (UL _) = True+isUnorderedList _ = False++getUnorderedListItems :: L -> [L]+getUnorderedListItems (UL items) = items+getUnorderedListItems _ = []++newtype UnorderedList = UnorderedList [L]+instance Element UnorderedList where+  renderElement (UnorderedList items) = renderAtLevel 0 items+    where+      bulletStyles = ["•", "◦", "▪"]+      +      renderAtLevel level itemList = +        let currentBullet = bulletStyles !! (level `mod` length bulletStyles)+            indent = replicate (level * 2) ' '+        in intercalate "\n" $ map (renderItem level indent currentBullet) itemList+      +      renderItem level indent bullet item = +        if isUnorderedList item+        then renderAtLevel (level + 1) (getUnorderedListItems item)+        else+          let content = render item+              contentLines = lines content+          in case contentLines of+            [singleLine] -> indent ++ bullet ++ " " ++ singleLine+            (firstLine:restLines) -> +              let firstOutput = indent ++ bullet ++ " " ++ firstLine+                  restIndent = replicate (length indent + length bullet + 1) ' '+                  restOutput = map (restIndent ++) restLines+              in intercalate "\n" (firstOutput : restOutput)+            [] -> indent ++ bullet ++ " "++data InlineBar = InlineBar String Double+instance Element InlineBar where+  renderElement (InlineBar label progress) =+    let clampedProgress = max 0.0 (min 1.0 progress)+        barWidth = 20+        filledSegments = floor (clampedProgress * fromIntegral barWidth)+        emptySegments = barWidth - filledSegments+        bar = replicate filledSegments '█' ++ replicate emptySegments '─'+        percentage = floor (clampedProgress * 100) :: Int+    in printf "%s [%s] %d%%" label bar percentage++-- Smart constructors and automatic conversions+text :: String -> L+text s = L (Text s)++br :: L  +br = L (LineBreak ())++center :: Element a => a -> L+center element = AutoCenter (L element)++-- | Center element within specified width+center' :: Element a => Int -> a -> L+center' width element = L (Centered (render element) width)++underline :: Element a => a -> L+underline element = L (Underlined (render element) "─")++-- | Add underline with custom character+underline' :: Element a => String -> a -> L+underline' char element = L (Underlined (render element) char)++ul :: [L] -> L+ul items = UL items++inlineBar :: String -> Double -> L+inlineBar label progress = L (InlineBar label progress)++statusCard :: String -> String -> L+statusCard label content = L (StatusCard label content NormalBorder)++-- | Status card with custom border+statusCard' :: Border -> String -> String -> L+statusCard' border label content = L (StatusCard label content border)++layout :: [L] -> L+layout elements = L (Layout elements)++row :: [L] -> L  +row elements = L (Row elements)++box :: String -> [L] -> L+box title elements = L (Box title elements NormalBorder)++-- | Box with custom border  +box' :: Border -> String -> [L] -> L+box' border title elements = L (Box title elements border)++-- | Create margin with custom prefix+margin :: String -> [L] -> L+margin prefix elements = L (Margin prefix elements)++-- | Predefined status margins+marginError, marginWarn, marginSuccess, marginInfo :: [L] -> L+marginError elements = L (Margin "[error]" elements)+marginWarn elements = L (Margin "[warn]" elements)  +marginSuccess elements = L (Margin "[success]" elements)+marginInfo elements = L (Margin "[info]" elements)++-- | Horizontal rule with default character and width+hr :: L+hr = L (HorizontalRule "─" 50)++-- | Horizontal rule with custom character  +hr' :: String -> L+hr' char = L (HorizontalRule char 50)++-- | Horizontal rule with custom character and width+hr'' :: String -> Int -> L+hr'' char width = L (HorizontalRule char width)++-- | Add padding around element+pad :: Element a => Int -> a -> L  +pad padding element = L (Padded (render element) padding)++-- | Create horizontal bar chart+chart :: [(String, Double)] -> L+chart dataPoints = L (Chart dataPoints)++-- | Create table with headers and rows+table :: [String] -> [[L]] -> L+table headers rows = L (Table headers rows NormalBorder)++-- | Create table with custom border+table' :: Border -> [String] -> [[L]] -> L+table' border headers rows = L (Table headers rows border)++-- | Create section with title and content+section :: String -> [L] -> L+section title content = L (Section title content "=" 3)++-- | Create section with custom glyph+section' :: String -> String -> [L] -> L+section' glyph title content = L (Section title content glyph 3)++-- | Create section with custom glyph and flanking chars+section'' :: String -> String -> Int -> [L] -> L+section'' glyph title flanking content = L (Section title content glyph flanking)++-- | Create key-value pairs+kv :: [(String, String)] -> L+kv pairs = L (KeyValue pairs)++-- | Create tree structure+tree :: String -> [Tree] -> L+tree name children = L (Tree name children)++-- | Create leaf tree node (no children)+leaf :: String -> Tree+leaf name = Tree name []++-- | Create branch tree node with children+branch :: String -> [Tree] -> Tree+branch name children = Tree name children+
+ README.md view
@@ -0,0 +1,403 @@+<p align="center">+  <img src="pix/layoutz-demo.png" width="700">+</p>++# <img src="../pix/layoutz.png" width="60"> layoutz++**Simple, beautiful CLI output for Haskell 🪶**++Build declarative and composable sections, trees, tables, dashboards for your Haskell applications.++## Features+- Zero dependencies, use `Layoutz.hs` like a header file+- Rich text formatting: alignment, underlines, padding, margins+- Lists, trees, tables, charts, banners...++## Installation++Add to your `package.yaml` or `.cabal` file:+```yaml+dependencies:+  - layoutz+```++Or install directly:+```bash+cabal install layoutz+```++All you need:+```haskell+import Layoutz+```++## Quickstart++Beautiful, compositional text layouts:++```haskell+import Layoutz++demo = layout+  [ center $ row [text "Layoutz", underline' "ˆ" $ text "DEMO"]+  , br+  , row+    [ statusCard "Users" "1.2K"+    , statusCard' DoubleBorder "API" "UP"+    , statusCard' ThickBorder "CPU" "23%"+    , table' RoundBorder ["Name", "Role", "Status"] +        [ [text "Alice", text "Engineer", text "Online"]+        , [text "Eve", text "QA", text "Away"]+        ]+    , section "Pugilists" [kv [("Kazushi", "Sakuraba"), ("Jet", "Li")]]+    ]+  ]++putStrLn $ render demo+```++```+                                Layoutz DEMO+                                        ˆˆˆˆ++┌─────────┐ ╔═══════╗ ┏━━━━━━━┓ ╭───────┬──────────┬────────╮ === Pugilists ===+│ Users   │ ║ API   ║ ┃ CPU   ┃ │ Name  │ Role     │ Status │ Kazushi: Sakuraba+│ 1.2K    │ ║ UP    ║ ┃ 23%   ┃ ├───────┼──────────┼────────┤ Jet:     Li+└─────────┘ ╚═══════╝ ┗━━━━━━━┛ │ Alice │ Engineer │ Online │+                                │ Eve   │ QA       │ Away   │+                                ╰───────┴──────────┴────────╯+```++## Core concepts+- Every piece of content is an `Element`+- Elements are **immutable** and **composable** - build complex layouts by combining simple elements+- A `layout` arranges elements **vertically**:+```haskell+layout [elem1, elem2, elem3]  -- Joins with "\n"+```+Call `render` on any element to get a string++The power comes from **uniform composition** - since everything has the `Element` typeclass, everything can be combined.++## Elements++### Text+```haskell+text "Simple text"+```+```+Simple text+```++### Line Break+Add line breaks with `br`:+```haskell+layout [text "Line 1", br, text "Line 2"]+```+```+Line 1++Line 2+```++### Section: `section`+```haskell+section "Config" [kv [("env", "prod")]]+section' "-" "Status" [kv [("health", "ok")]]+section'' "#" "Report" 5 [kv [("items", "42")]]+```+```+=== Config ===+env: prod++--- Status ---+health: ok++##### Report #####+items: 42+```++### Layout (vertical): `layout`+```haskell+layout [text "First", text "Second", text "Third"]+```+```+First+Second+Third+```++### Row (horizontal): `row`+```haskell+row [text "Left", text "Middle", text "Right"]+```+```+Left Middle Right+```++### Horizontal rule: `hr`+```haskell+hr+hr' "~"+hr'' "-" 10+```+```+──────────────────────────────────────────────────+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+----------+```++### Key-value pairs: `kv`+```haskell+kv [("name", "Alice"), ("role", "admin")]+```+```+name: Alice+role: admin+```++### Table: `table`+Tables automatically handle alignment and borders:+```haskell+table ["Name", "Age", "City"] +  [ [text "Alice", text "30", text "New York"]+  , [text "Bob", text "25", text ""]  -- Missing values handled+  , [text "Charlie", text "35", text "London"]+  ]+```+```+┌─────────┬─────┬─────────┐+│ Name    │ Age │ City    │+├─────────┼─────┼─────────┤+│ Alice   │ 30  │ New York│+│ Bob     │ 25  │         │+│ Charlie │ 35  │ London  │+└─────────┴─────┴─────────┘+```++### Unordered Lists: `ul`+Clean unordered lists with automatic nesting:+```haskell+ul [text "Feature A", text "Feature B", text "Feature C"]+```+```+• Feature A+• Feature B+• Feature C+```++Nested lists with auto-styling:+```haskell+ul [ text "Backend"+   , ul [text "API", text "Database"]+   , text "Frontend"+   , ul [text "Components", ul [text "Header", ul [text "Footer"]]]+   ]+```+```+• Backend+  ◦ API+  ◦ Database+• Frontend+  ◦ Components+    ▪ Header+      • Footer+```++### Underline: `underline`+Add underlines to any element:+```haskell+underline $ text "Important Title"+underline' "=" $ text "Custom"+```+```+Important Title+───────────────++Custom+══════+```++### Box: `box`+With title:+```haskell+box "Summary" [kv [("total", "42")]]+```+```+┌──Summary───┐+│ total: 42  │+└────────────┘+```++Without title:+```haskell+box "" [kv [("total", "42")]]+```+```+┌────────────┐+│ total: 42  │+└────────────┘+```++### Status card: `statusCard`+```haskell+statusCard "CPU" "45%"+```+```+┌───────┐+│ CPU   │+│ 45%   │+└───────┘+```++### Progress bar: `inlineBar`+```haskell+inlineBar "Download" 0.75+```+```+Download [███████████████─────] 75%+```++### Tree: `tree`+```haskell+tree "Project" +  [ branch "src" +      [ leaf "main.hs"+      , leaf "test.hs"+      ]+  , branch "docs"+      [ leaf "README.md"+      ]+  ]+```+```+Project+├── src+│   ├── main.hs+│   └── test.hs+└── docs+    └── README.md+```++### Chart: `chart`+```haskell+chart [("Web", 10), ("Mobile", 20), ("API", 15)]+```+```+Web    │████████████████████ 10+Mobile │████████████████████████████████████████ 20+API    │██████████████████████████████ 15+```++### Padding: `pad`+Add uniform padding around any element:+```haskell+pad 2 $ text "content"+```+```+        +        +  content  +        +        +```++### Centering: `center`+Smart auto-centering and manual width:+```haskell+center $ text "Auto-centered"     -- Uses layout context+center' 20 $ text "Manual width"  -- Fixed width+```+```+        Auto-centered        ++    Manual width    +```++### Margin: `margin`+Use `margin` for colorful "compiler-style" prefixes:++```haskell+layout+  [ marginError [text "Type error: expected Int, got String"]+  , marginWarn [text "Unused variable 'temp'"] +  , marginSuccess [text "Build completed successfully"]+  , marginInfo [text "Pro tip: Use layoutz for beautiful output"]+  ]+```+```+[error] Type error: expected Int, got String+[warn] Unused variable 'temp'+[success] Build completed successfully+[info] Pro tip: Use layoutz for beautiful output+```++## Border Styles+Elements like `box`, `table`, and `statusCard` support different border styles:++**NormalBorder** (default):+```haskell+box "Title" [text "content"]+```+```+┌──Title──┐+│ content │+└─────────┘+```++**DoubleBorder**:+```haskell+statusCard' DoubleBorder "API" "UP"+```+```+╔═══════╗+║ API   ║+║ UP    ║+╚═══════╝+```++**ThickBorder**:+```haskell+table' ThickBorder ["Name"] [[text "Alice"]]+```+```+┏━━━━━━━┓+┃ Name  ┃+┣━━━━━━━┫+┃ Alice ┃+┗━━━━━━━┛+```++**RoundBorder**:+```haskell+box' RoundBorder "Info" [text "content"]+```+```+╭──Info───╮+│ content │+╰─────────╯+```++## REPL++Drop into GHCi to experiment:+```bash+cabal repl+```++```haskell+λ> import Layoutz+λ> putStrLn $ render $ center $ box "Hello" [text "World!"]+┌──Hello──┐+│ World!  │+└─────────┘+λ> putStrLn $ render $ table ["A", "B"] [[text "1", text "2"]]+┌───┬───┐+│ A │ B │+├───┼───┤+│ 1 │ 2 │+└───┴───┘+```++## Inspiration+- Original Scala [layoutz](https://github.com/mattlianje/layoutz)
+ Test.hs view
@@ -0,0 +1,133 @@+{-# LANGUAGE OverloadedStrings #-}++import Test.Tasty+import Test.Tasty.HUnit+import Layoutz++main :: IO ()+main = defaultMain tests++tests :: TestTree+tests = testGroup "Layoutz Tests"+  [ basicElementTests+  , visualFeatureTests+  , dataVisualizationTests+  , containerTests+  , layoutTests+  , dimensionTests+  ]++-- Basic element tests+basicElementTests :: TestTree+basicElementTests = testGroup "Basic Elements"+  [ testCase "text rendering" $+      render (text "Hello World") @?= "Hello World"+      +  , testCase "line break rendering" $+      render br @?= ""+      +  , testCase "underline default" $+      render (underline $ text "Test") @?= "Test\n────"+      +  , testCase "underline custom" $+      render (underline' "=" $ text "Test") @?= "Test\n===="+      +  , testCase "center default width" $+      -- center now uses smart auto-centering, test within a layout context+      render (layout [text "wide content here to establish context width", center $ text "Test"]) @?= "wide content here to establish context width\n                    Test                    "+      +  , testCase "center custom width" $+      render (center' 10 $ text "Test") @?= "   Test   "+  ]++-- Visual feature tests+visualFeatureTests :: TestTree+visualFeatureTests = testGroup "Visual Features"+  [ testCase "horizontal rule default" $+      render hr @?= "──────────────────────────────────────────────────"+      +  , testCase "horizontal rule custom char" $+      render (hr' "=") @?= "=================================================="+      +  , testCase "horizontal rule custom width" $+      render (hr'' "-" 10) @?= "----------"+      +  , testCase "padding" $+      lines (render $ pad 2 $ text "Test") @?= ["        ", "        ", "  Test  ", "        ", "        "]+      +  , testCase "margin custom" $+      render (margin ">>>" [text "Line 1", text "Line 2"]) @?= ">>> Line 1\n>>> Line 2"+      +  , testCase "margin error" $+      render (marginError [text "Error message"]) @?= "[error] Error message"+  ]++-- Data visualization tests+dataVisualizationTests :: TestTree+dataVisualizationTests = testGroup "Data Visualization"+  [ testCase "inline bar 0%" $+      "Test [────────────────────] 0%" `elem` lines (render $ inlineBar "Test" 0.0) @?= True+      +  , testCase "inline bar 50%" $+      "Test [██████████──────────] 50%" `elem` lines (render $ inlineBar "Test" 0.5) @?= True+      +  , testCase "inline bar 100%" $+      "Test [████████████████████] 100%" `elem` lines (render $ inlineBar "Test" 1.0) @?= True+      +  , testCase "chart" $+      "Task │████████████████████████████████████████│ 50" `elem` lines (render $ chart [("Task", 50.0)]) @?= True+      +  , testCase "key-value pairs" $+      render (kv [("Key", "Value")]) @?= "Key: Value"+  ]++-- Container tests+containerTests :: TestTree+containerTests = testGroup "Containers"+  [ testCase "status card default border" $+      length (lines $ render $ statusCard "API" "UP") @?= 4+      +  , testCase "status card double border" $+      "╔═══════╗" `elem` lines (render $ statusCard' DoubleBorder "API" "UP") @?= True+      +  , testCase "box default border" $+      "┌──Title──┐" `elem` lines (render $ box "Title" [text "Content"]) @?= True+      +  , testCase "box double border" $+      "╔══Title══╗" `elem` lines (render $ box' DoubleBorder "Title" [text "Content"]) @?= True+  ]++-- Layout tests+layoutTests :: TestTree+layoutTests = testGroup "Layout"+  [ testCase "unordered list single level" $+      render (ul [text "Item 1", text "Item 2"]) @?= "• Item 1\n• Item 2"+      +  , testCase "unordered list nested" $+      "  ◦ Sub 1" `elem` lines (render $ ul [text "Item 1", ul [text "Sub 1", text "Sub 2"]]) @?= True+      +  , testCase "table basic" $+      "│ A │ B │" `elem` lines (render $ table ["A", "B"] [[text "1", text "2"]]) @?= True+      +  , testCase "tree structure" $+      "Root" `elem` lines (render $ tree "Root" [leaf "Child"]) @?= True+      +  , testCase "section" $+      "=== Title ===" `elem` lines (render $ section "Title" [text "Content"]) @?= True+      +  , testCase "row layout" $+      render (row [text "A", text "B"]) @?= "A B"+      +  , testCase "layout composition" $+      render (layout [text "Line 1", text "Line 2"]) @?= "Line 1\nLine 2"+  ]++-- Dimension tests+dimensionTests :: TestTree+dimensionTests = testGroup "Dimensions"+  [ testCase "width calculation" $+      width (text "Hello") @?= 5+      +  , testCase "height calculation" $+      height (layout [text "Line 1", text "Line 2"]) @?= 2+  ]
+ layoutz.cabal view
@@ -0,0 +1,39 @@+cabal-version: 3.0+name:          layoutz+version:       0.1.0.0+synopsis:      Simple, beautiful CLI output for Haskell+description:   +  Build declarative and composable sections, trees, tables, dashboards for your Haskell applications.+  .+  Zero dependencies, rich text formatting with alignment, underlines, padding, margins.+  Features lists, trees, tables, charts, banners and more.+homepage:      https://github.com/mattlianje/layoutz+bug-reports:   https://github.com/mattlianje/layoutz/issues+license:       Apache-2.0+license-file:  LICENSE+author:        Matthieu Court+maintainer:    matthieu.court@protonmail.com+copyright:     2025 Matthieu Court+category:      Text+build-type:    Simple+extra-source-files:+    README.md+    pix/*.png++library+  exposed-modules:     Layoutz+  build-depends:       base >= 4.7 && < 5+  hs-source-dirs:      .+  default-language:    Haskell2010+  ghc-options:         -Wall++test-suite layoutz-tests+  type:                exitcode-stdio-1.0+  main-is:             Test.hs+  build-depends:       base >= 4.7 && < 5+                     , layoutz+                     , tasty >= 1.4+                     , tasty-hunit >= 0.10+  hs-source-dirs:      .  +  default-language:    Haskell2010+  ghc-options:         -Wall
+ pix/layoutz-demo.png view

binary file changed (absent → 502408 bytes)

+ pix/layoutz-hs.png view

binary file changed (absent → 488692 bytes)