Files
Bolt-Console/bolt_console/gui/icons.py
T
themajesticmagicianandClaude Opus 5.5 3977804ae3 Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:

- server_client.list_chats() / create_chat(), same shape and error
  handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
  SESSION_ID every call reads, persisted to session.json. The window
  refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
  populated on open, with Refresh and New Chat. Network calls run as
  QRunnables on the global QThreadPool and come back through Signals,
  never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
  past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
  fallback).

This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.

Build output (build/, dist/, Release/, *.spec) is now ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:50:10 -06:00

220 lines
7.8 KiB
Python

"""Icons drawn with QPainter rather than shipped as files or borrowed from an
emoji font — crisp at any DPI, recolourable, and no assets to lose.
`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)`
wraps that as a QIcon; `IconLabel` is a tiny widget that shows one.
"""
from __future__ import annotations
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import QWidget
def _pen(colour: QColor, width: float) -> QPen:
pen = QPen(colour, width)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
return pen
def paint(p: QPainter, name: str, rect: QRectF, colour) -> None:
colour = QColor(colour)
p.save()
p.setRenderHint(QPainter.Antialiasing)
# Everything below is drawn on a 24x24 grid, then scaled into *rect*.
side = min(rect.width(), rect.height())
p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2)
p.scale(side / 24.0, side / 24.0)
stroke = _pen(colour, 1.9)
if name == "bolt":
path = QPainterPath()
for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22),
(19.5, 9.5), (13, 9.5), (13.5, 2)]):
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawPath(path)
elif name == "send":
p.setPen(_pen(colour, 2.4))
p.drawLine(QPointF(12, 19), QPointF(12, 5.5))
p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5))
p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5))
elif name == "folder":
p.setPen(stroke)
path = QPainterPath()
path.moveTo(3, 7)
path.lineTo(3, 18.5)
path.quadTo(3, 20, 4.5, 20)
path.lineTo(19.5, 20)
path.quadTo(21, 20, 21, 18.5)
path.lineTo(21, 9.5)
path.quadTo(21, 8, 19.5, 8)
path.lineTo(11.5, 8)
path.lineTo(9.5, 5.5)
path.lineTo(4.5, 5.5)
path.quadTo(3, 5.5, 3, 7)
p.drawPath(path)
elif name == "shell":
p.setPen(stroke)
p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3)
p.drawLine(QPointF(7, 10), QPointF(10, 12.5))
p.drawLine(QPointF(10, 12.5), QPointF(7, 15))
p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5))
elif name in ("read", "write", "edit"):
p.setPen(stroke)
page = QPainterPath()
page.moveTo(14, 2.5)
page.lineTo(6, 2.5)
page.quadTo(4.5, 2.5, 4.5, 4)
page.lineTo(4.5, 20)
page.quadTo(4.5, 21.5, 6, 21.5)
page.lineTo(18, 21.5)
page.quadTo(19.5, 21.5, 19.5, 20)
page.lineTo(19.5, 8)
page.closeSubpath()
p.drawPath(page)
p.drawLine(QPointF(14, 2.5), QPointF(14, 8))
p.drawLine(QPointF(14, 8), QPointF(19.5, 8))
if name == "read":
for y in (12, 15, 18):
p.drawLine(QPointF(8, y), QPointF(16, y))
elif name == "write":
p.drawLine(QPointF(12, 11.5), QPointF(12, 18))
p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75))
else:
p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5))
p.drawLine(QPointF(8, 18), QPointF(8, 15.5))
p.drawLine(QPointF(8, 18), QPointF(10.5, 18))
elif name == "list":
p.setPen(stroke)
for y in (6.5, 12, 17.5):
p.drawPoint(QPointF(4.5, y))
p.drawLine(QPointF(8.5, y), QPointF(20, y))
elif name == "blocked":
p.setPen(stroke)
p.drawEllipse(QRectF(3.5, 3.5, 17, 17))
p.drawLine(QPointF(7, 17), QPointF(17, 7))
elif name == "chevron":
p.setPen(stroke)
p.drawLine(QPointF(9, 6), QPointF(15, 12))
p.drawLine(QPointF(15, 12), QPointF(9, 18))
elif name == "chats":
p.setPen(stroke)
bubble = QPainterPath()
bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4)
p.drawPath(bubble)
p.drawLine(QPointF(8, 17), QPointF(7, 21))
p.drawLine(QPointF(7, 21), QPointF(12, 17))
for x in (8, 12, 16):
p.drawPoint(QPointF(x, 10.5))
elif name == "mic":
p.setPen(stroke)
p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3)
arc = QPainterPath()
arc.moveTo(5.5, 11)
arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11)
p.drawPath(arc)
p.drawLine(QPointF(12, 17), QPointF(12, 21))
elif name == "stop":
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
elif name == "plus":
p.setPen(_pen(colour, 2.1))
p.drawLine(QPointF(12, 5), QPointF(12, 19))
p.drawLine(QPointF(5, 12), QPointF(19, 12))
elif name == "file":
paint(p, "read", QRectF(0, 0, 24, 24), colour)
p.restore()
def icon(name: str, colour, size: int = 32) -> QIcon:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
paint(painter, name, QRectF(0, 0, size, size), colour)
painter.end()
return QIcon(pixmap)
APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
def app_icon_image(size: int) -> QImage:
"""An amber rounded square with a dark bolt, at *size* pixels. The one
drawing behind both the window icon and the .exe's icon (write_ico)."""
image = QImage(size, size, QImage.Format_ARGB32_Premultiplied)
image.fill(Qt.transparent)
p = QPainter(image)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor("#f5b83d"))
p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26)
inset = size * 0.18
paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305")
p.end()
return image
def app_icon() -> QIcon:
"""The window/taskbar icon."""
result = QIcon()
for size in APP_ICON_SIZES:
result.addPixmap(QPixmap.fromImage(app_icon_image(size)))
return result
def write_ico(path) -> None:
"""Save the app icon as a multi-size Windows .ico (PNG-compressed entries,
which Windows Vista+ reads at every size), for the frozen .exe."""
import struct
blobs = []
for size in APP_ICON_SIZES:
data = QByteArray()
buffer = QBuffer(data)
buffer.open(QIODevice.WriteOnly)
app_icon_image(size).save(buffer, "PNG")
buffer.close()
blobs.append((size, bytes(data)))
header = struct.pack("<HHH", 0, 1, len(blobs)) # reserved, type 1 = icon, count
offset = len(header) + 16 * len(blobs)
entries, payload = b"", b""
for size, blob in blobs:
edge = 0 if size >= 256 else size # 0 means 256 in the ICO format
entries += struct.pack("<BBBBHHII", edge, edge, 0, 0, 1, 32, len(blob), offset + len(payload))
payload += blob
with open(path, "wb") as handle:
handle.write(header + entries + payload)
class IconLabel(QWidget):
"""A fixed-size painted icon, optionally on a rounded tile."""
def __init__(self, name: str, colour, size: int = 18, tile=None, parent=None):
super().__init__(parent)
self._name, self._colour, self._tile = name, QColor(colour), tile
self.setFixedSize(QSize(size, size))
def set_icon(self, name: str, colour=None) -> None:
self._name = name
if colour is not None:
self._colour = QColor(colour)
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = QRectF(self.rect())
if self._tile is not None:
p.setPen(Qt.NoPen)
p.setBrush(QColor(self._tile))
p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28)
pad = rect.width() * 0.22
rect = rect.adjusted(pad, pad, -pad, -pad)
paint(p, self._name, rect, self._colour)