Coverage for yuio / color.py: 98%

248 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

7 

8""" 

9Text background and foreground color, as well as its style, is defined 

10by the :class:`yuio.color.Color` class. It stores RGB components and ANSI escape codes 

11for every aspect of text presentation. 

12 

13This is a low-level module upon which :mod:`yuio.io` builds 

14its higher-level abstraction. 

15 

16.. autoclass:: Color 

17 :members: 

18 

19.. autoclass:: ColorValue 

20 :members: 

21 

22.. autoclass:: ColorSupport 

23 :members: 

24 

25""" 

26 

27from __future__ import annotations 

28 

29import colorsys 

30import dataclasses 

31import enum 

32import re 

33from dataclasses import dataclass 

34 

35from typing import TYPE_CHECKING 

36from typing import ClassVar as _ClassVar 

37 

38if TYPE_CHECKING: 

39 import typing_extensions as _t 

40else: 

41 from yuio import _typing as _t 

42 

43__all__ = [ 

44 "Color", 

45 "ColorSupport", 

46 "ColorValue", 

47] 

48 

49 

50@dataclass(frozen=True, slots=True) 

51class ColorValue: 

52 """ 

53 Data about a single color. 

54 

55 """ 

56 

57 data: int | str | tuple[int, int, int] 

58 """ 

59 Color data. 

60 

61 Can be one of three things: 

62 

63 - an int value represents an 8-bit color code (a value between ``0`` and ``7``). 

64 

65 The actual color value for 8-bit color codes is controlled by the terminal's user. 

66 Therefore, it doesn't permit operations on colors. 

67 

68 Depending on where this value is used (foreground or background), it will 

69 result in either ``3x`` or ``4x`` SGR parameter. 

70 

71 - an RGB-tuple represents a true color. 

72 

73 When converted for a terminal that doesn't support true colors, 

74 it is automatically mapped to a corresponding 256- or 8-bit color. 

75 

76 Depending on where this value is used (foreground or background), it will 

77 result in either ``38``/``3x`` or ``48``/``4x`` SGR parameter sequence. 

78 

79 - A string value represents `a parameter for the SGR command`__. Yuio will add this 

80 value to an SGR escape sequence as is, without any modification. 

81 

82 __ https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters 

83 

84 """ 

85 

86 _NAMES = ["BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE"] 

87 

88 @classmethod 

89 def from_rgb(cls, r: int, g: int, b: int, /) -> ColorValue: 

90 """ 

91 Create a color value from rgb components. 

92 

93 Each component should be between 0 and 255. 

94 

95 :example: 

96 :: 

97 

98 >>> ColorValue.from_rgb(0xA0, 0x1E, 0x9C) 

99 <ColorValue #A01E9C> 

100 

101 """ 

102 

103 return cls((r, g, b)) 

104 

105 @classmethod 

106 def from_hex(cls, h: str, /) -> ColorValue: 

107 """ 

108 Create a color value from a hex string. 

109 

110 :example: 

111 :: 

112 

113 >>> ColorValue.from_hex('#A01E9C') 

114 <ColorValue #A01E9C> 

115 

116 """ 

117 

118 return cls(_parse_hex(h)) 

119 

120 def to_hex(self) -> str | None: 

121 """ 

122 Return color in hex format with leading ``#``. 

123 

124 :example: 

125 :: 

126 

127 >>> a = ColorValue.from_hex('#A01E9C') 

128 >>> a.to_hex() 

129 '#A01E9C' 

130 

131 """ 

132 

133 rgb = self.to_rgb() 

134 if rgb is not None: 

135 return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" 

136 else: 

137 return None 

138 

139 def to_rgb(self) -> tuple[int, int, int] | None: 

140 """ 

141 Return RGB components of the color. 

142 

143 :example: 

144 :: 

145 

146 >>> a = ColorValue.from_hex('#A01E9C') 

147 >>> a.to_rgb() 

148 (160, 30, 156) 

149 

150 """ 

151 

152 if isinstance(self.data, tuple): 

153 return self.data 

154 else: 

155 return None 

156 

157 def darken(self, amount: float, /) -> ColorValue: 

