py-sparcli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. py_sparcli-0.3.0.dist-info/METADATA +257 -0
  2. py_sparcli-0.3.0.dist-info/RECORD +61 -0
  3. py_sparcli-0.3.0.dist-info/WHEEL +4 -0
  4. py_sparcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  5. sparcli/__init__.py +184 -0
  6. sparcli/core/__init__.py +92 -0
  7. sparcli/core/border.py +160 -0
  8. sparcli/core/color.py +306 -0
  9. sparcli/core/cursor.py +59 -0
  10. sparcli/core/geometry.py +141 -0
  11. sparcli/core/markup.py +215 -0
  12. sparcli/core/render.py +245 -0
  13. sparcli/core/style.py +128 -0
  14. sparcli/core/terminal.py +127 -0
  15. sparcli/core/text.py +143 -0
  16. sparcli/core/theme.py +117 -0
  17. sparcli/core/width.py +244 -0
  18. sparcli/errors.py +38 -0
  19. sparcli/input/__init__.py +42 -0
  20. sparcli/input/calc.py +214 -0
  21. sparcli/input/confirm.py +188 -0
  22. sparcli/input/datepicker.py +298 -0
  23. sparcli/input/editor.py +189 -0
  24. sparcli/input/event.py +551 -0
  25. sparcli/input/field.py +90 -0
  26. sparcli/input/fuzzy.py +401 -0
  27. sparcli/input/guard.py +103 -0
  28. sparcli/input/history.py +160 -0
  29. sparcli/input/line_edit.py +229 -0
  30. sparcli/input/number.py +232 -0
  31. sparcli/input/outcome.py +107 -0
  32. sparcli/input/password.py +233 -0
  33. sparcli/input/prompt.py +134 -0
  34. sparcli/input/select.py +336 -0
  35. sparcli/input/shortcut.py +123 -0
  36. sparcli/input/text.py +564 -0
  37. sparcli/input/textarea.py +242 -0
  38. sparcli/input/validate.py +67 -0
  39. sparcli/output/__init__.py +56 -0
  40. sparcli/output/alert.py +107 -0
  41. sparcli/output/badge.py +82 -0
  42. sparcli/output/box.py +196 -0
  43. sparcli/output/columns.py +157 -0
  44. sparcli/output/compose.py +111 -0
  45. sparcli/output/diff.py +229 -0
  46. sparcli/output/kv.py +175 -0
  47. sparcli/output/layout.py +77 -0
  48. sparcli/output/list.py +274 -0
  49. sparcli/output/live.py +129 -0
  50. sparcli/output/multiprogress.py +73 -0
  51. sparcli/output/pager.py +83 -0
  52. sparcli/output/panel.py +125 -0
  53. sparcli/output/progress.py +233 -0
  54. sparcli/output/rule.py +134 -0
  55. sparcli/output/spinner.py +138 -0
  56. sparcli/output/table/__init__.py +17 -0
  57. sparcli/output/table/plan.py +198 -0
  58. sparcli/output/table/render.py +302 -0
  59. sparcli/output/table/table.py +351 -0
  60. sparcli/output/tree.py +157 -0
  61. sparcli/py.typed +0 -0
