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 +201/−0
- Layoutz.hs +560/−0
- README.md +403/−0
- Test.hs +133/−0
- layoutz.cabal +39/−0
- pix/layoutz-demo.png binary
- pix/layoutz-hs.png binary
+ 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)