158 """ 

159 Make this color darker by the given percentage. 

160 

161 Amount should be between 0 and 1. 

162 

163 :example: 

164 :: 

165 

166 >>> # Darken by 30%. 

167 ... ColorValue.from_hex('#A01E9C').darken(0.30) 

168 <ColorValue #70156D> 

169 

170 """ 

171 

172 rgb = self.to_rgb() 

173 if rgb is None: 

174 return self 

175 

176 amount = max(min(amount, 1), 0) 

177 r, g, b = rgb 

178 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF) 

179 v = v - v * amount 

180 r, g, b = colorsys.hsv_to_rgb(h, s, v) 

181 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF)) 

182 

183 def lighten(self, amount: float, /) -> ColorValue: 

184 """ 

185 Make this color lighter by the given percentage. 

186 

187 Amount should be between 0 and 1. 

188 

189 :example: 

190 :: 

191 

192 >>> # Lighten by 30%. 

193 ... ColorValue.from_hex('#A01E9C').lighten(0.30) 

194 <ColorValue #BC23B7> 

195 

196 """ 

197 

198 rgb = self.to_rgb() 

199 if rgb is None: 

200 return self 

201 

202 amount = max(min(amount, 1), 0) 

203 r, g, b = rgb 

204 h, s, v = colorsys.rgb_to_hsv(r / 0xFF, g / 0xFF, b / 0xFF) 

205 v = 1 - v 

206 v = 1 - (v - v * amount) 

207 r, g, b = colorsys.hsv_to_rgb(h, s, v) 

208 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF)) 

209 

210 def match_luminosity(self, other: ColorValue, /) -> ColorValue: 

211 """ 

212 Set luminosity of this color equal to one of the other color. 

213 

214 This function will keep hue and saturation of the color intact, 

215 but it will become as bright as the other color. 

216 

217 """ 

218 

219 rgb1, rgb2 = self.to_rgb(), other.to_rgb() 

220 if rgb1 is None or rgb2 is None: 

221 return self 

222 

223 h, s, _ = colorsys.rgb_to_hsv(rgb1[0] / 0xFF, rgb1[1] / 0xFF, rgb1[2] / 0xFF) 

224 _, _, v = colorsys.rgb_to_hsv(rgb2[0] / 0xFF, rgb2[1] / 0xFF, rgb2[2] / 0xFF) 

225 r, g, b = colorsys.hsv_to_rgb(h, s, v) 

226 return ColorValue.from_rgb(int(r * 0xFF), int(g * 0xFF), int(b * 0xFF)) 

227 

228 @staticmethod 

229 def lerp(*colors: ColorValue) -> _t.Callable[[float], ColorValue]: 

230 """ 

231 Return a lambda that allows linear interpolation between several colors. 

232 

233 If either color is a single ANSI escape code, the first color is always returned 

234 from the lambda. 

235 

236 :param colors: 

237 colors of a gradient. 

238 :returns: 

239 a callable that allows interpolating between colors: it accepts a float 

240 value between ``1`` and ``0`` and returns a color. 

241 :raises: 

242 :class:`ValueError` if no colors are given. 

243 :example: 

244 :: 

245 

246 >>> a = ColorValue.from_hex('#A01E9C') 

247 >>> b = ColorValue.from_hex('#22C60C') 

248 >>> lerp = ColorValue.lerp(a, b) 

249 

250 >>> lerp(0) 

251 <ColorValue #A01E9C> 

252 >>> lerp(0.5) 

253 <ColorValue #617254> 

254 >>> lerp(1) 

255 <ColorValue #22C60C> 

256 

257 """ 

258 

259 if not colors: 

260 raise ValueError("lerp expected at least 1 argument, got 0") 

261 elif len(colors) == 1 or not all( 

262 isinstance(color.data, tuple) for color in colors 

263 ): 

264 return lambda f, /: colors[0] 

265 else: 

266 l = len(colors) - 1 

267 

268 def lerp(f: float, /) -> ColorValue: 

269 i = int(f * l) 

270 f = (f - (i / l)) * l 

271 

272 if i == l: 

273 return colors[l] 

274 else: 

275 a, b = colors[i].data, colors[i + 1].data 

276 return ColorValue( 

277 tuple(int(ca + f * (cb - ca)) for ca, cb in zip(a, b)) # type: ignore 

278 ) 

279 

280 return lerp 

281 

