Repository: livebook-dev/kino
Pull request #490 (2025-02-11 08:24:41)

Add style to Kino.Text


Patch:
diff --git a/lib/kino/render.ex b/lib/kino/render.ex
index 84b5f5f..817d622 100644
--- a/lib/kino/render.ex
+++ b/lib/kino/render.ex
@@ -88,7 +88,7 @@ defimpl Kino.Render, for: Kino.Text do
   end
 
   def to_livebook(kino) do
-    %{type: :plain_text, text: kino.text, chunk: kino.chunk}
+    %{type: :plain_text, text: kino.text, chunk: kino.chunk, style: kino.style}
   end
 end
 
diff --git a/lib/kino/text.ex b/lib/kino/text.ex
index 73c2d41..60aa782 100644
--- a/lib/kino/text.ex
+++ b/lib/kino/text.ex
@@ -17,14 +17,17 @@ defmodule Kino.Text do
 
   @enforce_keys [:text]
 
-  defstruct [:text, :terminal, :chunk]
+  defstruct [:text, :terminal, :chunk, :style]
 
   @opaque t :: %__MODULE__{
             text: String.t(),
             terminal: boolean(),
-            chunk: boolean()
+            chunk: boolean(),
+            style: style()
           }
 
+  @type style :: [{:color | :font_weight | :font_size, String.Chars.t()}]
+
   @doc """
   Creates a new kino displaying the given text content.
 
@@ -37,6 +40,11 @@ defmodule Kino.Text do
       are merged into a single text. This is useful for streaming content.
       Defaults to `false`
 
+    * `:style` - a keyword list of CSS attributes, such as
+      `style: [color: "#FF0000", font_weight: :bold]`. The currently supported
+      styles are `:color`, `:font_size`, and `:font_weight`. Not supported on
+      terminal outputs.
+
   ## Examples
 
   ### Using the `:chunk` option
@@ -61,9 +69,33 @@ defmodule Kino.Text do
       Kino.nothing()
 
   """
-  @spec new(String.t(), opts) :: t() when opts: [terminal: boolean(), chunk: boolean()]
+  @spec new(String.t(), opts) :: t()
+        when opts: [terminal: boolean(), chunk: boolean(), style: style()]
   def new(text, opts \\ []) when is_binary(text) do
-    opts = Keyword.validate!(opts, terminal: false, chunk: false)
-    %__MODULE__{text: text, terminal: opts[:terminal], chunk: opts[:chunk]}
+    opts = Keyword.validate!(opts, terminal: false, chunk: false, style: [])
+    terminal? = opts[:terminal]
+    style = opts[:style]
+
+    cond do
+      not is_list(style) ->
+        raise ArgumentError, ":style must be a keyword list"
+
+      terminal? and style != [] ->
+        raise ArgumentError, ":style not supported when terminal: true"
+
+      true ->
+        Enum.each(style, fn
+          {key, value} when key in [:color, :font_weight, :font_size] ->
+            if String.contains?(to_string(value), ";") do
+              raise ArgumentError, "invalid CSS property value for #{inspect(key)}"
+            end
+
+          other ->
+            raise ArgumentError,
+                  ":style must be a keyword list of color/font_size/font_weight, got: #{inspect(other)}"
+        end)
+    end
+
+    %__MODULE__{text: text, terminal: terminal?, chunk: opts[:chunk], style: style}
   end
 end


Test patch:
diff --git a/test/kino/text_test.exs b/test/kino/text_test.exs
index 6266856..a7c4d23 100644
--- a/test/kino/text_test.exs
+++ b/test/kino/text_test.exs
@@ -4,10 +4,27 @@ defmodule Kino.TextTest do
   describe "new/1" do
     test "outputs plain text" do
       "Hello!" |> Kino.Text.new() |> Kino.render()
-      assert_output(%{type: :plain_text, text: "Hello!", chunk: false})
+      assert_output(%{type: :plain_text, text: "Hello!", chunk: false, style: []})
 
       "Hello!" |> Kino.Text.new(terminal: false) |> Kino.render()
-      assert_output(%{type: :plain_text, text: "Hello!", chunk: false})
+      assert_output(%{type: :plain_text, text: "Hello!", chunk: false, style: []})
+
+      "Hello!" |> Kino.Text.new(style: [font_weight: 300]) |> Kino.render()
+      assert_output(%{type: :plain_text, text: "Hello!", chunk: false, style: [font_weight: 300]})
+    end
+
+    test "validates style" do
+      assert_raise ArgumentError, ":style must be a keyword list", fn ->
+        Kino.Text.new("Hello!", style: %{font_weight: 300})
+      end
+
+      assert_raise ArgumentError, ":style not supported when terminal: true", fn ->
+        Kino.Text.new("Hello!", style: [font_weight: 300], terminal: true)
+      end
+
+      assert_raise ArgumentError, "invalid CSS property value for :font_weight", fn ->
+        Kino.Text.new("Hello!", style: [font_weight: "300;color"])
+      end
     end
 
     test "outputs terminal text" do