Coverage for yuio / color.py: 87%

248 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-04 10:05 +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 

33import typing 

34from dataclasses import dataclass 

35 

36from yuio import _typing as _t 

37 

38__all__ = [ 

39 "Color", 

40 "ColorSupport", 

41 "ColorValue", 

42] 

43 

44 

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

46class ColorValue: 

47 """ 

48 Data about a single color. 

49 

50 """ 

51 

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

53 """ 

54 Color data. 

55 

56 Can be one of three things: 

57 

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

59 

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

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

62 

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

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

65 

66 - an RGB-tuple represents a true color. 

67 

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

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

70 

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

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

73 

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

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

76 

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

78 

79 """ 

80 

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

82 

83 @classmethod 

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

85 """ 

86 Create a color value from rgb components. 

87 

88 Each component should be between 0 and 255. 

89 

90 :example: 

91 :: 

92 

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

94 <ColorValue #A01E9C> 

95 

96 """ 

97 

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

99 

100 @classmethod 

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

102 """ 

103 Create a color value from a hex string. 

104 

105 :example: 

106 :: 

107 

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

109 <ColorValue #A01E9C> 

110 

111 """ 

112 

113 return cls(_parse_hex(h)) 

114 

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

116 """ 

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

118 

119 :example: 

120 :: 

121 

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

123 >>> a.to_hex() 

124 '#A01E9C' 

125 

126 """ 

127 

128 rgb = self.to_rgb() 

129 if rgb is not None: 

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

131 else: 

132 return None 

133 

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

135 """ 

136 Return RGB components of the color. 

137 

138 :example: 

139 :: 

140 

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

142 >>> a.to_rgb() 

143 (160, 30, 156) 

144 

145 """ 

146 

147 if isinstance(self.data, tuple): 

148 return self.data 

149 else: 

150 return None 

151 

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

153 """ 

154 Make this color darker by the given percentage. 

155 

156 Amount should be between 0 and 1. 

157 

158 :example: 

159 :: 

160 

161 >>> # Darken by 30%. 

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

163 <ColorValue #70156D> 

164 

165 """ 

166 

167 rgb = self.to_rgb() 

168 if rgb is None: 

169 return self 

170 

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

172 r, g, b = rgb 

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

174 v = v - v * amount 

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

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

177 

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

179 """ 

180 Make this color lighter by the given percentage. 

181 

182 Amount should be between 0 and 1. 

183 

184 :example: 

185 :: 

186 

187 >>> # Lighten by 30%. 

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

189 <ColorValue #BC23B7> 

190 

191 """ 

192 

193 rgb = self.to_rgb() 

194 if rgb is None: 

195 return self 

196 

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

198 r, g, b = rgb 

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

200 v = 1 - v 

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

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

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

204 

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

206 """ 

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

208 

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

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

211 

212 """ 

213 

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

215 if rgb1 is None or rgb2 is None: 

216 return self 

217 

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

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

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

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

222 

223 @staticmethod 

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

225 """ 

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

227 

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

229 from the lambda. 

230 

231 :param colors: 

232 colors of a gradient. 

233 :returns: 

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

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

236 :raises: 

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

238 :example: 

239 :: 

240 

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

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

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

244 

245 >>> lerp(0) 

246 <ColorValue #A01E9C> 

247 >>> lerp(0.5) 

248 <ColorValue #617254> 

249 >>> lerp(1) 

250 <ColorValue #22C60C> 

251 

252 """ 

253 

254 if not colors: 

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

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

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

258 ): 

259 return lambda f, /: colors[0] 

260 else: 

261 l = len(colors) - 1 

262 

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

264 i = int(f * l) 

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

266 

267 if i == l: 

268 return colors[l] 

269 else: 

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

271 return ColorValue( 

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

273 ) 

274 

275 return lerp 

276 

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

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

279 

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

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

282 

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

284 if color_support == ColorSupport.NONE: 

285 return "" 

286 elif isinstance(self.data, int): 

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

288 elif isinstance(self.data, str): 

289 return self.data 

290 elif color_support == ColorSupport.ANSI_TRUE: 

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

292 elif color_support == ColorSupport.ANSI_256: 

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

294 else: 

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

296 

297 def __repr__(self) -> str: 

298 if isinstance(self.data, tuple): 

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

300 elif isinstance(self.data, int): 

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

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

303 else: 

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

305 else: 

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

307 

308 

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

310class Color: 

311 """ 

312 Data about terminal output style. Contains 

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

314 

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

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

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

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

319 

320 Colors can be combined before printing, though:: 

321 

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

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

324 

325 Yuio supports true RGB colors. They are automatically converted 

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

327 

328 """ 

329 

330 fore: ColorValue | None = None 

331 """ 

332 Foreground color. 

333 

334 """ 

335 

336 back: ColorValue | None = None 

337 """ 

338 Background color. 

339 

340 """ 

341 

342 bold: bool | None = None 

343 """ 

344 If true, render text as bold. 

345 

346 """ 

347 

348 dim: bool | None = None 

349 """ 

350 If true, render text as dim. 

351 

352 """ 