282 def _as_fore(self, color_support: ColorSupport, /) -> str: 

283 return self._as_code(color_support, fg_bg_prefix="3") 

284 

285 def _as_back(self, color_support: ColorSupport, /) -> str: 

286 return self._as_code(color_support, fg_bg_prefix="4") 

287 

288 def _as_code(self, color_support: ColorSupport, /, fg_bg_prefix: str) -> str: 

289 if color_support == ColorSupport.NONE: 

290 return "" # pragma: no cover 

291 elif isinstance(self.data, int): 

292 return f"{fg_bg_prefix}{self.data}" 

293 elif isinstance(self.data, str): 

294 return self.data 

295 elif color_support == ColorSupport.ANSI_TRUE: 

296 return f"{fg_bg_prefix}8;2;{self.data[0]};{self.data[1]};{self.data[2]}" 

297 elif color_support == ColorSupport.ANSI_256: 

298 return f"{fg_bg_prefix}8;5;{_rgb_to_256(*self.data)}" 

299 else: 

300 return f"{fg_bg_prefix}{_rgb_to_8(*self.data)}" 

301 

302 def __repr__(self) -> str: 

303 if isinstance(self.data, tuple): 

304 return f"<ColorValue {self.to_hex()}>" 

305 elif isinstance(self.data, int): 

306 if 0 <= self.data < len(self._NAMES): 

307 return f"<{self._NAMES[self.data]}>" 

308 else: 

309 return f"<ColorValue {self.data}>" 

310 else: 

311 return f"<ColorValue {self.data!r}>" 

312 

313 

314@dataclass(frozen=True, slots=True) 

315class Color: 

316 """ 

317 Data about terminal output style. Contains 

318 foreground and background color, as well as text styles. 

319 

320 When converted to an ANSI code and printed, a color completely overwrites a previous 

321 color that was used by a terminal. This behavior prevents different colors and styles 

322 bleeding one into another. So, for example, printing :data:`Color.STYLE_BOLD` 

323 and then :data:`Color.FORE_RED` will result in non-bold red text. 

324 

325 Colors can be combined before printing, though:: 

326 

327 >>> Color.STYLE_BOLD | Color.FORE_RED # Bold red 

328 <Color fore=<RED> bold=True> 

329 

330 Yuio supports true RGB colors. They are automatically converted 

331 to 256- or 8-bit colors if needed. 

332 

333 """ 

334 

335 fore: ColorValue | None = None 

336 """ 

337 Foreground color. 

338 

339 """ 

340 

341 back: ColorValue | None = None 

342 """ 

343 Background color. 

344 

345 """ 

346 

347 bold: bool | None = None 

348 """ 

349 If true, render text as bold. 

350 

351 """ 

352 

353 dim: bool | None = None 

354 """ 

355 If true, render text as dim. 

356 

357 """ 

358 

359 italic: bool | None = None 

360 """ 

361 If true, render text in italic font. 

362 

363 """ 

364 

365 underline: bool | None = None 

366 """ 

367 If true, underline the text. 

368 

369 """ 

370 

371 inverse: bool | None = None 

372 """ 

373 If true, swap foreground and background. 

374 

375 """ 

376 

377 blink: bool | None = None 

378 """ 

379 If true, render blinking text. 

380 

381 """ 

382 

383 def __or__(self, other: Color, /): 

384 return Color( 

385 other.fore if other.fore is not None else self.fore, 

386 other.back if other.back is not None else self.back, 

387 other.bold if other.bold is not None else self.bold, 

388 other.dim if other.dim is not None else self.dim, 

389 other.italic if other.italic is not None else self.italic, 

390 other.underline if other.underline is not None else self.underline, 

391 other.inverse if other.inverse is not None else self.inverse, 

392 other.blink if other.blink is not None else self.blink, 

393 ) 

394 

395 def __ior__(self, other: Color, /): 

396 return self | other 

397 

398 @classmethod 

399 def fore_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color: 

400 """ 

401 Create a foreground color value from rgb components. 

402 

403 Each component should be between 0 and 255. 

404 

405 :example: 

406 :: 

407 

408 >>> Color.fore_from_rgb(0xA0, 0x1E, 0x9C) 

409 <Color fore=<ColorValue #A01E9C>> 

410 

411 """ 