sparcli/core/markup.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ sparcli.core.markup
3
+ ==================
4
+
5
+ Parses lightweight inline markup such as ``[bold red]text[/]`` into rich text.
6
+
7
+ Space-separated specs inside brackets combine attributes and colors; ``on
8
+ <color>`` sets a background; ``[/]`` closes the most recent tag; a backtick
9
+ span like ```code``` gets a cyan code style; and a backslash escapes the next
10
+ character. The parser never fails: a bracket that names no known style or
11
+ attribute (such as ``array[0]``) and any malformed bracket are emitted
12
+ literally.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from sparcli.core.color import Color
18
+ from sparcli.core.render import Renderable, Rendered
19
+ from sparcli.core.style import Attribute, Style
20
+ from sparcli.core.text import Line, Span, Text
21
+
22
+ _BACKGROUND_KEYWORD = "on"
23
+
24
+ _ATTRIBUTE_TOKENS: dict[str, Attribute] = {
25
+ "bold": Attribute.BOLD,
26
+ "b": Attribute.BOLD,
27
+ "dim": Attribute.DIM,
28
+ "d": Attribute.DIM,
29
+ "italic": Attribute.ITALIC,
30
+ "i": Attribute.ITALIC,
31
+ "underline": Attribute.UNDERLINED,
32
+ "underlined": Attribute.UNDERLINED,
33
+ "u": Attribute.UNDERLINED,
34
+ "strike": Attribute.STRIKETHROUGH,
35
+ "strikethrough": Attribute.STRIKETHROUGH,
36
+ "s": Attribute.STRIKETHROUGH,
37
+ }
38
+
39
+
40
+ def parse(markup: str) -> Text:
41
+ """
42
+ Parses markup into a :class:`~sparcli.core.text.Text`.
43
+
44
+ Parameters
45
+ ----------
46
+ markup : str
47
+ The markup string.
48
+
49
+ Returns
50
+ -------
51
+ Text
52
+ The parsed rich text.
53
+
54
+ Examples
55
+ --------
56
+ >>> text = parse("[bold]hi[/]")
57
+ >>> text.lines[0].plain()
58
+ 'hi'
59
+ """
60
+ return _Parser(markup).run()
61
+
62
+
63
+ def markup_print(markup: str) -> None:
64
+ """Parses ``markup`` and prints it to stdout."""
65
+ _AsRenderable(parse(markup)).print()
66
+
67
+
68
+ def markup_println(markup: str) -> None:
69
+ """Parses ``markup``, appends a blank line and prints it to stdout."""
70
+ text = parse(markup)
71
+ text.push_line(Line())
72
+ _AsRenderable(text).print()
73
+
74
+
75
+ class _AsRenderable(Renderable):
76
+ """Adapts a parsed :class:`Text` to the :class:`Renderable` interface."""
77
+
78
+ __slots__ = ("_text",)
79
+
80
+ def __init__(self, text: Text) -> None:
81
+ self._text = text
82
+
83
+ def render(self, max_width: int) -> Rendered:
84
+ """Returns the text's lines as a rendered block."""
85
+ return Rendered.from_text(self._text)
86
+
87
+
88
+ class _Parser:
89
+ """A small state machine turning markup into styled lines."""
90
+
91
+ __slots__ = ("_chars", "_stack", "_lines", "_spans", "_buffer", "_in_code")
92
+
93
+ def __init__(self, markup: str) -> None:
94
+ self._chars = markup
95
+ self._stack: list[Style] = [Style.new()]
96
+ self._lines: list[Line] = []
97
+ self._spans: list[Span] = []
98
+ self._buffer: list[str] = []
99
+ self._in_code = False
100
+
101
+ def run(self) -> Text:
102
+ """Parses the input and returns the resulting text."""
103
+ index = 0
104
+ length = len(self._chars)
105
+ while index < length:
106
+ char = self._chars[index]
107
+ if char == "\\":
108
+ index = self._consume_escape(index)
109
+ elif char == "[":
110
+ index = self._consume_tag(index)
111
+ elif char == "`":
112
+ self._toggle_code()
113
+ index += 1
114
+ elif char == "\n":
115
+ self._break_line()
116
+ index += 1
117
+ else:
118
+ self._buffer.append(char)
119
+ index += 1
120
+ self._break_line()
121
+ return Text(self._lines)
122
+
123
+ def _consume_escape(self, index: int) -> int:
124
+ """Appends the escaped character literally and returns the next index."""
125
+ if index + 1 < len(self._chars):
126
+ self._buffer.append(self._chars[index + 1])
127
+ return index + 2
128
+ self._buffer.append("\\")
129
+ return index + 1
130
+
131
+ def _consume_tag(self, index: int) -> int:
132
+ """Handles a ``[...]`` tag or emits an unrecognized bracket literally."""
133
+ end = self._chars.find("]", index)
134
+ if end == -1:
135
+ self._buffer.append("[")
136
+ return index + 1
137
+ tag = self._chars[index + 1 : end]
138
+ if tag.strip() == "/":
139
+ self._flush_buffer()
140
+ if len(self._stack) > 1:
141
+ self._stack.pop()
142
+ return end + 1
143
+ specs = _parse_specs(tag)
144
+ if specs == Style.new():
145
+ # A closed bracket that names no known style or attribute (e.g.
146
+ # "array[0]") is content, not markup, so emit it literally.
147
+ self._buffer.append(self._chars[index : end + 1])
148
+ return end + 1
149
+ self._flush_buffer()
150
+ self._stack.append(self._current().patch(specs))
151
+ return end + 1
152
+
153
+ def _toggle_code(self) -> None:
154
+ """Toggles a cyan code style on or off at the current nesting."""
155
+ self._flush_buffer()
156
+ if self._in_code:
157
+ if len(self._stack) > 1:
158
+ self._stack.pop()
159
+ else:
160
+ code = self._current().patch(Style.new().with_fg(Color.CYAN))
161
+ self._stack.append(code)
162
+ self._in_code = not self._in_code
163
+
164
+ def _break_line(self) -> None:
165
+ """Flushes the buffer and starts a new line."""
166
+ self._flush_buffer()
167
+ self._lines.append(Line(self._spans))
168
+ self._spans = []
169
+
170
+ def _flush_buffer(self) -> None:
171
+ """Emits the buffered text as a span with the current style."""
172
+ if not self._buffer:
173
+ return
174
+ content = "".join(self._buffer)
175
+ self._spans.append(Span(content=content, style=self._current()))
176
+ self._buffer = []
177
+
178
+ def _current(self) -> Style:
179
+ """Returns the style on top of the stack."""
180
+ return self._stack[-1]
181
+
182
+
183
+ def _parse_specs(tag: str) -> Style:
184
+ """Parses a tag's space-separated specs into a style."""
185
+ style = Style.new()
186
+ expect_background = False
187
+ for token in tag.split():
188
+ lowered = token.lower()
189
+ if lowered == _BACKGROUND_KEYWORD:
190
+ expect_background = True
191
+ continue
192
+ if expect_background:
193
+ color = _parse_color(token)
194
+ if color is not None:
195
+ style = style.with_bg(color)
196
+ expect_background = False
197
+ continue
198
+ style = _apply_token(style, lowered, token)
199
+ return style
200
+
201
+
202
+ def _apply_token(style: Style, lowered: str, token: str) -> Style:
203
+ """Applies a single foreground or attribute token to ``style``."""
204
+ attribute = _ATTRIBUTE_TOKENS.get(lowered)
205
+ if attribute is not None:
206
+ return style.add_modifier(attribute)
207
+ color = _parse_color(token)
208
+ return style.with_fg(color) if color is not None else style
209
+
210
+
211
+ def _parse_color(token: str) -> Color | None:
212
+ """Parses a hex or named color token, returning ``None`` if unknown."""
213
+ if token.startswith("#"):
214
+ return Color.from_hex(token)
215
+ return Color.from_name(token)
sparcli/core/render.py ADDED
@@ -0,0 +1,245 @@
1
+ """
2
+ sparcli.core.render
3
+ ===================
4
+
5
+ Defines the render model: :class:`Rendered`, :class:`Renderable` and the ANSI
6
+ writer.
7
+
8
+ Every widget lays itself out into a :class:`Rendered` (a list of styled lines
9
+ of known width) before anything reaches the terminal. Colors and attributes are
10
+ turned into escape codes only here, in :func:`write_rendered`, downgraded to the
11
+ detected :class:`~sparcli.core.terminal.ColorSupport`. Plain, unstyled text is
12
+ written without a single escape, which keeps piped and ``NO_COLOR`` output
13
+ clean.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass, field
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from sparcli.core.color import ResolvedColor
24
+ from sparcli.core.style import Attribute, Style
25
+ from sparcli.core.terminal import ColorSupport, color_support, term_width
26
+ from sparcli.core.text import IntoLine, Line, Span, Text, into_line
27
+
28
+ _ESC = "\x1b"
29
+ _RESET = f"{_ESC}[0m"
30
+ _OSC8_START = f"{_ESC}]8;;"
31
+ _OSC8_END = f"{_ESC}\\"
32
+
33
+ # Control characters (C0, DEL and C1) minus tab. Emitting these verbatim lets
34
+ # untrusted content inject escape sequences or corrupt the layout, so they are
35
+ # stripped just before text reaches the terminal.
36
+ _CONTROL_TABLE: dict[int, None] = {
37
+ code: None
38
+ for code in (*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0))
39
+ if code != 0x09
40
+ }
41
+
42
+ # SGR parameter for each attribute flag.
43
+ _ATTRIBUTE_SGR: list[tuple[Attribute, str]] = [
44
+ (Attribute.BOLD, "1"),
45
+ (Attribute.DIM, "2"),
46
+ (Attribute.ITALIC, "3"),
47
+ (Attribute.UNDERLINED, "4"),
48
+ (Attribute.STRIKETHROUGH, "9"),
49
+ ]
50
+
51
+
52
+ @runtime_checkable
53
+ class SupportsWrite(Protocol):
54
+ """A minimal text sink: anything with a ``write(str)`` method."""
55
+
56
+ def write(self, data: str, /) -> int | None:
57
+ """Writes ``data`` to the sink."""
58
+ ...
59
+
60
+
61
+ class Renderable(ABC):
62
+ """
63
+ The interface every printable widget implements.
64
+
65
+ Subclasses provide :meth:`render`; :meth:`print` and :meth:`print_to` are
66
+ derived from it.
67
+ """
68
+
69
+ __slots__ = ()
70
+
71
+ @abstractmethod
72
+ def render(self, max_width: int) -> Rendered:
73
+ """
74
+ Lays the widget out into at most ``max_width`` columns.
75
+
76
+ Parameters
77
+ ----------
78
+ max_width : int
79
+ The maximum width available, in columns.
80
+
81
+ Returns
82
+ -------
83
+ Rendered
84
+ The laid-out block of styled lines.
85
+ """
86
+ raise NotImplementedError
87
+
88
+ def print(self) -> None:
89
+ """Renders at the terminal width and writes the result to stdout."""
90
+ rendered = self.render(term_width())
91
+ write_rendered(sys.stdout, rendered, color_support())
92
+ sys.stdout.flush()
93
+
94
+ def print_to(self, writer: SupportsWrite) -> None:
95
+ """
96
+ Renders at the terminal width and writes to ``writer``.
97
+
98
+ Parameters
99
+ ----------
100
+ writer : SupportsWrite
101
+ Any object with a ``write(str)`` method (a file, ``io.StringIO``).
102
+ """
103
+ rendered = self.render(term_width())
104
+ write_rendered(writer, rendered, color_support())
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class Rendered(Renderable):
109
+ """
110
+ A laid-out block of styled lines of known width.
111
+
112
+ :class:`Rendered` is both the output of :meth:`Renderable.render` and the
113
+ composable unit used to stack and place widgets next to each other. It is
114
+ itself a :class:`Renderable`, so a pre-built block can be printed directly.
115
+ """
116
+
117
+ lines: list[Line] = field(default_factory=list)
118
+
119
+ @classmethod
120
+ def empty(cls) -> Rendered:
121
+ """Returns an empty rendered block."""
122
+ return cls([])
123
+
124
+ @classmethod
125
+ def from_text(cls, text: Text) -> Rendered:
126
+ """Returns a rendered block from a :class:`Text`'s lines."""
127
+ return cls(list(text.lines))
128
+
129
+ def render(self, max_width: int) -> Rendered:
130
+ """Returns a copy of this block, ignoring ``max_width``."""
131
+ return Rendered(list(self.lines))
132
+
133
+ def push(self, line: IntoLine) -> None:
134
+ """Appends a line to the block."""
135
+ self.lines.append(into_line(line))
136
+
137
+ def width(self) -> int:
138
+ """Returns the width of the widest line in columns."""
139
+ return max((line.width() for line in self.lines), default=0)
140
+
141
+ def height(self) -> int:
142
+ """Returns the number of lines."""
143
+ return len(self.lines)
144
+
145
+ def plain(self) -> str:
146
+ """Returns the block's text without styling, lines joined by ``\\n``."""
147
+ return "\n".join(line.plain() for line in self.lines)
148
+
149
+
150
+ def write_rendered(
151
+ writer: SupportsWrite, rendered: Rendered, support: ColorSupport
152
+ ) -> None:
153
+ """
154
+ Writes a :class:`Rendered` to ``writer`` as ANSI, one line per row.
155
+
156
+ Parameters
157
+ ----------
158
+ writer : SupportsWrite
159
+ The text sink to write to.
160
+ rendered : Rendered
161
+ The block to write.
162
+ support : ColorSupport
163
+ The color depth to emit.
164
+ """
165
+ for line in rendered.lines:
166
+ write_line(writer, line, support)
167
+ writer.write("\n")
168
+
169
+
170
+ def write_line(
171
+ writer: SupportsWrite, line: Line, support: ColorSupport
172
+ ) -> None:
173
+ """Writes a single styled line to ``writer`` without a trailing newline."""
174
+ for span in line.spans:
175
+ writer.write(_render_span(span, support))
176
+
177
+
178
+ def _render_span(span: Span, support: ColorSupport) -> str:
179
+ """Returns the escaped string for a single span at a support level."""
180
+ content = _sanitize(span.content)
181
+ if support is ColorSupport.NONE:
182
+ return content
183
+ codes = _sgr_codes(span.style, support)
184
+ content = _wrap_link(content, span.link)
185
+ if not codes:
186
+ return content
187
+ prefix = f"{_ESC}[{';'.join(codes)}m"
188
+ return f"{prefix}{content}{_RESET}"
189
+
190
+
191
+ def _sanitize(text: str) -> str:
192
+ """Strips control characters (except tab) that would corrupt output."""
193
+ return text.translate(_CONTROL_TABLE)
194
+
195
+
196
+ def _sgr_codes(style: Style, support: ColorSupport) -> list[str]:
197
+ """Returns the SGR parameters for a style, empty when nothing is styled."""
198
+ codes: list[str] = []
199
+ if style.fg is not None:
200
+ resolved = style.fg.resolve(support)
201
+ if resolved is not None:
202
+ codes.append(_color_sgr(resolved, background=False))
203
+ if style.bg is not None:
204
+ resolved = style.bg.resolve(support)
205
+ if resolved is not None:
206
+ codes.append(_color_sgr(resolved, background=True))
207
+ for attribute, code in _ATTRIBUTE_SGR:
208
+ if style.attrs.contains(attribute):
209
+ codes.append(code)
210
+ return codes
211
+
212
+
213
+ def _color_sgr(resolved: ResolvedColor, *, background: bool) -> str:
214
+ """Builds the SGR fragment for a resolved color."""
215
+ lead = "48" if background else "38"
216
+ match resolved:
217
+ case ("reset",):
218
+ return "49" if background else "39"
219
+ case ("ansi16", index):
220
+ return _ansi16_sgr(index, background=background)
221
+ case ("rgb", red, green, blue):
222
+ return f"{lead};2;{red};{green};{blue}"
223
+ case ("indexed", palette_index):
224
+ return f"{lead};5;{palette_index}"
225
+
226
+
227
+ def _ansi16_sgr(index: int, *, background: bool) -> str:
228
+ """Builds the SGR fragment for a 16-color palette index."""
229
+ if index < 8:
230
+ base = 40 if background else 30
231
+ return str(base + index)
232
+ base = 100 if background else 90
233
+ return str(base + index - 8)
234
+
235
+
236
+ def _wrap_link(content: str, link: str | None) -> str:
237
+ """Wraps ``content`` in an OSC-8 hyperlink when a link is present.
238
+
239
+ The URL is scrubbed of control characters so a crafted link cannot
240
+ terminate the OSC-8 sequence early and inject its own escapes.
241
+ """
242
+ if link is None:
243
+ return content
244
+ safe = _sanitize(link)
245
+ return f"{_OSC8_START}{safe}{_OSC8_END}{content}{_OSC8_START}{_OSC8_END}"
sparcli/core/style.py ADDED
@@ -0,0 +1,128 @@
1
+ """
2
+ sparcli.core.style
3
+ ==================
4
+
5
+ Defines text styling: attributes and the :class:`Style` value type.
6
+
7
+ A :class:`Style` bundles an optional foreground and background color with a set
8
+ of :class:`Attribute` flags (bold, dim, italic, ...). Styles are immutable;
9
+ builder methods return a new style, and :meth:`Style.patch` layers one style on
10
+ top of another.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import enum
16
+ from dataclasses import dataclass, replace
17
+
18
+ from sparcli.core.color import Color
19
+
20
+
21
+ class Attribute(enum.IntFlag):
22
+ """Bitflags for text attributes; combine with ``|``."""
23
+
24
+ NONE = 0
25
+ BOLD = enum.auto()
26
+ DIM = enum.auto()
27
+ ITALIC = enum.auto()
28
+ UNDERLINED = enum.auto()
29
+ STRIKETHROUGH = enum.auto()
30
+
31
+ def contains(self, other: Attribute) -> bool:
32
+ """Returns whether every flag in ``other`` is set in ``self``."""
33
+ return (self & other) == other
34
+
35
+ def is_empty(self) -> bool:
36
+ """Returns whether no attribute flag is set."""
37
+ return self == Attribute.NONE
38
+
39
+
40
+ # ratatui-familiar alias for the attribute flags.
41
+ Modifier = Attribute
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Style:
46
+ """
47
+ An immutable text style: foreground, background and attribute flags.
48
+
49
+ Attributes
50
+ ----------
51
+ fg : Color | None
52
+ Foreground color, or ``None`` to inherit.
53
+ bg : Color | None
54
+ Background color, or ``None`` to inherit.
55
+ attrs : Attribute
56
+ The set of active attribute flags.
57
+ """
58
+
59
+ fg: Color | None = None
60
+ bg: Color | None = None
61
+ attrs: Attribute = Attribute.NONE
62
+
63
+ @classmethod
64
+ def new(cls) -> Style:
65
+ """Returns an empty style with no color and no attributes."""
66
+ return cls()
67
+
68
+ def with_fg(self, color: Color) -> Style:
69
+ """Returns a copy with the foreground color set."""
70
+ return replace(self, fg=color)
71
+
72
+ def with_bg(self, color: Color) -> Style:
73
+ """Returns a copy with the background color set."""
74
+ return replace(self, bg=color)
75
+
76
+ def add_modifier(self, attr: Attribute) -> Style:
77
+ """Returns a copy with ``attr`` added to the attribute flags."""
78
+ return replace(self, attrs=self.attrs | attr)
79
+
80
+ def remove_modifier(self, attr: Attribute) -> Style:
81
+ """Returns a copy with ``attr`` cleared from the attribute flags."""
82
+ return replace(self, attrs=self.attrs & ~attr)
83
+
84
+ def bold(self) -> Style:
85
+ """Returns a copy with the bold attribute set."""
86
+ return self.add_modifier(Attribute.BOLD)
87
+
88
+ def dim(self) -> Style:
89
+ """Returns a copy with the dim attribute set."""
90
+ return self.add_modifier(Attribute.DIM)
91
+
92
+ def italic(self) -> Style:
93
+ """Returns a copy with the italic attribute set."""
94
+ return self.add_modifier(Attribute.ITALIC)
95
+
96
+ def underlined(self) -> Style:
97
+ """Returns a copy with the underlined attribute set."""
98
+ return self.add_modifier(Attribute.UNDERLINED)
99
+
100
+ def strikethrough(self) -> Style:
101
+ """Returns a copy with the strikethrough attribute set."""
102
+ return self.add_modifier(Attribute.STRIKETHROUGH)
103
+
104
+ def patch(self, other: Style) -> Style:
105
+ """
106
+ Layers ``other`` on top of this style.
107
+
108
+ Parameters
109
+ ----------
110
+ other : Style
111
+ The overriding style. Its set colors win; attribute flags from both
112
+ styles are combined.
113
+
114
+ Returns
115
+ -------
116
+ Style
117
+ The merged style.
118
+ """
119
+ return Style(
120
+ fg=other.fg if other.fg is not None else self.fg,
121
+ bg=other.bg if other.bg is not None else self.bg,
122
+ attrs=self.attrs | other.attrs,
123
+ )
124
+
125
+ @classmethod
126
+ def from_color(cls, color: Color) -> Style:
127
+ """Returns a style whose foreground is ``color``."""
128
+ return cls(fg=color)