diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ac2397 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26.0.2" + gleam-version: "1.2.1" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..368592d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.beam +*.ez +/build +erl_crash.dump +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..eee6748 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# pink +Bindings to [Ink](https://github.com/vadimdemedes/ink) +A minimal React-like library for building terminal UIs. + +[![Package Version](https://img.shields.io/hexpm/v/pink)](https://hex.pm/packages/pink) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/pink/) + +## Installation + +This package depends on Ink and React. The specific versions listed below are recommended because they have been tested with this package. Using other versions may lead to unexpected results. + +```sh +npm install ink@5.0 ink-spinner@5.0 react@18.3 +``` + +To add the Pink package to your Gleam project, use the following command: + +```sh +gleam add pink +``` + +Here's a basic example of how to use Pink: + +```gleam +import pink +import pink/attribute +import pink/hook + +pub fn main() { + // Create a new React component (we need this to use hooks) + use <- pink.component() + + // Initialize a state (this is React's useState hook) + let message = hook.state("World") + + // Create a box with a border and a text component inside + pink.box([attribute.border_style(attribute.BorderSingle)], [ + pink.text([], "Hello, " <> message.value) + ]) +} +``` diff --git a/birdie_snapshots/box.accepted b/birdie_snapshots/box.accepted new file mode 100644 index 0000000..631fe70 --- /dev/null +++ b/birdie_snapshots/box.accepted @@ -0,0 +1,23 @@ +--- +version: 1.1.6 +title: box +file: ./test/pink_test.gleam +test_name: box_test +--- + + + This is a box with margin 2 + + +widt +h 4 +height 4 + + + +┌──────┐ ╔══════╗ ╭─────╮ ┏━━━━┓ +│single│ ║double║ │round│ ┃bold┃ +└──────┘ ╚══════╝ ╰─────╯ ┗━━━━┛ +╓─────────────╖ ╒═════════════╕ +-------+ +║single double║ │double single│ |classic| +╙─────────────╜ ╘═════════════╛ +-------+ \ No newline at end of file diff --git a/birdie_snapshots/hook_app.accepted b/birdie_snapshots/hook_app.accepted new file mode 100644 index 0000000..522d686 --- /dev/null +++ b/birdie_snapshots/hook_app.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: hook app +file: ./test/pink_test.gleam +test_name: use_app_test +--- +Hello, world \ No newline at end of file diff --git a/birdie_snapshots/hook_focus.accepted b/birdie_snapshots/hook_focus.accepted new file mode 100644 index 0000000..3cd79fd --- /dev/null +++ b/birdie_snapshots/hook_focus.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: hook focus +file: ./test/pink_test.gleam +test_name: use_focus_test +--- +I am focused \ No newline at end of file diff --git a/birdie_snapshots/hook_focus_manager.accepted b/birdie_snapshots/hook_focus_manager.accepted new file mode 100644 index 0000000..13e7439 --- /dev/null +++ b/birdie_snapshots/hook_focus_manager.accepted @@ -0,0 +1,14 @@ +--- +version: 1.1.6 +title: hook focus_manager +file: ./test/pink_test.gleam +test_name: use_manager_test +--- +I am not focused +I am not focused +--- +I am focused +I am not focused +--- +I am not focused +I am focused \ No newline at end of file diff --git a/birdie_snapshots/hook_input.accepted b/birdie_snapshots/hook_input.accepted new file mode 100644 index 0000000..e2552e1 --- /dev/null +++ b/birdie_snapshots/hook_input.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: hook input +file: ./test/pink_test.gleam +test_name: use_input_test +--- +Type something \ No newline at end of file diff --git a/birdie_snapshots/hook_state.accepted b/birdie_snapshots/hook_state.accepted new file mode 100644 index 0000000..8e6c6cd --- /dev/null +++ b/birdie_snapshots/hook_state.accepted @@ -0,0 +1,11 @@ +--- +version: 1.1.6 +title: hook state +file: ./test/pink_test.gleam +test_name: use_state_test +--- +Hello, world +--- +Changed +--- +Changed 2 \ No newline at end of file diff --git a/birdie_snapshots/hook_stdin.accepted b/birdie_snapshots/hook_stdin.accepted new file mode 100644 index 0000000..1897375 --- /dev/null +++ b/birdie_snapshots/hook_stdin.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: hook stdin +file: ./test/pink_test.gleam +test_name: use_stdin_test +--- +is_raw_mode_supported: True \ No newline at end of file diff --git a/birdie_snapshots/hook_stdout.accepted b/birdie_snapshots/hook_stdout.accepted new file mode 100644 index 0000000..50bd242 --- /dev/null +++ b/birdie_snapshots/hook_stdout.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: hook stdout +file: ./test/pink_test.gleam +test_name: use_stdout_test +--- +Hello from stdout \ No newline at end of file diff --git a/birdie_snapshots/newline.accepted b/birdie_snapshots/newline.accepted new file mode 100644 index 0000000..fd703d4 --- /dev/null +++ b/birdie_snapshots/newline.accepted @@ -0,0 +1,9 @@ +--- +version: 1.1.6 +title: newline +file: ./test/pink_test.gleam +test_name: newline_test +--- +Hello + +World \ No newline at end of file diff --git a/birdie_snapshots/spacer.accepted b/birdie_snapshots/spacer.accepted new file mode 100644 index 0000000..8bc0d18 --- /dev/null +++ b/birdie_snapshots/spacer.accepted @@ -0,0 +1,18 @@ +--- +version: 1.1.6 +title: spacer +file: ./test/pink_test.gleam +test_name: spacer_test +--- +Left Right + +Top + + + + + + + + +Bottom \ No newline at end of file diff --git a/birdie_snapshots/spinner.accepted b/birdie_snapshots/spinner.accepted new file mode 100644 index 0000000..43962c3 --- /dev/null +++ b/birdie_snapshots/spinner.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: spinner +file: ./test/pink_test.gleam +test_name: spinner_test +--- +⠋ \ No newline at end of file diff --git a/birdie_snapshots/static.accepted b/birdie_snapshots/static.accepted new file mode 100644 index 0000000..f518f16 --- /dev/null +++ b/birdie_snapshots/static.accepted @@ -0,0 +1,18 @@ +--- +version: 1.1.6 +title: static +file: ./test/pink_test.gleam +test_name: static_test +--- + Test 1 + Test 2 + Test 3 + Test 4 + Test 5 + Test 6 + Test 7 + Test 8 + Test 9 + Test 10 + +Completed tests: 10 \ No newline at end of file diff --git a/birdie_snapshots/text.accepted b/birdie_snapshots/text.accepted new file mode 100644 index 0000000..5bf8cbd --- /dev/null +++ b/birdie_snapshots/text.accepted @@ -0,0 +1,14 @@ +--- +version: 1.1.6 +title: text +file: ./test/pink_test.gleam +test_name: text_test +--- +I am green +I am black on white +I am white +I am bold +I am italic +I am underline +I am strikethrough +I am inversed \ No newline at end of file diff --git a/birdie_snapshots/transform.accepted b/birdie_snapshots/transform.accepted new file mode 100644 index 0000000..55794d1 --- /dev/null +++ b/birdie_snapshots/transform.accepted @@ -0,0 +1,7 @@ +--- +version: 1.1.6 +title: transform +file: ./test/pink_test.gleam +test_name: transform_test +--- +HELLO, WORLD \ No newline at end of file diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..c4b120d --- /dev/null +++ b/gleam.toml @@ -0,0 +1,23 @@ +name = "pink" +version = "1.0.0" +target = "javascript" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +gleam_json = ">= 2.0.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +birdie = ">= 1.1.6 and < 2.0.0" +gleam_javascript = ">= 0.10.0 and < 1.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..6ebd807 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,28 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birdie", version = "1.1.6", build_tools = ["gleam"], requirements = ["argv", "filepath", "glance", "gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "justin", "rank", "simplifile", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "9DBF73AEF936EAA8D22FFCE2B10CF42E38D62F8A006F89E3FFC1170D5C1E585B" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "glance", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "8F3314D27773B7C3B9FB58D8C02C634290422CE531988C0394FA0DF8676B964D" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_javascript", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "62C31BFFE914FA2AFEE8459353CA1621A66BC10A7AF24B39E1EE15ACF33989B2" }, + { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "glexer", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "BD477AD657C2B637FEF75F2405FAEFFA533F277A74EF1A5E17B55B1178C228FB" }, + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" }, +] + +[requirements] +birdie = { version = ">= 1.1.6 and < 2.0.0" } +gleam_javascript = { version = ">= 0.10.0 and < 1.0.0"} +gleam_json = { version = ">= 2.0.0 and < 3.0.0" } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/package.json b/package.json new file mode 100644 index 0000000..218e8b3 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "pink", + "version": "1.0.0", + "main": "index.js", + "author": "Douglas M. ", + "license": "MIT", + "dependencies": { + "ink": "^5.0.0", + "ink-spinner": "^5.0.0", + "react": "^18.3.1" + }, + "devDependencies": { + "ink-testing-library": "^4.0.0" + } +} diff --git a/src/ink_ffi.mjs b/src/ink_ffi.mjs new file mode 100644 index 0000000..d04bf5b --- /dev/null +++ b/src/ink_ffi.mjs @@ -0,0 +1,45 @@ +import React from "react" +import { Text, Box, Newline, Spacer, useStdin, Static, Transform, useFocus, useFocusManager } from "ink"; +export { render, useInput, useApp, useStderr, useStdout } from "ink"; + +export function element(element, props, children) { + const parsedChildren = typeof children === "object" ? children.toArray() : [children] + return React.createElement(element, props, ...parsedChildren); +} + +export const text = (props, content) => element(Text, props, content); +export const box = (props, children) => element(Box, props, children); +export const newline = (props) => React.createElement(Newline, props, null); +export const spacer = () => React.createElement(Spacer, null, null); +export const static_ = (items, childFunc) => element(Static, { items: items.toArray() }, childFunc); +export const transform = (transform, children) => element(Transform, { transform }, children); + +export const useStdin_ = () => { + const stdin = useStdin() + + return { + stdin: stdin.stdin, + is_raw_mode_supported: stdin.isRawModeSupported, + set_raw_mode: stdin.setRawMode, + } +} + +export const useFocus_ = (options) => { + const focus = useFocus(options) + + return { + is_focused: focus.isFocused, + } +} + +export const useFocusManager_ = () => { + const manager = useFocusManager() + + return { + enable_focus: manager.enableFocus, + disable_focus: manager.disableFocus, + focus_next: manager.focusNext, + focus_previous: manager.focusPrevious, + focus: manager.focus, + } +} diff --git a/src/ink_spinner_ffi.mjs b/src/ink_spinner_ffi.mjs new file mode 100644 index 0000000..992899c --- /dev/null +++ b/src/ink_spinner_ffi.mjs @@ -0,0 +1,4 @@ +import React from "react"; +import Spinner from "ink-spinner"; + +export const spinner = ({ type_ }) => React.createElement(Spinner, { type: type_ }, null); diff --git a/src/pink.gleam b/src/pink.gleam new file mode 100644 index 0000000..fa2d8c5 --- /dev/null +++ b/src/pink.gleam @@ -0,0 +1,248 @@ +/// This module provides functions to create Ink components +import gleam/json.{type Json} +import pink/attribute.{type Attribute} + +/// A ReactNode is a representation of a component in the Ink library. +pub type ReactNode + +/// Make a ReactNode from a function +/// This is needed to use React hooks in the function +/// +/// ## Examples +/// ```gleam +/// use <- component() +/// let is_loading = hook.state(False) +/// ```` +@external(javascript, "./react_ffi.mjs", "component") +pub fn component(element: fn() -> ReactNode) -> ReactNode + +/// Wrapper for a fragment element +@external(javascript, "./react_ffi.mjs", "fragment") +fn react_fragment(attributes: Json, children: List(ReactNode)) -> ReactNode + +/// Render a ReactNode to the terminal +/// This is the main function to use to render components +/// +/// ## Examples +/// ```gleam +/// render( +/// box( +/// [ +/// attribute.flex_direction(attribute.FlexColumn), +/// attribute.gap(1), +/// ], +/// [ +/// text([], "Hello, "), +/// text([style.underline()], "world!"), +/// ] +/// ) +/// ) +/// ``` +@external(javascript, "./ink_ffi.mjs", "render") +pub fn render(component: ReactNode) -> Nil + +/// Wrapper for a box element +@external(javascript, "./ink_ffi.mjs", "box") +fn ink_box(attributes: Json, children: List(ReactNode)) -> ReactNode + +/// Wrapper for a text element +@external(javascript, "./ink_ffi.mjs", "text") +fn ink_text(attributes: Json, content: String) -> ReactNode + +/// Wrapper for a text element with children elements +@external(javascript, "./ink_ffi.mjs", "text") +fn ink_text_nested(attributes: Json, children: List(ReactNode)) -> ReactNode + +/// Wrapper for a newline element +@external(javascript, "./ink_ffi.mjs", "newline") +fn ink_newline(attributes: Json) -> ReactNode + +/// Wrapper for a spacer element +@external(javascript, "./ink_ffi.mjs", "spacer") +fn ink_spacer() -> ReactNode + +/// Wrapper for a static element +@external(javascript, "./ink_ffi.mjs", "static_") +fn ink_static(items: List(a), child: fn(a, Int) -> ReactNode) -> ReactNode + +/// Spinner component for Ink. Uses [cli-spinners](https://github.com/sindresorhus/cli-spinners) for the collection of spinners. +/// +/// ## Examples +/// ```gleam +/// spinner(type_: "dots") +/// ``` +@external(javascript, "./ink_spinner_ffi.mjs", "spinner") +pub fn spinner(type_ type_: String) -> ReactNode + +/// Wrapper for a transform element +@external(javascript, "./ink_ffi.mjs", "transform") +fn ink_transform( + transform: fn(String, Int) -> String, + children: List(ReactNode), +) -> ReactNode + +/// This component can display text, and change its style to make it bold, underline, italic or strikethrough. +/// It only supports text content. If you need to display other components within text, use `text_nested` instead. +/// +/// ## Examples +/// ```gleam +/// text(with: [], displaying: "Hello, world!") +/// ``` +pub fn text( + with attributes: List(Attribute), + displaying content: String, +) -> ReactNode { + attributes + |> attribute.encode + |> ink_text(content) +} + +/// The same as `text`, but allows you to nest other components within text. +/// +/// ## Examples +/// ```gleam +/// text_nested(attr: [], content: [ +/// text([style.bold()], "Hello, "), +/// text([style.underline()], "world!"), +/// ]) +/// ``` +pub fn text_nested( + attr attributes: List(Attribute), + content children: List(ReactNode), +) -> ReactNode { + attributes + |> attribute.encode + |> ink_text_nested(children) +} + +/// Creates a new ReactNode with the given element and children +fn node( + element: fn(Json, List(ReactNode)) -> ReactNode, + attributes: List(Attribute), + children: List(ReactNode), +) -> ReactNode { + attributes + |> attribute.encode + |> element(children) +} + +/// A React fragment is a way to group multiple elements together +/// without adding extra nodes to the DOM. +/// +/// ## Examples +/// ```gleam +/// fragment(attr: [], elements: [ +/// box([attribute.padding(1)], [pink.text([], "Hello, ")]), +/// text([style.underline()], "world!"), +/// ]) +/// ``` +pub fn fragment( + attr attributes: List(Attribute), + elements children: List(ReactNode), +) -> ReactNode { + node(react_fragment, attributes, children) +} + +/// `box` is an essential Ink component to build your layout. It's like
in the browser. +/// +/// ## Examples +/// ```gleam +/// box( +/// styles: [ +/// attribute.flex_direction(attribute.FlexColumn), +/// attribute.gap(1), +/// ], +/// components: [ +/// text([], "Hello, "), +/// text([style.underline()], "world!"), +/// ] +/// ) +/// ``` +pub fn box( + styles attributes: List(Attribute), + components children: List(ReactNode), +) -> ReactNode { + node(ink_box, attributes, children) +} + +/// Adds one or more newline (\n) characters. Must be used within components. +/// +/// ## Examples +/// ```gleam +/// text_nested([], [ +/// text([], "Hello, "), +/// newline([attribute.count(2)]), +/// text([], "world!") +/// ]) +/// ``` +pub fn newline(settings attributes: List(Attribute)) -> ReactNode { + attributes + |> attribute.encode + |> ink_newline +} + +/// A flexible space that expands along the major axis of its containing layout. It's useful as a shortcut for filling all the available spaces between elements. +/// For example, using in a with default flex direction (row) will position "Left" on the left side and will push "Right" to the right side. +/// +/// ## Examples +/// ```gleam +/// box([], [ +/// text([], "Left"), +/// spacer(), +/// text([], "Right"), +/// ]) +/// box( +/// [ +/// attribute.flex_direction(attribute.FlexColumn), +/// attribute.height(attribute.Spaces(10)), +/// ], +/// [text([], "Top"), spacer(), text([], "Bottom")], +/// ) +/// ``` +pub fn spacer() -> ReactNode { + ink_spacer() +} + +/// `static` component permanently renders its output above everything else. It's useful for displaying activity like completed tasks or logs - things that are not changing after they're rendered (hence the name "Static"). +/// It's preferred to use for use cases like these, when you can't know or control the amount of items that need to be rendered. +/// +/// ## Examples +/// ```gleam +/// fragment([], [ +/// // This part will be rendered once to the terminal +/// static(for: tests, using: fn(test_, _index) { +/// box([attribute.key(test_)], [ +/// text([attribute.color("green")], " " <> test_), +/// ]) +/// }), +/// // This part keeps updating as state changes +/// box([attribute.margin_top(1)], [ +/// text([], +/// "Completed tests: " <> int.to_string(list.length(tests)), +/// ), +/// ]), +/// ]) +/// ``` +pub fn static( + for items: List(a), + using callback: fn(a, Int) -> ReactNode, +) -> ReactNode { + ink_static(items, callback) +} + +/// Transform a string representation of React components before they are written to output. For example, you might want to apply a gradient to text, add a clickable link or create some text effects. +/// These use cases can't accept React nodes as input, they are expecting a string. That's what component does, it gives you an output string of its child components and lets you transform it in any way +/// +/// ## Examples +/// ```gleam +/// transform( +/// apply: fn(output, _index) { string.uppercase(output) }, +/// on: [text([], "Hello, world")], +/// ) +/// ``` +pub fn transform( + apply transform: fn(String, Int) -> String, + on children: List(ReactNode), +) -> ReactNode { + ink_transform(transform, children) +} diff --git a/src/pink/attribute.gleam b/src/pink/attribute.gleam new file mode 100644 index 0000000..1cb93fc --- /dev/null +++ b/src/pink/attribute.gleam @@ -0,0 +1,942 @@ +/// Attributes for components +import gleam/int +import gleam/json.{type Json} +import gleam/list +import gleam/string + +/// `Attribute` is a type that represents an attribute of an element +pub opaque type Attribute { + Attribute(key: String, value: Json) +} + +/// `Wrap` is the type the function `wrap` accepts +pub type Wrap { + Wrap + Truncate + TruncateStart + TruncateMiddle + TruncateEnd +} + +/// `Dimension` is the type some functions accept +pub type Dimension { + Spaces(Int) + Percent(Int) +} + +/// `FlexDirection` is the type the function `flex_direction` accepts +pub type FlexDirection { + FlexRow + FlexColumn + FlexRowReverse + FlexColumnReverse +} + +/// `FlexWrap` is the type the function `flex_wrap` accepts +pub type FlexWrap { + FlexWrap + FlexNoWrap + FlexWrapReverse +} + +/// `AlignItems` is the type the function `align_items` accepts +pub type AlignItems { + ItemsStart + ItemsCenter + ItemsEnd +} + +/// `AlignSelf` is the type the function `align_self` accepts +pub type AlignSelf { + SelfAuto + SelfStart + SelfCenter + SelfEnd +} + +/// `JustifyContent` is the type the function `justify_content` accepts +pub type JustifyContent { + ContentStart + ContentCenter + ContentEnd + ContentSpaceBetween + ContentSpaceAround +} + +/// `Display` is the type the function `display` accepts +pub type Display { + DisplayFlex + DisplayNone +} + +/// `Visibility` is the type the overflow functions accept +pub type Visibility { + Visible + Hidden +} + +/// `BorderStyle` is the type the function `border_style` accepts +pub type BorderStyle { + BorderSingle + BorderDouble + BorderRound + BorderBold + BorderSingleDouble + BorderDoubleSingle + BorderClassic + BorderCustom( + top_left: String, + top: String, + top_right: String, + left: String, + bottom_left: String, + bottom: String, + bottom_right: String, + right: String, + ) +} + +/// Create a custom attribute +/// Useful for passing custom data to components or for setting attributes that are not yet supported by Pink +pub fn custom(key: String, value: Json) -> Attribute { + Attribute(key, value) +} + +/// Set a unique key for the element +/// Useful for components that are rendered in a loop +pub fn key(key: String) -> Attribute { + Attribute("key", json.string(key)) +} + +/// Change text background color. Ink uses chalk under the hood, so all its functionality is supported +/// +/// ## Examples +/// ```gleam +/// text([background_color("green")], "Green") +/// text([background_color("#005cc5")], "Blue") +/// text([background_color("rgb(232, 131, 136)")], "Red") +/// ``` +pub fn background_color(color: String) -> Attribute { + Attribute("backgroundColor", json.string(color)) +} + +/// Change text color. Ink uses chalk under the hood, so all its functionality is supported +/// +/// ## Examples +/// ```gleam +/// text([color("green")], "Green") +/// text([color("#005cc5")], "Blue") +/// text([color("rgb(232, 131, 136)")], "Red") +/// ``` +pub fn color(color: String) -> Attribute { + Attribute("color", json.string(color)) +} + +/// Dim the color (emit a small amount of light) +/// +/// ## Examples +/// ```gleam +/// text([dim_color("red")], "Dimmed red") +/// ``` +pub fn dim_color(color: String) -> Attribute { + Attribute("dimColor", json.string(color)) +} + +/// Make the text bold +pub fn bold(is_bold: Bool) -> Attribute { + Attribute("bold", json.bool(is_bold)) +} + +/// Make the text italic +pub fn italic(is_italic: Bool) -> Attribute { + Attribute("italic", json.bool(is_italic)) +} + +/// Make the text underlined +pub fn underline(is_underline: Bool) -> Attribute { + Attribute("underline", json.bool(is_underline)) +} + +/// Make the text crossed with a line +pub fn strikethrough(is_strikethrough: Bool) -> Attribute { + Attribute("strikethrough", json.bool(is_strikethrough)) +} + +/// Inverse background and foreground colors +/// +/// ## Examples +/// ```gleam +/// text([inverse(True), color("yellow")], "Inversed Yellow") +/// ``` +pub fn inverse(is_inverse: Bool) -> Attribute { + Attribute("inverse", json.bool(is_inverse)) +} + +/// This property tells Ink to wrap or truncate text if its width is larger than container +/// If wrap is passed (by default), Ink will wrap text and split it into multiple lines. If Truncate* is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off +/// +/// ## Examples +/// ```gleam +/// box([width(7)], [text([], "Hello World")]) +/// // -> "Hello\nWorld" +/// ``` +/// ```gleam +/// // Truncate is an alias to TruncateEnd +/// box([width(7)], [text([wrap(Truncate)], "Hello World")]) +/// // -> "Hello…" +/// ``` +/// ```gleam +/// box([width(7)], [text([wrap(TruncateMiddle)], "Hello World")]) +/// // -> "He…ld" +/// ``` +/// ```gleam +/// box([width(7)], [text([wrap(TruncateStart)], "Hello World")]) +/// // -> "…World" +/// ``` +pub fn wrap(wrap: Wrap) -> Attribute { + Attribute( + "wrap", + json.string(case wrap { + Wrap -> "wrap" + Truncate -> "truncate" + TruncateStart -> "truncate-start" + TruncateMiddle -> "truncate-middle" + TruncateEnd -> "truncate-end" + }), + ) +} + +/// Sets a minimum width of the element +pub fn min_width(width: Int) -> Attribute { + Attribute("minWidth", json.int(width)) +} + +/// Sets a minimum height of the element +pub fn min_height(height: Int) -> Attribute { + Attribute("minHeight", json.int(height)) +} + +/// Top padding +pub fn padding_top(padding: Int) -> Attribute { + Attribute("paddingTop", json.int(padding)) +} + +/// Bottom padding +pub fn padding_bottom(padding: Int) -> Attribute { + Attribute("paddingBottom", json.int(padding)) +} + +/// Left padding +pub fn padding_left(padding: Int) -> Attribute { + Attribute("paddingLeft", json.int(padding)) +} + +/// Right padding +pub fn padding_right(padding: Int) -> Attribute { + Attribute("paddingRight", json.int(padding)) +} + +/// Horizontal padding. Equivalent to setting padding_left and padding_right +pub fn padding_x(padding: Int) -> Attribute { + Attribute("paddingX", json.int(padding)) +} + +/// Vertical padding. Equivalent to setting padding_top and padding_bottom +pub fn padding_y(padding: Int) -> Attribute { + Attribute("paddingY", json.int(padding)) +} + +/// Padding on all sides. Equivalent to setting padding_top, padding_bottom, padding_left and padding_right +pub fn padding(padding: Int) -> Attribute { + Attribute("padding", json.int(padding)) +} + +/// Top margin +pub fn margin_top(margin: Int) -> Attribute { + Attribute("marginTop", json.int(margin)) +} + +/// Bottom margin +pub fn margin_bottom(margin: Int) -> Attribute { + Attribute("marginBottom", json.int(margin)) +} + +/// Left margin +pub fn margin_left(margin: Int) -> Attribute { + Attribute("marginLeft", json.int(margin)) +} + +/// Right margin +pub fn margin_right(margin: Int) -> Attribute { + Attribute("marginRight", json.int(margin)) +} + +/// Horizontal margin. Equivalent to setting margin_left and margin_right +pub fn margin_x(margin: Int) -> Attribute { + Attribute("marginX", json.int(margin)) +} + +/// Vertical margin. Equivalent to setting margin_top and margin_bottom +pub fn margin_y(margin: Int) -> Attribute { + Attribute("marginY", json.int(margin)) +} + +/// Margin on all sides. Equivalent to setting margin_top, margin_bottom, margin_left and margin_right +pub fn margin(margin: Int) -> Attribute { + Attribute("margin", json.int(margin)) +} + +/// Size of the gap between an element's columns and rows. Shorthand for column_gap and row_gap +/// +/// ## Examples +/// ```gleam +/// box([gap(1), width(3), flex_wrap(FlexWrap)], [ +/// text([], "A"), +/// text([], "B"), +/// text([], "C") +/// ]) +/// // -> A B +/// // -> +/// // -> C +/// ``` +pub fn gap(gap: Int) -> Attribute { + Attribute("gap", json.int(gap)) +} + +/// Size of the gap between an element's columns +/// +/// ## Examples +/// ```gleam +/// box([column_gap(1)], [ +/// text([], "A"), +/// text([], "B"), +/// ]) +/// // -> A B +/// ``` +pub fn column_gap(gap: Int) -> Attribute { + Attribute("columnGap", json.int(gap)) +} + +/// Size of the gap between an element's rows +/// +/// ## Examples +/// ```gleam +/// box([flex_direction(FlexColumn), row_gap(1)], [ +/// text([], "A"), +/// text([], "B"), +/// ]) +/// // -> A +/// // -> +/// // -> B +/// ``` +pub fn row_gap(gap: Int) -> Attribute { + Attribute("rowGap", json.int(gap)) +} + +/// Defines the ability for a flex item to grow if necessary. It accepts a unitless value that serves as a proportion. It dictates what amount of the available space inside the flex container the item should take up +/// For more information, see [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/) +/// +/// ## Examples +/// ```gleam +/// box([], [ +/// text([], "Label"), +/// box([flex_grow(1)], [ +/// text([], "Fills all remaining space") +/// ]) +/// ]) +/// ``` +pub fn flex_grow(grow: Int) -> Attribute { + Attribute("flexGrow", json.int(grow)) +} + +/// Specifies the “flex shrink factor” which determines how much the flex item will shrink relative to the rest of the flex items in the flex container when there isn’t enough space on the row +/// For more information, see [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/) +/// +/// ## Examples +/// ```gleam +/// box([width(20)], [ +/// box([flex_shrink(2), width(10)], [ +/// text([], "Will be 1/4") +/// ]), +/// box([width(10)], [ +/// text([], "Will be 3/4") +/// ]), +/// ]) +/// ``` +pub fn flex_shrink(shrink: Int) -> Attribute { + Attribute("flexShrink", json.int(shrink)) +} + +/// Specifies the initial size of the flex item, before any available space is distributed according to the flex factors. +/// When omitted from the flex shorthand, its specified value is the length zero +/// For more information, see [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/) +/// +/// ## Examples +/// ```gleam +/// box([width(6)], [ +/// box([flex_basis(Spaces(3))], [ +/// text([], "X") +/// ]), +/// text([], "Y") +/// ]) +/// // -> X Y +/// ``` +/// ```gleam +/// box([width(6)], [ +/// box([flex_basis(Percent(50))], [ +/// text([], "X") +/// ]), +/// text([], "Y") +/// ]) +/// // -> X Y +/// ``` +pub fn flex_basis(basis: Dimension) -> Attribute { + Attribute("flexBasis", dimension_encoder(basis)) +} + +/// Establishes the main-axis, thus defining the direction flex items are placed in the flex container +/// For more information, see [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/) +/// +/// ## Examples +/// ```gleam +/// box([], [ +/// box([margin_right(1)], [ +/// text([], "X") +/// ]), +/// text([], "Y") +/// ]) +/// // -> X Y +/// ``` +/// ```gleam +/// box([flex_direction(FlexRowReverse)], [ +/// text([], "X"), +/// box([margin_right(1)], [ +/// text([], "Y") +/// ]) +/// ]) +/// // -> Y X +/// ``` +/// ```gleam +/// box([flex_direction(FlexColumn)], [ +/// text([], "X"), +/// text([], "Y") +/// ]) +/// // -> X +/// // -> Y +/// ``` +/// ```gleam +/// box([flex_direction(FlexColumnReverse)], [ +/// text([], "X"), +/// text([], "Y") +/// ]) +/// // -> Y +/// // -> X +/// ``` +pub fn flex_direction(direction: FlexDirection) -> Attribute { + Attribute( + "flexDirection", + json.string(case direction { + FlexRow -> "row" + FlexColumn -> "column" + FlexRowReverse -> "row-reverse" + FlexColumnReverse -> "column-reverse" + }), + ) +} + +/// Defines whether the flex items are forced in a single line or can be flowed into multiple lines. +/// If set to multiple lines, it also defines the cross-axis which determines the direction new lines are stacked in, aiding responsiveness layout behavior without CSS media queries +/// For more information, see [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/) +/// +/// ## Examples +/// ```gleam +/// box([width(2), flex_wrap(FlexWrap)], [ +/// text([], "A"), +/// text([], "BC") +/// ]) +/// // -> A +/// // -> BC +/// ``` +/// ```gleam +/// box([flex_direction(FlexColumn), height(2), flex_wrap(FlexWrap)], [ +/// text([], "A"), +/// text([], "B"), +/// text([], "C") +/// ]) +/// // -> AC +/// // -> B +/// ``` +pub fn flex_wrap(wrap: FlexWrap) -> Attribute { + Attribute( + "flexWrap", + json.string(case wrap { + FlexWrap -> "wrap" + FlexNoWrap -> "nowrap" + FlexWrapReverse -> "wrap-reverse" + }), + ) +} + +/// Effects how elements are aligned both in Flexbox and Grid layouts +/// For more information, see [align-items](https://css-tricks.com/almanac/properties/a/align-items/) +/// +/// ## Examples +/// ```gleam +/// box([align_items(ItemsStart)], [ +/// box([margin_right(1)], [text([], "X")]), +/// text_nested([], [ +/// text([], "A"), +/// newline([]), +/// text([], "B"), +/// newline([]), +/// text([], "C"), +/// ]), +/// ]) +/// // -> X A +/// // -> B +/// // -> C +/// ``` +/// ```gleam +/// box([align_items(ItemsCenter)], [ +/// box([margin_right(1)], [text([], "X")]), +/// text_nested([], [ +/// text([], "A"), +/// newline([]), +/// text([], "B"), +/// newline([]), +/// text([], "C"), +/// ]), +/// ]) +/// // -> A +/// // -> X B +/// // -> C +/// ``` +/// ```gleam +/// box([align_items(ItemsEnd)], [ +/// box([margin_right(1)], [text([], "X")]), +/// text_nested([], [ +/// text([], "A"), +/// newline([]), +/// text([], "B"), +/// newline([]), +/// text([], "C"), +/// ]), +/// ]) +/// // -> A +/// // -> B +/// // -> X C +/// ``` +pub fn align_items(align: AlignItems) -> Attribute { + Attribute( + "alignItems", + json.string(case align { + ItemsStart -> "flex-start" + ItemsCenter -> "center" + ItemsEnd -> "flex-end" + }), + ) +} + +/// Makes possible to override the align-items value for specific flex items +/// For more information, see [align-self](https://css-tricks.com/almanac/properties/a/align-self/) +/// +/// ## Examples +/// ```gleam +/// box([height(Spaces(3))], [ +/// box([align_self(SelfStart)], [text([], "X")]), +/// ]) +/// // -> X +/// // -> +/// // -> +/// ``` +/// ```gleam +/// box([height(Spaces(3))], [ +/// box([align_self(SelfCenter)], [text([], "X")]), +/// ]) +/// // -> +/// // -> X +/// // -> +/// ``` +/// ```gleam +/// box([height(Spaces(3))], [ +/// box([align_self(SelfEnd)], [text([], "X")]), +/// ]) +/// // -> +/// // -> +/// // -> X +/// ``` +pub fn align_self(align: AlignSelf) -> Attribute { + Attribute( + "alignSelf", + json.string(case align { + SelfAuto -> "auto" + SelfStart -> "flex-start" + SelfCenter -> "center" + SelfEnd -> "flex-end" + }), + ) +} + +/// Defines the alignment along the main axis. +/// It helps distribute extra free space leftover when either all the flex items on a line are inflexible, or are flexible but have reached their maximum size. +/// It also exerts some control over the alignment of items when they overflow the line +/// For more information, see [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/) +/// +/// ## Examples +/// ```gleam +/// box([justify_content(ContentStart)], [ +/// text([], "X") +/// ]) +/// // -> "X " +/// ``` +/// ```gleam +/// box([justify_content(ContentCenter)], [ +/// text([], "X") +/// ]) +/// // -> " X " +/// ``` +/// ```gleam +/// box([justify_content(ContentEnd)], [ +/// text([], "X") +/// ]) +/// // -> " X" +/// ``` +/// ```gleam +/// box([justify_content(ContentSpaceBetween)], [ +/// text([], "X"), +/// text([], "Y") +/// ]) +/// // -> "X Y" +/// ``` +/// ```gleam +/// box([justify_content(ContentSpaceAround)], [ +/// text([], "X"), +/// text([], "Y") +/// ]) +/// // -> " X Y " +/// ``` +pub fn justify_content(justify: JustifyContent) -> Attribute { + Attribute( + "justifyContent", + json.string(case justify { + ContentStart -> "flex-start" + ContentCenter -> "center" + ContentEnd -> "flex-end" + ContentSpaceBetween -> "space-between" + ContentSpaceAround -> "space-around" + }), + ) +} + +/// Set this property to `DisplayNone` to hide the element +pub fn display(display: Display) -> Attribute { + Attribute( + "display", + json.string(case display { + DisplayFlex -> "flex" + DisplayNone -> "none" + }), + ) +} + +/// Add a border with a specified style. +/// If `border_style` is not set, no border will be added. +/// Ink uses border styles from [cli-boxes](https://github.com/sindresorhus/cli-boxes) module. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderSingle)], [text([], "single")]) +/// // -> ┌──────┐ +/// // -> │single│ +/// // -> └──────┘ +/// ``` +/// ```gleam +/// box([border_style(BorderDouble)], [text([], "double")]) +/// // -> ╔══════╗ +/// // -> ║double║ +/// // -> ╚══════╝ +/// ``` +/// ```gleam +/// box([border_style(BorderRound)], [text([], "round")]) +/// // -> ╭─────╮ +/// // -> │round│ +/// // -> ╰─────╯ +/// ``` +/// ```gleam +/// box([border_style(BorderBold)], [text([], "bold")]) +/// // -> ┏━━━━┓ +/// // -> ┃bold┃ +/// // -> ┗━━━━┛ +/// ``` +/// ```gleam +/// box([border_style(BorderSingleDouble)], [text([], "single double")]) +/// // -> ╓─────────────╖ +/// // -> ║single double║ +/// // -> ╙─────────────╜ +/// ``` +/// ```gleam +/// box([border_style(BorderDoubleSingle)], [text([], "double single")]) +/// // -> ╒═════════════╕ +/// // -> │double single│ +/// // -> ╘═════════════╛ +/// ``` +/// ```gleam +/// box([border_style(BorderClassic)], [text([], "classic")]) +/// // -> +-------+ +/// // -> |classic| +/// // -> +-------+ +/// ``` +pub fn border_style(style: BorderStyle) -> Attribute { + Attribute("borderStyle", case style { + BorderSingle -> json.string("single") + BorderDouble -> json.string("double") + BorderRound -> json.string("round") + BorderBold -> json.string("bold") + BorderSingleDouble -> json.string("singleDouble") + BorderDoubleSingle -> json.string("doubleSingle") + BorderClassic -> json.string("classic") + BorderCustom( + top_left, + top, + top_right, + left, + bottom_left, + bottom, + bottom_right, + right, + ) -> { + json.object([ + #("topLeft", json.string(top_left)), + #("top", json.string(top)), + #("topRight", json.string(top_right)), + #("left", json.string(left)), + #("bottomLeft", json.string(bottom_left)), + #("bottom", json.string(bottom)), + #("bottomRight", json.string(bottom_right)), + #("right", json.string(right)), + ]) + } + }) +} + +/// Change border color. +/// Shorthand for setting `border_top_color`, `border_right_color`, `border_bottom_color` and `border_left_color`. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_color("green")], [ +/// text([], "Green Rounded Box") +/// ]) +/// ``` +pub fn border_color(color: String) -> Attribute { + Attribute("borderColor", json.string(color)) +} + +/// Change top border color. Accepts the same values as `color` in `text` function +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_top_color("green")], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_top_color(color: String) -> Attribute { + Attribute("borderTopColor", json.string(color)) +} + +/// Change right border color. Accepts the same values as `color` in `text` function +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_right_color("green")], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_right_color(color: String) -> Attribute { + Attribute("borderRightColor", json.string(color)) +} + +/// Change bottom border color. Accepts the same values as `color` in `text` function +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_bottom_color("green")], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_bottom_color(color: String) -> Attribute { + Attribute("borderBottomColor", json.string(color)) +} + +/// Change left border color. Accepts the same values as `color` in `text` function +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_left_color("green")], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_left_color(color: String) -> Attribute { + Attribute("borderLeftColor", json.string(color)) +} + +/// Dim the border color. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_dim_color(True)], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_dim_color(has_color: Bool) -> Attribute { + Attribute("borderDimColor", json.bool(has_color)) +} + +/// Dim the top border color. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_top_dim_color(True)], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_top_dim_color(has_color: Bool) -> Attribute { + Attribute("borderTopDimColor", json.bool(has_color)) +} + +/// Dim the bottom border color. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_bottom_dim_color(True)], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_bottom_dim_color(has_color: Bool) -> Attribute { + Attribute("borderBottomDimColor", json.bool(has_color)) +} + +/// Dim the left border color. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_left_dim_color(True)], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_left_dim_color(has_color: Bool) -> Attribute { + Attribute("borderLeftDimColor", json.bool(has_color)) +} + +/// Dim the right border color. +/// +/// ## Examples +/// ```gleam +/// box([border_style(BorderRound), border_right_dim_color(True)], [ +/// text([], "Hello world") +/// ]) +/// ``` +pub fn border_right_dim_color(has_color: Bool) -> Attribute { + Attribute("borderRightDimColor", json.bool(has_color)) +} + +/// Determines whether top border is visible +pub fn border_top(has_border: Bool) -> Attribute { + Attribute("borderTop", json.bool(has_border)) +} + +/// Determines whether right border is visible +pub fn border_right(has_border: Bool) -> Attribute { + Attribute("borderRight", json.bool(has_border)) +} + +/// Determines whether bottom border is visible +pub fn border_bottom(has_border: Bool) -> Attribute { + Attribute("borderBottom", json.bool(has_border)) +} + +/// Determines whether left border is visible +pub fn border_left(has_border: Bool) -> Attribute { + Attribute("borderLeft", json.bool(has_border)) +} + +/// Number of newlines to insert +pub fn count(count: Int) -> Attribute { + Attribute("count", json.int(count)) +} + +/// Width of the element. +/// You can set it in `Spaces` or in `Percent`, which will calculate the width based on the width of parent element. +/// ## Examples +/// ```gleam +/// box([width(Spaces(4))], [text([], "X")]) +/// // -> X +/// ``` +/// ```gleam +/// box([width(Spaces(10))], [ +/// box([width(Percent(50))], [text([], "X")]), +/// text([], "Y") +/// ]) +/// // -> X Y +/// ``` +pub fn width(width: Dimension) -> Attribute { + Attribute("width", dimension_encoder(width)) +} + +/// Height of the element. +/// You can set it in lines (rows) or in percent, which will calculate the height based on the height of parent element +/// ## Examples +/// ```gleam +/// box([height(Spaces(4))], [text([], "X")]) +/// // -> X\n\n\n +/// ``` +/// ```gleam +/// box([height(Spaces(6)), flex_direction(FlexColumn)], [ +/// box([width(Percent(50))], [text([], "X")]), +/// text([], "Y") +/// ]) +/// // -> X\n\n\nY\n\n\n +/// ``` +pub fn height(height: Dimension) -> Attribute { + Attribute("height", dimension_encoder(height)) +} + +/// Behavior for an element's overflow in horizontal direction. +pub fn overflow_x(overflow: Visibility) -> Attribute { + Attribute("overflowX", visibility_encoder(overflow)) +} + +/// Behavior for an element's overflow in vertical direction. +pub fn overflow_y(overflow: Visibility) -> Attribute { + Attribute("overflowY", visibility_encoder(overflow)) +} + +/// Shortcut for setting `overflow_x` and `overflow_y` at the same time +pub fn overflow(overflow: Visibility) -> Attribute { + Attribute("overflow", visibility_encoder(overflow)) +} + +pub fn encode(attributes: List(Attribute)) -> Json { + attributes + |> list.map(fn(attribute) { #(attribute.key, attribute.value) }) + |> json.object +} + +fn dimension_encoder(dimension: Dimension) -> Json { + case dimension { + Spaces(spaces) -> json.int(spaces) + Percent(percent) -> + percent + |> int.to_string + |> string.append("%") + |> json.string + } +} + +fn visibility_encoder(visibility: Visibility) -> Json { + case visibility { + Visible -> "visible" + Hidden -> "hidden" + } + |> json.string +} diff --git a/src/pink/focus.gleam b/src/pink/focus.gleam new file mode 100644 index 0000000..0b4a039 --- /dev/null +++ b/src/pink/focus.gleam @@ -0,0 +1,48 @@ +/// Module for managing focus state. +import gleam/json.{type Json} +import gleam/option.{type Option, None, Some} + +/// `Focus` is the type returned by the `focus` function +pub type Focus { + Focus(is_focused: Bool) +} + +/// `FocusOptions` is the type used by the `focus` function to configure the focus state. +pub opaque type FocusOptions { + FocusOptions(auto_focus: Bool, is_active: Bool, id: Option(String)) +} + +/// Create a new `FocusOptions` with the default values. +pub fn options() -> FocusOptions { + FocusOptions(auto_focus: False, is_active: True, id: None) +} + +/// Set the `auto_focus` field on the `FocusOptions`. +/// Auto focus this component, if there's no active (focused) component right now +pub fn set_auto_focus(options: FocusOptions, auto_focus: Bool) { + FocusOptions(..options, auto_focus: auto_focus) +} + +/// Set the `is_active` field on the `FocusOptions`. +/// Enable or disable this component's focus, while still maintaining its position in the list of focusable components. This is useful for inputs that are temporarily disabled +pub fn set_is_active(options: FocusOptions, is_active: Bool) { + FocusOptions(..options, is_active: is_active) +} + +/// Set the `id` field on the `FocusOptions`. +/// Set a component's focus ID, which can be used to programmatically focus the component. This is useful for large interfaces with many focusable elements, to avoid having to cycle through all of them +pub fn set_id(options: FocusOptions, id: String) { + FocusOptions(..options, id: Some(id)) +} + +@internal +pub fn encode_options(options: FocusOptions) -> Json { + json.object([ + #("autoFocus", json.bool(options.auto_focus)), + #("isActive", json.bool(options.is_active)), + #("id", case options.id { + Some(id) -> json.string(id) + None -> json.null() + }), + ]) +} diff --git a/src/pink/hook.gleam b/src/pink/hook.gleam new file mode 100644 index 0000000..9883eef --- /dev/null +++ b/src/pink/hook.gleam @@ -0,0 +1,185 @@ +/// This module contains hooks that are used to interact with the terminal and handle user input. +import gleam/dynamic.{type Dynamic} +import gleam/io +import gleam/json.{type Json} +import gleam/result +import gleam/string +import pink/focus.{type Focus, type FocusOptions} +import pink/key.{type Key} + +/// `App` is a record that is returned by the `app` hook. +pub type App { + App(exit: fn() -> Nil) +} + +/// `Stdin` is a record that is returned by the `stdin` hook. +pub type Stdin { + Stdin( + stdin: Dynamic, + is_raw_mode_supported: Bool, + set_raw_mode: fn(Bool) -> Nil, + ) +} + +/// `Stdout` is a record that is returned by the `stdout` hook. +pub type Stdout { + Stdout(stdout: Dynamic, write: fn(String) -> Nil) +} + +/// `Stderr` is a record that is returned by the `stderr` hook. +pub type Stderr { + Stderr(stderr: Dynamic, write: fn(String) -> Nil) +} + +/// `State` is a record that is returned by the `state` hook. +pub type State(a) { + State(value: a, set: fn(a) -> Nil, set_with: fn(fn(a) -> a) -> Nil) +} + +/// `FocusManager` is a record that is returned by the `focus_manager` hook. +pub type FocusManager { + FocusManager( + enable_focus: fn() -> Nil, + disable_focus: fn() -> Nil, + focus_next: fn() -> Nil, + focus_previous: fn() -> Nil, + focus: fn(String) -> Nil, + ) +} + +/// `app` is a hook which exposes a function to manually exit the app (unmount). +@external(javascript, "../ink_ffi.mjs", "useApp") +pub fn app() -> App + +/// `stdin` is a hook which exposes stdin stream +/// +/// The `Stdin` record contains the following fields: +/// - `stdin` - process.stdin. Useful if your app needs to handle user input +/// - `is_raw_mode_supported` - A boolean flag determining if the current stdin supports `set_raw_mode`. A component using `set_raw_mode` might want to use `is_raw_mode_supported` to nicely fall back in environments where raw mode is not supported +/// - `set_raw_mode` - See [setRawMode](https://nodejs.org/api/tty.html#tty_readstream_setrawmode_mode). Ink exposes this function to be able to handle Ctrl+C, that's why you should use Ink's `set_raw_mode` instead of `process.stdin.setRawMode` +@external(javascript, "../ink_ffi.mjs", "useStdin_") +pub fn stdin() -> Stdin + +/// `stdout` is a hook which exposes stdout stream, where Ink renders your app. +/// +/// The `Stdout` record contains the following fields: +/// - `stdout` - process.stdout +/// - `write` - Write any string to stdout, while preserving Ink's output. It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to `static`, except it can't accept components, it only works with strings +@external(javascript, "../ink_ffi.mjs", "useStdout") +pub fn stdout() -> Stdout + +/// `stderr` is a hook which exposes stderr stream. +/// +/// The `Stderr` record contains the following fields: +/// - `stderr` - process.stderr +/// - `write` - Write any string to stderr, while preserving Ink's output. It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to `static`, except it can't accept components, it only works with strings +@external(javascript, "../ink_ffi.mjs", "useStderr") +pub fn stderr() -> Stderr + +@external(javascript, "../ink_ffi.mjs", "useInput") +fn use_input( + callback callback: fn(String, Json) -> Nil, + options options: Json, +) -> Nil + +@external(javascript, "../ink_ffi.mjs", "useFocus_") +fn use_focus(options options: Json) -> Focus + +/// This hook exposes methods to enable or disable focus management for all components or manually switch focus to next or previous components. +/// +/// The `FocusManager` record contains the following fields: +/// - `enable_focus` - Enable focus management for all components +/// Note: You don't need to call this method manually, unless you've disabled focus management. Focus management is enabled by default +/// - `disable_focus` - Disable focus management for all components. Currently active component (if there's one) will lose its focus. +/// - `focus_next` - Switch focus to the next focusable component. If there's no active component right now, focus will be given to the first focusable component. If active component is the last in the list of focusable components, focus will be switched to the first active component. +/// Note: Ink calls this method when user presses Tab. +/// - `focus_previous` - Switch focus to the previous focusable component. If there's no active component right now, focus will be given to the last focusable component. If active component is the first in the list of focusable components, focus will be switched to the last active component. +/// Note: Ink calls this method when user presses Shift+Tab. +/// - `focus` - Switch focus to the component with the specified id. If there's no component with that ID, focus will be given to the next focusable component. +@external(javascript, "../ink_ffi.mjs", "useFocusManager_") +pub fn focus_manager() -> FocusManager + +@external(javascript, "../react_ffi.mjs", "useState") +fn use_state(initial initial: a) -> #(a, fn(fn(a) -> a) -> Nil) + +/// `effect` is a hook which allows you to perform side effects in function components. +/// This is a React hook and not a Ink hook. +/// Read more about it on [react.dev docs](https://react.dev/reference/react/useEffect) +/// +/// This hook is here for convenience, so you don't have to create the bindings yourself +/// +/// If you want to run a cleanup function when the component unmounts, use `effect_clean` instead +@external(javascript, "../react_ffi.mjs", "useEffect") +pub fn effect(callback: fn() -> Nil, dependencies: List(a)) -> Nil + +/// This is the same as `effect`, but it also allows you to run a cleanup function when the component unmounts +@external(javascript, "../react_ffi.mjs", "useEffect") +pub fn effect_clean(callback: fn() -> fn() -> Nil, dependencies: List(a)) -> Nil + +/// This hook is used for handling user input. +/// It's a more convenient alternative to using `stdin` and listening to data events. +/// The callback you pass to `input` is called for each character when user enters any input. +/// However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as input. +/// +/// The `is_active` option is used to enable or disable capturing of user input. +/// Useful when there are multiple `input` hooks used at once to avoid handling the same input several times +/// +/// ## Examples +/// ```gleam +/// component(fn() { +/// hook.input(fn(input, keys) { +/// case input, keys { +/// "q", _ -> // Exit program +/// _, [LeftArrow] -> // Left arrow key pressed +/// } +/// }, True) +/// }) +/// ``` +pub fn input(callback: fn(String, List(Key)) -> Nil, is_active: Bool) { + use_input( + fn(input: String, json_key: Json) { + let key = + json_key + |> json.to_string + |> json.decode(key.list_decoder()) + |> result.map_error(fn(decode_error) { + io.debug("Failed to decode key" <> string.inspect(decode_error)) + decode_error + }) + |> result.unwrap([]) + + callback(input, key) + }, + json.object([#("isActive", json.bool(is_active))]), + ) +} + +/// Component that uses useFocus hook becomes "focusable" to Ink, so when user presses Tab, Ink will switch focus to this component. +/// If there are multiple components that execute useFocus hook, focus will be given to them in the order that these components are rendered in. +/// +/// This hook returns a record with `is_focused` boolean field, which determines if this component is focused or not +pub fn focus(options: FocusOptions) { + options + |> focus.encode_options + |> use_focus +} + +/// `state` is a hook which allows you to create a stateful value. +/// This is a React hook and not a Ink hook. +/// Read more about it on [react.dev docs](https://react.dev/reference/react/useState) +/// +/// This hook is here for convenience, so you don't have to create the bindings yourself +/// +/// `state` returns a record with the following fields: +/// - `value` - The current state value +/// - `set` - A function to update the state value. +/// - `set_with` - A function to update the state value. It accepts a function which receives the current state value and returns a new state value. +pub fn state(initial: a) -> State(a) { + let #(state, set_state) = use_state(initial) + + State( + value: state, + set: fn(a) { set_state(fn(_) { a }) }, + set_with: set_state, + ) +} diff --git a/src/pink/key.gleam b/src/pink/key.gleam new file mode 100644 index 0000000..409f82f --- /dev/null +++ b/src/pink/key.gleam @@ -0,0 +1,94 @@ +/// A module for decoding keyboard keys from JSON. +import gleam/dynamic +import gleam/function +import gleam/list +import gleam/result + +/// A key on the keyboard. +pub type Key { + UpArrow + DownArrow + LeftArrow + RightArrow + PageDown + PageUp + Return + Escape + Ctrl + Shift + Tab + Backspace + Delete + Meta +} + +fn decoder(key) { + fn(json) { + json + |> dynamic.bool + |> result.map(fn(value) { + case value { + True -> Ok(key) + False -> Error(Nil) + } + }) + } +} + +@internal +pub fn list_decoder() { + fn(json_string) { + dynamic.decode9( + fn( + up_arrow, + down_arrow, + left_arrow, + right_arrow, + page_down, + page_up, + return, + escape, + ctrl, + ) { + dynamic.decode5( + fn(shift, tab, backspace, delete, meta) { + list.filter_map( + [ + up_arrow, + down_arrow, + left_arrow, + right_arrow, + page_down, + page_up, + return, + escape, + ctrl, + shift, + tab, + backspace, + delete, + meta, + ], + function.identity, + ) + }, + dynamic.field("shift", decoder(Shift)), + dynamic.field("tab", decoder(Tab)), + dynamic.field("backspace", decoder(Backspace)), + dynamic.field("delete", decoder(Delete)), + dynamic.field("meta", decoder(Meta)), + )(json_string) + }, + dynamic.field("upArrow", decoder(UpArrow)), + dynamic.field("downArrow", decoder(DownArrow)), + dynamic.field("leftArrow", decoder(LeftArrow)), + dynamic.field("rightArrow", decoder(RightArrow)), + dynamic.field("pageDown", decoder(PageDown)), + dynamic.field("pageUp", decoder(PageUp)), + dynamic.field("return", decoder(Return)), + dynamic.field("escape", decoder(Escape)), + dynamic.field("ctrl", decoder(Ctrl)), + )(json_string) + |> result.flatten + } +} diff --git a/src/react_ffi.mjs b/src/react_ffi.mjs new file mode 100644 index 0000000..108dc3d --- /dev/null +++ b/src/react_ffi.mjs @@ -0,0 +1,15 @@ +import { createElement, Fragment, useEffect as reactEffect } from "react" + +export { useState, } from "react" + +export const useEffect = (effect, deps) => + reactEffect(effect, deps.toArray()) + + +export const component = (element) => createElement(element) + +export const fragment = (props, children) => { + const parsedChildren = typeof children === "object" ? children.toArray() : [children] + return createElement(Fragment, props, ...parsedChildren) +} + diff --git a/test/ink_test_ffi.mjs b/test/ink_test_ffi.mjs new file mode 100644 index 0000000..d35b682 --- /dev/null +++ b/test/ink_test_ffi.mjs @@ -0,0 +1,14 @@ +import { render as inkRender } from "ink-testing-library" + +export const render = (component) => { + const result = inkRender(component) + return { + ...result, + last_frame: result.lastFrame + } +} + +export const setTimeout = (callback, ms) => + globalThis.setTimeout(callback, ms) + + diff --git a/test/pink_test.gleam b/test/pink_test.gleam new file mode 100644 index 0000000..cd25056 --- /dev/null +++ b/test/pink_test.gleam @@ -0,0 +1,390 @@ +import birdie +import gleam/int +import gleam/javascript/promise +import gleam/list +import gleam/string +import gleeunit +import pink.{type ReactNode} +import pink/attribute +import pink/focus +import pink/hook + +pub type Timer + +pub type Render { + Render(last_frame: fn() -> String, frames: List(String)) +} + +@external(javascript, "./ink_test_ffi.mjs", "render") +pub fn render(component: ReactNode) -> Render + +@external(javascript, "./ink_test_ffi.mjs", "setTimeout") +pub fn set_timeout(callback: fn() -> Nil, timeout: Int) -> Timer + +pub fn main() { + gleeunit.main() +} + +pub fn text_test() { + let data = + pink.fragment([], [ + pink.text([attribute.color("green")], "I am green"), + pink.text( + [attribute.color("black"), attribute.background_color("white")], + "I am black on white", + ), + pink.text([attribute.color("#ffffff")], "I am white"), + pink.text([attribute.bold(True)], "I am bold"), + pink.text([attribute.italic(True)], "I am italic"), + pink.text([attribute.underline(True)], "I am underline"), + pink.text([attribute.strikethrough(True)], "I am strikethrough"), + pink.text([attribute.inverse(True)], "I am inversed"), + ]) + |> render + + data.last_frame() + |> birdie.snap("text") +} + +pub fn box_test() { + let data = + pink.box([attribute.flex_direction(attribute.FlexColumn)], [ + pink.box([attribute.margin(2)], [ + pink.text([], "This is a box with margin 2"), + ]), + pink.box([attribute.width(attribute.Spaces(4))], [ + pink.text([], "width 4"), + ]), + pink.box([attribute.height(attribute.Spaces(4))], [ + pink.text([], "height 4"), + ]), + pink.box([], [ + pink.box( + [ + attribute.border_style(attribute.BorderSingle), + attribute.margin_right(2), + ], + [pink.text([], "single")], + ), + pink.box( + [ + attribute.border_style(attribute.BorderDouble), + attribute.margin_right(2), + ], + [pink.text([], "double")], + ), + pink.box( + [ + attribute.border_style(attribute.BorderRound), + attribute.margin_right(2), + ], + [pink.text([], "round")], + ), + pink.box([attribute.border_style(attribute.BorderBold)], [ + pink.text([], "bold"), + ]), + ]), + pink.box([], [ + pink.box( + [ + attribute.border_style(attribute.BorderSingleDouble), + attribute.margin_right(2), + ], + [pink.text([], "single double")], + ), + pink.box( + [ + attribute.border_style(attribute.BorderDoubleSingle), + attribute.margin_right(2), + ], + [pink.text([], "double single")], + ), + pink.box([attribute.border_style(attribute.BorderClassic)], [ + pink.text([], "classic"), + ]), + ]), + ]) + |> render + + data.last_frame() + |> birdie.snap("box") +} + +pub fn newline_test() { + let data = + pink.text_nested([], [ + pink.text([attribute.color("green")], "Hello"), + pink.newline([attribute.count(2)]), + pink.text([attribute.color("red")], "World"), + ]) + |> render + + data.last_frame() + |> birdie.snap("newline") +} + +pub fn spacer_test() { + let data = + pink.box( + [attribute.flex_direction(attribute.FlexColumn), attribute.gap(1)], + [ + pink.box([], [ + pink.text([], "Left"), + pink.spacer(), + pink.text([], "Right"), + ]), + pink.box( + [ + attribute.flex_direction(attribute.FlexColumn), + attribute.height(attribute.Spaces(10)), + ], + [pink.text([], "Top"), pink.spacer(), pink.text([], "Bottom")], + ), + ], + ) + |> render + + data.last_frame() + |> birdie.snap("spacer") +} + +pub fn static_test() { + promise.new(fn(resolve) { + let data = + pink.component(fn() { + let tests = hook.state([]) + + let run = fn() { + tests.set_with(fn(previous_tests) { + list.append(previous_tests, [ + "Test " <> int.to_string(list.length(previous_tests) + 1), + ]) + }) + } + + hook.effect( + fn() { + list.range(1, 10) + |> list.each(fn(n) { set_timeout(run, n * 100) }) + }, + [], + ) + + pink.fragment([], [ + pink.static(for: tests.value, using: fn(test_, _index) { + pink.box([attribute.key(test_)], [ + pink.text([attribute.color("green")], " " <> test_), + ]) + }), + pink.box([attribute.margin_top(1)], [ + pink.text( + [attribute.dim_color("")], + "Completed tests: " <> int.to_string(list.length(tests.value)), + ), + ]), + ]) + }) + |> render + + set_timeout(fn() { resolve(data) }, 1100) + + Nil + }) + |> promise.await(fn(data) { + data.last_frame() + |> birdie.snap("static") + |> promise.resolve + }) +} + +pub fn transform_test() { + let data = + pink.transform(apply: fn(output, _index) { string.uppercase(output) }, on: [ + pink.text([], "Hello, world"), + ]) + |> render + + data.last_frame() + |> birdie.snap("transform") +} + +pub fn spinner_test() { + let data = + pink.spinner("dots") + |> render + + data.last_frame() + |> birdie.snap("spinner") +} + +pub fn use_state_test() { + promise.new(fn(resolve) { + let data = + pink.component(fn() { + let message = hook.state("Hello, world") + + hook.effect( + fn() { + message.set("Changed") + set_timeout( + fn() { message.set_with(fn(previous) { previous <> " 2" }) }, + 0, + ) + Nil + }, + [], + ) + + pink.text([], message.value) + }) + |> render + set_timeout(fn() { resolve(data) }, 10) + + Nil + }) + |> promise.await(fn(data) { + data.frames + |> string.join("\n---\n") + |> birdie.snap("hook state") + |> promise.resolve + }) +} + +pub fn use_input_test() { + let data = + pink.component(fn() { + hook.input(fn(_input, _keys) { Nil }, True) + + pink.text([], "Type something") + }) + |> render + + data.last_frame() + |> birdie.snap("hook input") +} + +pub fn use_app_test() { + let data = + pink.component(fn() { + let app = hook.app() + + hook.effect(fn() { app.exit() }, []) + + pink.text([], "Hello, world") + }) + |> render + + data.last_frame() + |> birdie.snap("hook app") +} + +pub fn use_stdin_test() { + let data = + pink.component(fn() { + let stdin = hook.stdin() + + hook.effect( + fn() { + case stdin.is_raw_mode_supported { + True -> stdin.set_raw_mode(True) + False -> Nil + } + }, + [], + ) + + pink.text( + [], + "is_raw_mode_supported: " <> string.inspect(stdin.is_raw_mode_supported), + ) + }) + |> render + + data.last_frame() + |> birdie.snap("hook stdin") +} + +pub fn use_stdout_test() { + promise.new(fn(resolve) { + let data = + pink.component(fn() { + let stdout = hook.stdout() + + hook.effect(fn() { stdout.write("Hello from stdout") }, []) + + pink.text([], "Hello from text") + }) + |> render + + set_timeout(fn() { resolve(data) }, 100) + + Nil + }) + |> promise.await(fn(data) { + data.last_frame() + |> birdie.snap("hook stdout") + |> promise.resolve + }) +} + +pub fn use_focus_test() { + let data = + pink.component(fn() { + let focus = + hook.focus( + focus.options() + |> focus.set_auto_focus(True), + ) + + pink.text([], case focus.is_focused { + True -> "I am focused" + False -> "I am not focused" + }) + }) + |> render + + data.last_frame() + |> birdie.snap("hook focus") +} + +pub fn use_manager_test() { + let focusable_component = + pink.component(fn() { + let focus = hook.focus(focus.options()) + pink.text([], case focus.is_focused { + True -> "I am focused" + False -> "I am not focused" + }) + }) + promise.new(fn(resolve) { + let data = + pink.component(fn() { + let manager = hook.focus_manager() + hook.effect( + fn() { + manager.disable_focus() + manager.enable_focus() + manager.focus_next() + set_timeout(fn() { manager.focus_previous() }, 0) + Nil + }, + [], + ) + pink.box([attribute.flex_direction(attribute.FlexColumn)], [ + focusable_component, + focusable_component, + ]) + }) + |> render + + set_timeout(fn() { resolve(data) }, 10) + + Nil + }) + |> promise.await(fn(data) { + data.frames + |> string.join("\n---\n") + |> birdie.snap("hook focus_manager") + |> promise.resolve + }) +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..464a6a9 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,298 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alcalzone/ansi-tokenize@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz#9f89839561325a8e9a0c32360b8d17e48489993f" + integrity sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^4.0.0" + +ansi-escapes@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.0.0.tgz#00fc19f491bbb18e1d481b97868204f92109bfe7" + integrity sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw== + dependencies: + environment "^1.0.0" + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^6.0.0, ansi-styles@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +auto-bind@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-5.0.1.tgz#50d8e63ea5a1dddcb5e5e36451c1a8266ffbb2ae" + integrity sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== + +chalk@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + +cli-boxes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" + integrity sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g== + +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + +cli-spinners@^2.7.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +cli-truncate@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-4.0.0.tgz#6cc28a2924fee9e25ce91e973db56c7066e6172a" + integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== + dependencies: + slice-ansi "^5.0.0" + string-width "^7.0.0" + +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-4.0.0.tgz#2de7d46e98514385cb01f7b3b741320115f4c95e" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== + dependencies: + convert-to-spaces "^2.0.1" + +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz#61a6c98f8aa626c16b296b862a91412a33bceb6b" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== + +emoji-regex@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23" + integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw== + +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +get-east-asian-width@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz#5e6ebd9baee6fb8b7b6bd505221065f0cd91f64e" + integrity sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA== + +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== + +ink-spinner@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ink-spinner/-/ink-spinner-5.0.0.tgz#32ec318ef8ebb0ace8f595451f8e93280623429f" + integrity sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA== + dependencies: + cli-spinners "^2.7.0" + +ink-testing-library@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ink-testing-library/-/ink-testing-library-4.0.0.tgz#7071dac9a3d783e7bab2ee05fdf1d01a2cd3bb0d" + integrity sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q== + +ink@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ink/-/ink-5.0.1.tgz#f2ef9796a3911830c3995dedd227ec84ae27de4b" + integrity sha512-ae4AW/t8jlkj/6Ou21H2av0wxTk8vrGzXv+v2v7j4in+bl1M5XRMVbfNghzhBokV++FjF8RBDJvYo+ttR9YVRg== + dependencies: + "@alcalzone/ansi-tokenize" "^0.1.3" + ansi-escapes "^7.0.0" + ansi-styles "^6.2.1" + auto-bind "^5.0.1" + chalk "^5.3.0" + cli-boxes "^3.0.0" + cli-cursor "^4.0.0" + cli-truncate "^4.0.0" + code-excerpt "^4.0.0" + indent-string "^5.0.0" + is-in-ci "^0.1.0" + lodash "^4.17.21" + patch-console "^2.0.0" + react-reconciler "^0.29.0" + scheduler "^0.23.0" + signal-exit "^3.0.7" + slice-ansi "^7.1.0" + stack-utils "^2.0.6" + string-width "^7.0.0" + type-fest "^4.8.3" + widest-line "^5.0.0" + wrap-ansi "^9.0.0" + ws "^8.15.0" + yoga-wasm-web "~0.3.3" + +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + +is-fullwidth-code-point@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz#9609efced7c2f97da7b60145ef481c787c7ba704" + integrity sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA== + dependencies: + get-east-asian-width "^1.0.0" + +is-in-ci@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-0.1.0.tgz#5e07d6a02ec3a8292d3f590973357efa3fceb0d3" + integrity sha512-d9PXLEY0v1iJ64xLiQMJ51J128EYHAaOR4yZqQi8aHGfw6KgifM3/Viw1oZZ1GCVmb3gBuyhLyHj0HgR2DhSXQ== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +patch-console@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-2.0.0.tgz#9023f4665840e66f40e9ce774f904a63167433bb" + integrity sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== + +react-reconciler@^0.29.0: + version "0.29.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.29.2.tgz#8ecfafca63549a4f4f3e4c1e049dd5ad9ac3a54f" + integrity sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +scheduler@^0.23.0, scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +signal-exit@^3.0.2, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + +slice-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.0.tgz#cd6b4655e298a8d1bdeb04250a433094b347b9a9" + integrity sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^5.0.0" + +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +string-width@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.1.0.tgz#d994252935224729ea3719c49f7206dc9c46550a" + integrity sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + +strip-ansi@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +type-fest@^4.8.3: + version "4.20.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.20.1.tgz#d97bb1e923bf524e5b4b43421d586760fb2ee8be" + integrity sha512-R6wDsVsoS9xYOpy8vgeBlqpdOyzJ12HNfQhC/aAKWM3YoCV9TtunJzh/QpkMgeDhkoynDcw5f1y+qF9yc/HHyg== + +widest-line@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-5.0.0.tgz#b74826a1e480783345f0cd9061b49753c9da70d0" + integrity sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA== + dependencies: + string-width "^7.0.0" + +wrap-ansi@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.0.tgz#1a3dc8b70d85eeb8398ddfb1e4a02cd186e58b3e" + integrity sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + +ws@^8.15.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + +yoga-wasm-web@~0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba" + integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==