412 

413 return cls(fore=ColorValue.from_rgb(r, g, b), **kwargs) 

414 

415 @classmethod 

416 def fore_from_hex(cls, h: str, **kwargs) -> Color: 

417 """ 

418 Create a foreground color value from a hex string. 

419 

420 :example: 

421 :: 

422 

423 >>> Color.fore_from_hex('#A01E9C') 

424 <Color fore=<ColorValue #A01E9C>> 

425 

426 """ 

427 

428 return cls(fore=ColorValue.from_hex(h), **kwargs) 

429 

430 @classmethod 

431 def back_from_rgb(cls, r: int, g: int, b: int, **kwargs) -> Color: 

432 """ 

433 Create a background color value from rgb components. 

434 

435 Each component should be between 0 and 255. 

436 

437 :example: 

438 :: 

439 

440 >>> Color.back_from_rgb(0xA0, 0x1E, 0x9C) 

441 <Color back=<ColorValue #A01E9C>> 

442 

443 """ 

444 

445 return cls(back=ColorValue.from_rgb(r, g, b), **kwargs) 

446 

447 @classmethod 

448 def back_from_hex(cls, h: str, **kwargs) -> Color: 

449 """ 

450 Create a background color value from a hex string. 

451 

452 :example: 

453 :: 

454 

455 >>> Color.back_from_hex('#A01E9C') 

456 <Color back=<ColorValue #A01E9C>> 

457 

458 """ 

459 

460 return cls(back=ColorValue.from_hex(h), **kwargs) 

461 

462 @staticmethod 

463 def lerp(*colors: Color) -> _t.Callable[[float], Color]: 

464 """ 

465 Return a lambda that allows linear interpolation between several colors. 

466 

467 If either color is a single ANSI escape code, the first color is always returned 

468 from the lambda. 

469 

470 :param colors: 

471 colors of a gradient. 

472 :returns: 

473 a callable that allows interpolating between colors: it accepts a float 

474 value between ``1`` and ``0`` and returns a color. 

475 :raises: 

476 :class:`ValueError` if no colors given. 

477 :example: 

478 :: 

479 

480 >>> a = Color.fore_from_hex('#A01E9C') 

481 >>> b = Color.fore_from_hex('#22C60C') 

482 >>> lerp = Color.lerp(a, b) 

483 

484 >>> lerp(0) 

485 <Color fore=<ColorValue #A01E9C>> 

486 >>> lerp(0.5) 

487 <Color fore=<ColorValue #617254>> 

488 >>> lerp(1) 

489 <Color fore=<ColorValue #22C60C>> 

490 

491 """ 

492 

493 if not colors: 

494 raise ValueError("lerp expected at least 1 argument, got 0") 

495 elif len(colors) == 1: 

496 return lambda f, /: colors[0] 

497 else: 

498 fore_lerp = all( 

499 color.fore is not None and isinstance(color.fore.data, tuple) 

500 for color in colors 

501 ) 

502 if fore_lerp: 

503 fore = ColorValue.lerp(*(color.fore for color in colors)) # type: ignore 

504 

505 back_lerp = all( 

506 color.back is not None and isinstance(color.back.data, tuple) 

507 for color in colors 

508 ) 

509 if back_lerp: 

510 back = ColorValue.lerp(*(color.back for color in colors)) # type: ignore 

511 

512 if fore_lerp and back_lerp: 

513 return lambda f: dataclasses.replace( 

514 colors[0], 

515 fore=fore(f), # type: ignore 

516 back=back(f), # type: ignore 

517 ) 

518 elif fore_lerp: 

519 return lambda f: dataclasses.replace(colors[0], fore=fore(f)) # type: ignore 

520 elif back_lerp: 

521 return lambda f: dataclasses.replace(colors[0], back=back(f)) # type: ignore 

522 else: 

523 return lambda f, /: colors[0] 

524 

525 def as_code(self, color_support: ColorSupport) -> str: 

526 """ 

527 Convert this color into an ANSI escape code with respect to the given 

528 terminal capabilities. 

529 

530 :param color_support: 

531 level of color support of a terminal. 

532 :returns: 

533 either ANSI escape code for this color or an empty string. 

534 

535 """ 

536 

537 if color_support == ColorSupport.NONE: 