353 

354 italic: bool | None = None 

355 """ 

356 If true, render text in italic font. 

357 

358 """ 

359 

360 underline: bool | None = None 

361 """ 

362 If true, render underline the text. 

363 

364 """ 

365 

366 inverse: bool | None = None 

367 """ 

368 If true, swap foreground and background. 

369 

370 """ 

371 

372 blink: bool | None = None 

373 """ 

374 If true, render blinking text. 

375 

376 """ 

377 

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

379 return Color( 

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

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

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

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

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

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

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

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

388 ) 

389 

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

391 return self | other 

392 

393 @classmethod 

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

395 """ 

396 Create a foreground color value from rgb components. 

397 

398 Each component should be between 0 and 255. 

399 

400 :example: 

401 :: 

402 

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

404 <Color fore=<ColorValue #A01E9C>> 

405 

406 """ 

407 

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

409 

410 @classmethod 

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

412 """ 

413 Create a foreground color value from a hex string. 

414 

415 :example: 

416 :: 

417 

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

419 <Color fore=<ColorValue #A01E9C>> 

420 

421 """ 

422 

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

424 

425 @classmethod 

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

427 """ 

428 Create a background color value from rgb components. 

429 

430 Each component should be between 0 and 255. 

431 

432 :example: 

433 :: 

434 

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

436 <Color back=<ColorValue #A01E9C>> 

437 

438 """ 

439 

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

441 

442 @classmethod 

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

444 """ 

445 Create a background color value from a hex string. 

446 

447 :example: 

448 :: 

449 

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

451 <Color back=<ColorValue #A01E9C>> 

452 

453 """ 

454 

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

456 

457 @staticmethod 

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

459 """ 

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

461 

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

463 from the lambda. 

464 

465 :param colors: 

466 colors of a gradient. 

467 :returns: 

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

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

470 :raises: 

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

472 :example: 

473 :: 

474 

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

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

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

478 

479 >>> lerp(0) 

480 <Color fore=<ColorValue #A01E9C>> 

481 >>> lerp(0.5) 

482 <Color fore=<ColorValue #617254>> 

483 >>> lerp(1) 

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

485 

486 """ 

487 

488 if not colors: 

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

490 elif len(colors) == 1: 

491 return lambda f, /: colors[0] 

492 else: 

493 fore_lerp = all( 

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

495 for color in colors 

496 ) 

497 if fore_lerp: 

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

499 

500 back_lerp = all( 

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

502 for color in colors 

503 ) 

504 if back_lerp: 

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

506 

507 if fore_lerp and back_lerp: 

508 return lambda f: dataclasses.replace( 

509 colors[0], 

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

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

512 ) 

513 elif fore_lerp: 

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

515 elif back_lerp: 

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

517 else: 

518 return lambda f, /: colors[0] 

519 

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

521 """ 

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

523 terminal capabilities. 

524 

525 :param color_support: 

526 level of color support of a terminal. 

527 :returns: 

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

529 

530 """ 

531 

532 if color_support == ColorSupport.NONE: 

533 return "" 

534 

535 codes = [] 

536 if self.fore: 

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

538 if self.back: 

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

540 if self.bold: 

541 codes.append("1") 

542 if self.dim: 

543 codes.append("2") 

544 if self.italic: 

545 codes.append("3") 

546 if self.underline: 

547 codes.append("4") 

548 if self.blink: 

549 codes.append("5") 

550 if self.inverse: 

551 codes.append("7") 

552 if codes: 

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

554 else: 

555 return "\x1b[m" 

556 

557 def __repr__(self): 

558 res = "<Color" 

559 for field in dataclasses.fields(self): 

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

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

562 res += ">" 

563 return res 

564 

565 NONE: typing.ClassVar[Color] = dict() # type: ignore 

566 """ 

567 No color. 

568 

569 """ 

570 

571 STYLE_BOLD: typing.ClassVar[Color] = dict(bold=True) # type: ignore 

572 """ 

573 Bold font style. 

574 

575 """ 

576 

577 STYLE_DIM: typing.ClassVar[Color] = dict(dim=True) # type: ignore 

578 """ 

579 Dim font style. 

580 

581 """ 

582 

583 STYLE_ITALIC: typing.ClassVar[Color] = dict(italic=True) # type: ignore 

584 """ 

585 Italic font style. 

586 

587 """ 

588 

589 STYLE_UNDERLINE: typing.ClassVar[Color] = dict(underline=True) # type: ignore 

590 """ 

591 Underline font style. 

592 

593 """ 

594 

595 STYLE_INVERSE: typing.ClassVar[Color] = dict(inverse=True) # type: ignore 

596 """ 

597 Swaps foreground and background colors. 

598 

599 """ 

600 

601 STYLE_BLINK: typing.ClassVar[Color] = dict(blink=True) # type: ignore 

602 """ 

603 Makes the text blink. 

604 

605 """ 

606 

607 STYLE_NORMAL: typing.ClassVar[Color] = dict( 

608 bold=False, 

609 dim=False, 

610 underline=False, 

611 italic=False, 

612 inverse=False, 

613 blink=False, 

614 ) # type: ignore 

