From 922bbcac1fd52bce8711d7394b4bf60dabb9c4dc Mon Sep 17 00:00:00 2001 From: "Douglas M." Date: Mon, 24 Jun 2024 22:10:02 +0200 Subject: [PATCH] feat: first version --- .github/workflows/test.yml | 23 + .gitignore | 5 + README.md | 25 + birdie_snapshots/box.accepted | 23 + birdie_snapshots/hook_app.accepted | 7 + birdie_snapshots/hook_focus.accepted | 7 + birdie_snapshots/hook_input.accepted | 7 + birdie_snapshots/hook_stdin.accepted | 7 + birdie_snapshots/hook_stdout.accepted | 7 + birdie_snapshots/newline.accepted | 9 + birdie_snapshots/spacer.accepted | 18 + birdie_snapshots/spinner.accepted | 7 + birdie_snapshots/static.accepted | 18 + birdie_snapshots/text.accepted | 14 + birdie_snapshots/transform.accepted | 7 + gleam.toml | 23 + manifest.toml | 28 + package.json | 15 + src/ink_ffi.mjs | 33 + src/ink_spinner_ffi.mjs | 4 + src/pink.gleam | 249 +++++++ src/pink/attribute.gleam | 930 ++++++++++++++++++++++++++ src/pink/focus.gleam | 37 + src/pink/hook.gleam | 132 ++++ src/pink/key.gleam | 91 +++ src/react_ffi.mjs | 15 + test/ink_test_ffi.mjs | 14 + test/pink_test.gleam | 315 +++++++++ yarn.lock | 298 +++++++++ 29 files changed, 2368 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 birdie_snapshots/box.accepted create mode 100644 birdie_snapshots/hook_app.accepted create mode 100644 birdie_snapshots/hook_focus.accepted create mode 100644 birdie_snapshots/hook_input.accepted create mode 100644 birdie_snapshots/hook_stdin.accepted create mode 100644 birdie_snapshots/hook_stdout.accepted create mode 100644 birdie_snapshots/newline.accepted create mode 100644 birdie_snapshots/spacer.accepted create mode 100644 birdie_snapshots/spinner.accepted create mode 100644 birdie_snapshots/static.accepted create mode 100644 birdie_snapshots/text.accepted create mode 100644 birdie_snapshots/transform.accepted create mode 100644 gleam.toml create mode 100644 manifest.toml create mode 100644 package.json create mode 100644 src/ink_ffi.mjs create mode 100644 src/ink_spinner_ffi.mjs create mode 100644 src/pink.gleam create mode 100644 src/pink/attribute.gleam create mode 100644 src/pink/focus.gleam create mode 100644 src/pink/hook.gleam create mode 100644 src/pink/key.gleam create mode 100644 src/react_ffi.mjs create mode 100644 test/ink_test_ffi.mjs create mode 100644 test/pink_test.gleam create mode 100644 yarn.lock 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..5b17645 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# pink + +[![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/) + +```sh +gleam add pink +``` +```gleam +import pink + +pub fn main() { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +gleam shell # Run an Erlang shell +``` 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_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_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..bbf32df --- /dev/null +++ b/src/ink_ffi.mjs @@ -0,0 +1,33 @@ +import React from "react" +import { Text, Box, Newline, Spacer, useStdin, Static, Transform, useFocus } 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, + } +} 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..6fc30ad --- /dev/null +++ b/src/pink.gleam @@ -0,0 +1,249 @@ +/// Bindings to [Ink](https://github.com/vadimdemedes/ink) +/// A minimal React-like library for building terminal UIs. +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..b9d52cd --- /dev/null +++ b/src/pink/attribute.gleam @@ -0,0 +1,930 @@ +//// Shorthand for setting `border_top_dim_color`, `border_bottom_dim_color`, `border_left_dim_color` and `border_right_dim_color` + +/// 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) +} + +pub type Wrap { + Wrap + Truncate + TruncateStart + TruncateMiddle + TruncateEnd +} + +pub type Dimension { + Spaces(Int) + Percent(Int) +} + +pub type FlexDirection { + FlexRow + FlexColumn + FlexRowReverse + FlexColumnReverse +} + +pub type FlexWrap { + FlexWrap + FlexNoWrap + FlexWrapReverse +} + +pub type AlignItems { + ItemsStart + ItemsCenter + ItemsEnd +} + +pub type AlignSelf { + SelfAuto + SelfStart + SelfCenter + SelfEnd +} + +pub type JustifyContent { + ContentStart + ContentCenter + ContentEnd + ContentSpaceBetween + ContentSpaceAround +} + +pub type Display { + DisplayFlex + DisplayNone +} + +pub type Visibility { + Visible + Hidden +} + +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, + ) +} + +pub fn custom(key: String, value: Json) -> Attribute { + Attribute(key, value) +} + +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..9e16a28 --- /dev/null +++ b/src/pink/focus.gleam @@ -0,0 +1,37 @@ +import gleam/json.{type Json} +import gleam/option.{type Option, None, Some} + +pub type Focus { + Focus(is_focused: Bool) +} + +pub opaque type FocusOptions { + FocusOptions(auto_focus: Bool, is_active: Bool, id: Option(String)) +} + +pub fn options() -> FocusOptions { + FocusOptions(False, True, None) +} + +pub fn set_auto_focus(options: FocusOptions, auto_focus: Bool) { + FocusOptions(..options, auto_focus: auto_focus) +} + +pub fn set_is_active(options: FocusOptions, is_active: Bool) { + FocusOptions(..options, is_active: is_active) +} + +pub fn set_id(options: FocusOptions, id: String) { + FocusOptions(..options, id: Some(id)) +} + +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..480c3af --- /dev/null +++ b/src/pink/hook.gleam @@ -0,0 +1,132 @@ +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} + +pub type App { + App(exit: fn() -> Nil) +} + +pub type Stdin { + Stdin( + stdin: Dynamic, + is_raw_mode_supported: Bool, + set_raw_mode: fn(Bool) -> Nil, + ) +} + +pub type Stdout { + Stdout(stdout: Dynamic, write: fn(String) -> Nil) +} + +pub type Stderr { + Stderr(stderr: Dynamic, write: fn(String) -> Nil) +} + +pub type State(a) { + State(value: a, set: fn(a) -> Nil, set_with: fn(fn(a) -> a) -> 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 + +@external(javascript, "../react_ffi.mjs", "useState") +fn use_state(initial initial: a) -> #(a, fn(fn(a) -> a) -> Nil) + +@external(javascript, "../react_ffi.mjs", "useEffect") +pub fn effect(callback: fn() -> Nil, dependencies: List(a)) -> Nil + +@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))]), + ) +} + +pub fn focus(options: FocusOptions) { + options + |> focus.encode_options + |> use_focus +} + +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..b87b515 --- /dev/null +++ b/src/pink/key.gleam @@ -0,0 +1,91 @@ +import gleam/dynamic +import gleam/function +import gleam/list +import gleam/result + +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) + } + }) + } +} + +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..e465712 --- /dev/null +++ b/test/pink_test.gleam @@ -0,0 +1,315 @@ +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) +} + +@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_input_test() { + let data = + pink.component(fn() { + hook.input(fn(_input, _keys) { todo }, 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") +} 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==