538 return "" 

539 

540 codes = [] 

541 if self.fore: 

542 codes.append(self.fore._as_fore(color_support)) 

543 if self.back: 

544 codes.append(self.back._as_back(color_support)) 

545 if self.bold: 

546 codes.append("1") 

547 if self.dim: 

548 codes.append("2") 

549 if self.italic: 

550 codes.append("3") 

551 if self.underline: 

552 codes.append("4") 

553 if self.blink: 

554 codes.append("5") 

555 if self.inverse: 

556 codes.append("7") 

557 if codes: 

558 return "\x1b[;" + ";".join(codes) + "m" 

559 else: 

560 return "\x1b[m" 

561 

562 def __repr__(self): 

563 res = "<Color" 

564 for field in dataclasses.fields(self): 

565 if (value := getattr(self, field.name)) is not None: 

566 res += f" {field.name}={value!r}" 

567 res += ">" 

568 return res 

569 

570 NONE: _ClassVar[Color] = dict() # type: ignore 

571 """ 

572 No color. 

573 

574 """ 

575 

576 STYLE_BOLD: _ClassVar[Color] = dict(bold=True) # type: ignore 

577 """ 

578 Bold font style. 

579 

580 """ 

581 

582 STYLE_DIM: _ClassVar[Color] = dict(dim=True) # type: ignore 

583 """ 

584 Dim font style. 

585 

586 """ 

587 

588 STYLE_ITALIC: _ClassVar[Color] = dict(italic=True) # type: ignore 

589 """ 

590 Italic font style. 

591 

592 """ 

593 

594 STYLE_UNDERLINE: _ClassVar[Color] = dict(underline=True) # type: ignore 

595 """ 

596 Underline font style. 

597 

598 """ 

599 

600 STYLE_INVERSE: _ClassVar[Color] = dict(inverse=True) # type: ignore 

601 """ 

602 Swaps foreground and background colors. 

603 

604 """ 

605 

606 STYLE_BLINK: _ClassVar[Color] = dict(blink=True) # type: ignore 

607 """ 

608 Makes the text blink. 

609 

610 """ 

611 

612 STYLE_NORMAL: _ClassVar[Color] = dict( 

613 bold=False, 

614 dim=False, 

615 underline=False, 

616 italic=False, 

617 inverse=False, 

618 blink=False, 

619 ) # type: ignore 

620 """ 

621 Normal style. 

622 

623 """ 

624 

625 FORE_NORMAL: _ClassVar[Color] = dict(fore=ColorValue(9)) # type: ignore 

626 """ 

627 Normal foreground color. 

628 

629 """ 

630 

631 FORE_NORMAL_DIM: _ClassVar[Color] = dict(fore=ColorValue("2")) # type: ignore 

632 """ 

633 Normal foreground color rendered with dim setting. 

634 

635 This is an alternative to bright black that works with 

636 most terminals and color schemes. 

637 

638 """ 

639 

640 FORE_BLACK: _ClassVar[Color] = dict(fore=ColorValue(0)) # type: ignore 

641 """ 

642 Black foreground color. 

643 

644 .. warning:: 

645 

646 Avoid using this color, in most terminals it is the same as background color. 

647 Instead, use :attr:`~Color.FORE_NORMAL_DIM`. 

648 

649 """ 

650 

651 FORE_RED: _ClassVar[Color] = dict(fore=ColorValue(1)) # type: ignore 

652 """ 

653 Red foreground color. 

654 

655 """ 

656 

657 FORE_GREEN: _ClassVar[Color] = dict(fore=ColorValue(2)) # type: ignore 

658 """ 

659 Green foreground color. 

660 

661 """ 

662 

663 FORE_YELLOW: _ClassVar[Color] = dict(fore=ColorValue(3)) # type: ignore 

664 """ 

665 Yellow foreground color. 

666 

667 """ 

668 

669 FORE_BLUE: _ClassVar[Color] = dict(fore=ColorValue(4)) # type: ignore 

670 """ 

671 Blue foreground color. 

672 

673 """ 

674 

675 FORE_MAGENTA: _ClassVar[Color] = dict(fore=ColorValue(5)) # type: ignore 

676 """ 

677 Magenta foreground color. 

678 

679 """ 

680 