615 """ 

616 Normal style. 

617 

618 """ 

619 

620 FORE_NORMAL: typing.ClassVar[Color] = dict(fore=ColorValue(9)) # type: ignore 

621 """ 

622 Normal foreground color. 

623 

624 """ 

625 

626 FORE_NORMAL_DIM: typing.ClassVar[Color] = dict(fore=ColorValue("2")) # type: ignore 

627 """ 

628 Normal foreground color rendered with dim setting. 

629 

630 This is an alternative to bright black that works with 

631 most terminals and color schemes. 

632 

633 """ 

634 

635 FORE_BLACK: typing.ClassVar[Color] = dict(fore=ColorValue(0)) # type: ignore 

636 """ 

637 Black foreground color. 

638 

639 .. warning:: 

640 

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

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

643 

644 """ 

645 

646 FORE_RED: typing.ClassVar[Color] = dict(fore=ColorValue(1)) # type: ignore 

647 """ 

648 Red foreground color. 

649 

650 """ 

651 

652 FORE_GREEN: typing.ClassVar[Color] = dict(fore=ColorValue(2)) # type: ignore 

653 """ 

654 Green foreground color. 

655 

656 """ 

657 

658 FORE_YELLOW: typing.ClassVar[Color] = dict(fore=ColorValue(3)) # type: ignore 

659 """ 

660 Yellow foreground color. 

661 

662 """ 

663 

664 FORE_BLUE: typing.ClassVar[Color] = dict(fore=ColorValue(4)) # type: ignore 

665 """ 

666 Blue foreground color. 

667 

668 """ 

669 

670 FORE_MAGENTA: typing.ClassVar[Color] = dict(fore=ColorValue(5)) # type: ignore 

671 """ 

672 Magenta foreground color. 

673 

674 """ 

675 

676 FORE_CYAN: typing.ClassVar[Color] = dict(fore=ColorValue(6)) # type: ignore 

677 """ 

678 Cyan foreground color. 

679 

680 """ 

681 

682 FORE_WHITE: typing.ClassVar[Color] = dict(fore=ColorValue(7)) # type: ignore 

683 """ 

684 White foreground color. 

685 

686 .. warning:: 

687 

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

689 it is unreadable. 

690 

691 """ 

692 

693 BACK_NORMAL: typing.ClassVar[Color] = dict(back=ColorValue(9)) # type: ignore 

694 """ 

695 Normal background color. 

696 

697 """ 

698 

699 BACK_BLACK: typing.ClassVar[Color] = dict(back=ColorValue(0)) # type: ignore 

700 """ 

701 Black background color. 

702 

703 """ 

704 

705 BACK_RED: typing.ClassVar[Color] = dict(back=ColorValue(1)) # type: ignore 

706 """ 

707 Red background color. 

708 

709 """ 

710 

711 BACK_GREEN: typing.ClassVar[Color] = dict(back=ColorValue(2)) # type: ignore 

712 """ 

713 Green background color. 

714 

715 """ 

716 

717 BACK_YELLOW: typing.ClassVar[Color] = dict(back=ColorValue(3)) # type: ignore 

718 """ 

719 Yellow background color. 

720 

721 """ 

722 

723 BACK_BLUE: typing.ClassVar[Color] = dict(back=ColorValue(4)) # type: ignore 

724 """ 

725 Blue background color. 

726 

727 """ 

728 

729 BACK_MAGENTA: typing.ClassVar[Color] = dict(back=ColorValue(5)) # type: ignore 

730 """ 

731 Magenta background color. 

732 

733 """ 

734 

735 BACK_CYAN: typing.ClassVar[Color] = dict(back=ColorValue(6)) # type: ignore 

736 """ 

737 Cyan background color. 

738 

739 """ 

740 

741 BACK_WHITE: typing.ClassVar[Color] = dict(back=ColorValue(7)) # type: ignore 

742 """ 

743 White background color. 

744 

745 """ 

746 

747 

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

749 if _n == _n.upper(): 

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

751del _n, _v # type: ignore 

752 

753 

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

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

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

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

758 

759 

760class ColorSupport(enum.IntEnum): 

761 """ 

762 Terminal's capability for coloring output. 

763 

764 """ 

765 

766 NONE = 0 

767 """ 

768 yuio.color.Color codes are not supported. 

769 

770 """ 

771 

772 ANSI = 1 

773 """ 

774 Only simple 8-bit color codes are supported. 

775 

776 """ 

777 

778 ANSI_256 = 2 

779 """ 

780 256-encoded colors are supported. 

781 

782 """ 

783 

784 ANSI_TRUE = 3 

785 """ 

786 True colors are supported. 

787 

788 """ 

789 

790 

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

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

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

794 

795 if r == g == b: 

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

797 if i >= len(color_components): 

798 return 232 + i - len(color_components) 

799 r, g, b = i, i, i 

800 else: 

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

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

803 

804 

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

806 return ( 

807 (1 if r >= 128 else 0) 

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

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

810 )