681 FORE_CYAN: _ClassVar[Color] = dict(fore=ColorValue(6)) # type: ignore 

682 """ 

683 Cyan foreground color. 

684 

685 """ 

686 

687 FORE_WHITE: _ClassVar[Color] = dict(fore=ColorValue(7)) # type: ignore 

688 """ 

689 White foreground color. 

690 

691 .. warning:: 

692 

693 Avoid using it. In some terminals, notably in the Mac OS default terminal, 

694 it is unreadable. 

695 

696 """ 

697 

698 BACK_NORMAL: _ClassVar[Color] = dict(back=ColorValue(9)) # type: ignore 

699 """ 

700 Normal background color. 

701 

702 """ 

703 

704 BACK_BLACK: _ClassVar[Color] = dict(back=ColorValue(0)) # type: ignore 

705 """ 

706 Black background color. 

707 

708 """ 

709 

710 BACK_RED: _ClassVar[Color] = dict(back=ColorValue(1)) # type: ignore 

711 """ 

712 Red background color. 

713 

714 """ 

715 

716 BACK_GREEN: _ClassVar[Color] = dict(back=ColorValue(2)) # type: ignore 

717 """ 

718 Green background color. 

719 

720 """ 

721 

722 BACK_YELLOW: _ClassVar[Color] = dict(back=ColorValue(3)) # type: ignore 

723 """ 

724 Yellow background color. 

725 

726 """ 

727 

728 BACK_BLUE: _ClassVar[Color] = dict(back=ColorValue(4)) # type: ignore 

729 """ 

730 Blue background color. 

731 

732 """ 

733 

734 BACK_MAGENTA: _ClassVar[Color] = dict(back=ColorValue(5)) # type: ignore 

735 """ 

736 Magenta background color. 

737 

738 """ 

739 

740 BACK_CYAN: _ClassVar[Color] = dict(back=ColorValue(6)) # type: ignore 

741 """ 

742 Cyan background color. 

743 

744 """ 

745 

746 BACK_WHITE: _ClassVar[Color] = dict(back=ColorValue(7)) # type: ignore 

747 """ 

748 White background color. 

749 

750 """ 

751 

752 

753for _n, _v in vars(Color).items(): 

754 if _n == _n.upper(): 

755 setattr(Color, _n, Color(**_v)) 

756del _n, _v # type: ignore 

757 

758 

759def _parse_hex(h: str) -> tuple[int, int, int]: 

760 if not re.match(r"^#[0-9a-fA-F]{6}$", h): 

761 raise ValueError(f"invalid hex string {h!r}") 

762 return tuple(int(h[i : i + 2], 16) for i in (1, 3, 5)) # type: ignore 

763 

764 

765class ColorSupport(enum.IntEnum): 

766 """ 

767 Terminal's capability for coloring output. 

768 

769 """ 

770 

771 NONE = 0 

772 """ 

773 yuio.color.Color codes are not supported. 

774 

775 """ 

776 

777 ANSI = 1 

778 """ 

779 Only simple 8-bit color codes are supported. 

780 

781 """ 

782 

783 ANSI_256 = 2 

784 """ 

785 256-encoded colors are supported. 

786 

787 """ 

788 

789 ANSI_TRUE = 3 

790 """ 

791 True colors are supported. 

792 

793 """ 

794 

795 def __repr__(self) -> str: 

796 return self.name 

797 

798 

799def _rgb_to_256(r: int, g: int, b: int) -> int: 

800 closest_idx = lambda x, vals: min((abs(x - v), i) for i, v in enumerate(vals))[1] 

801 color_components = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF] 

802 

803 if r == g == b: 

804 i = closest_idx(r, color_components + [0x08 + 10 * i for i in range(24)]) 

805 if i >= len(color_components): 

806 return 232 + i - len(color_components) 

807 r, g, b = i, i, i 

808 else: 

809 r, g, b = (closest_idx(x, color_components) for x in (r, g, b)) 

810 return r * 36 + g * 6 + b + 16 

811 

812 

813def _rgb_to_8(r: int, g: int, b: int) -> int: 

814 return ( 

815 (1 if r >= 128 else 0) 

816 | (1 if g >= 128 else 0) << 1 

817 | (1 if b >= 128 else 0) << 2 

818 )