From 72c06ed46599b3ba4ddac43b933999e7fc9c175a Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sat, 19 Sep 2026 21:33:59 +0200 Subject: [PATCH] feat UI full redesign --- gui.py | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 323 insertions(+), 37 deletions(-) diff --git a/gui.py b/gui.py index 6516256..953d540 100644 --- a/gui.py +++ b/gui.py @@ -6,15 +6,24 @@ from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer from pygments.util import ClassNotFound -from PyQt6.QtCore import QThread, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal from PyQt6.QtGui import ( QColor, QFont, + QIcon, + QLinearGradient, + QPainter, + QPainterPath, + QPalette, + QPixmap, QTextBlockFormat, QTextCharFormat, QTextCursor, QTextDocument, QTextDocumentFragment, + QTextLength, + QTextTableCellFormat, + QTextTableFormat, ) from PyQt6.QtWidgets import ( QApplication, @@ -33,6 +42,198 @@ import chatbot # How often the bottom-right GPU/VRAM readout refreshes. GPU_STATUS_INTERVAL_MS = 1000 +# --- Visual theme ------------------------------------------------------ +# An explicit dark palette rather than relying on the OS theme, so the app +# looks the same everywhere instead of "Qt follows Windows' dark app theme". +BG_APP = "#0b0d12" +BG_PANEL = "#12151c" +BG_ELEVATED = "#181c25" +BORDER = "#262b36" +BORDER_STRONG = "#333949" +TEXT_PRIMARY = "#e6e8ee" +TEXT_SECONDARY = "#8b92a5" +ACCENT = "#6366f1" +ACCENT_HOVER = "#7b7ef4" +ACCENT_PRESSED = "#4f52d6" + +# (accent, background) per chat role, used for the accent stripe and tinted +# card background of each message. +ROLE_STYLES = { + "user": ("#60a5fa", "#141b2b"), + "assistant": ("#34d399", "#0f211c"), + "system": ("#9aa1b4", "#161922"), +} + +_STYLESHEET = f""" +QMainWindow {{ + background-color: {BG_APP}; +}} + +QWidget {{ + color: {TEXT_PRIMARY}; + font-family: "Segoe UI Variable Text", "Segoe UI", sans-serif; + font-size: 10.5pt; +}} + +#header {{ + border-bottom: 1px solid {BORDER}; +}} + +#titleLabel {{ + font-size: 15pt; + font-weight: 600; + color: {TEXT_PRIMARY}; +}} + +#subtitleLabel {{ + font-size: 9pt; + color: {TEXT_SECONDARY}; +}} + +#badge {{ + background-color: {BG_ELEVATED}; + border: 1px solid {BORDER}; + border-radius: 11px; + padding: 4px 12px; + color: {TEXT_SECONDARY}; + font-size: 9pt; +}} + +QTextBrowser#chatView {{ + background-color: {BG_PANEL}; + border: 1px solid {BORDER}; + border-radius: 14px; + padding: 10px; +}} + +QLineEdit#messageInput {{ + background-color: {BG_ELEVATED}; + border: 1px solid {BORDER}; + border-radius: 19px; + padding: 0 16px; + color: {TEXT_PRIMARY}; + selection-background-color: {ACCENT}; +}} + +QLineEdit#messageInput:focus {{ + border: 1px solid {ACCENT}; +}} + +QLineEdit#messageInput:disabled {{ + color: {TEXT_SECONDARY}; +}} + +QPushButton#sendButton {{ + background-color: {ACCENT}; + color: #ffffff; + border: none; + border-radius: 19px; + padding: 0 22px; + font-weight: 600; +}} + +QPushButton#sendButton:hover {{ + background-color: {ACCENT_HOVER}; +}} + +QPushButton#sendButton:pressed {{ + background-color: {ACCENT_PRESSED}; +}} + +QPushButton#sendButton:disabled {{ + background-color: {BG_ELEVATED}; + color: {TEXT_SECONDARY}; + border: 1px solid {BORDER}; +}} + +QStatusBar {{ + background-color: {BG_APP}; + border-top: 1px solid {BORDER}; + color: {TEXT_SECONDARY}; + font-size: 9pt; +}} + +QStatusBar::item {{ + border: none; +}} + +QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 4px 2px 4px 0; +}} + +QScrollBar::handle:vertical {{ + background: {BORDER_STRONG}; + border-radius: 5px; + min-height: 24px; +}} + +QScrollBar::handle:vertical:hover {{ + background: {TEXT_SECONDARY}; +}} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; +}} + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; +}} + +QToolTip {{ + background-color: {BG_ELEVATED}; + color: {TEXT_PRIMARY}; + border: 1px solid {BORDER}; + padding: 4px 8px; +}} +""" + + +def _dark_palette() -> QPalette: + """Baseline QPalette for the Fusion style, so anything the stylesheet + doesn't reach (native dialogs, menus) still matches the dark theme.""" + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(BG_APP)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.Base, QColor(BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(BG_PANEL)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.Text, QColor(TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_SECONDARY)) + palette.setColor(QPalette.ColorRole.Button, QColor(BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(ACCENT)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) + return palette + + +def _make_app_icon() -> QIcon: + """Small generated glyph for the window/taskbar icon, since the project + ships no icon asset.""" + size = 64 + pixmap = QPixmap(size, size) + pixmap.fill(QColor(0, 0, 0, 0)) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + gradient = QLinearGradient(0, 0, size, size) + gradient.setColorAt(0.0, QColor(ACCENT_HOVER)) + gradient.setColorAt(1.0, QColor(ACCENT_PRESSED)) + + path = QPainterPath() + path.addRoundedRect(2, 2, size - 4, size - 4, 16, 16) + painter.fillPath(path, gradient) + + painter.setPen(QColor("#ffffff")) + painter.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "D") + painter.end() + + return QIcon(pixmap) + # GitHub dialect adds fenced code blocks with language tags, tables, and # strikethrough on top of CommonMark; NoHTML stops raw HTML in the model's # output from being interpreted as literal markup in the chat view. @@ -103,6 +304,48 @@ def _reset_cursor_format(cursor: QTextCursor) -> None: cursor.insertBlock(QTextBlockFormat(), QTextCharFormat()) +def _insert_message_card(cursor: QTextCursor, accent: str, bg: str) -> QTextCursor: + """Insert a flat card with a colored accent stripe for one chat message, + and return a cursor positioned inside it, ready for content. + + QTextBrowser's HTML subset has no border-radius, so a rounded chat bubble + isn't achievable directly; a two-column table (a thin accent-colored + strip beside a tinted message body) stands in for one. Each message gets + its own table/cell, so - unlike the previous shared-cursor approach - + there's no risk of one message's formatting bleeding into the next. + """ + table_format = QTextTableFormat() + table_format.setCellPadding(0) + table_format.setCellSpacing(0) + table_format.setBorder(0) + table_format.setWidth(QTextLength(QTextLength.Type.PercentageLength, 100)) + table_format.setColumnWidthConstraints( + [ + QTextLength(QTextLength.Type.FixedLength, 3), + QTextLength(QTextLength.Type.PercentageLength, 100), + ] + ) + table_format.setTopMargin(2) + table_format.setBottomMargin(10) + + table = cursor.insertTable(1, 2, table_format) + + strip_format = QTextTableCellFormat() + strip_format.setBackground(QColor(accent)) + table.cellAt(0, 0).setFormat(strip_format) + + body_format = QTextTableCellFormat() + body_format.setBackground(QColor(bg)) + body_format.setTopPadding(10) + body_format.setBottomPadding(10) + body_format.setLeftPadding(14) + body_format.setRightPadding(14) + body_cell = table.cellAt(0, 1) + body_cell.setFormat(body_format) + + return body_cell.firstCursorPosition() + + class ModelLoaderThread(QThread): loaded = pyqtSignal(object) failed = pyqtSignal(str) @@ -155,30 +398,66 @@ class ChatWindow(QMainWindow): central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(14) + + header = QWidget() + header.setObjectName("header") + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(4, 0, 4, 12) + header_layout.setSpacing(12) + + title_box = QVBoxLayout() + title_box.setSpacing(0) + title_label = QLabel("Dev Assist") + title_label.setObjectName("titleLabel") + subtitle_label = QLabel("Local LLM · tool-augmented") + subtitle_label.setObjectName("subtitleLabel") + title_box.addWidget(title_label) + title_box.addWidget(subtitle_label) + header_layout.addLayout(title_box) + header_layout.addStretch(1) + + self._n_ctx = 0 + self.context_status_label = QLabel("Context: --") + self.context_status_label.setObjectName("badge") + header_layout.addWidget(self.context_status_label) + + self.gpu_status_label = QLabel("GPU: --") + self.gpu_status_label.setObjectName("badge") + header_layout.addWidget(self.gpu_status_label) + + layout.addWidget(header) self.chat_view = QTextBrowser() + self.chat_view.setObjectName("chatView") self.chat_view.setOpenExternalLinks(True) - self.chat_view.setFont(QFont("Segoe UI", 10)) + self.chat_view.setFont(QFont("Segoe UI Variable Text", 10)) layout.addWidget(self.chat_view) input_row = QHBoxLayout() + input_row.setSpacing(10) + self.input_box = QLineEdit() + self.input_box.setObjectName("messageInput") self.input_box.setPlaceholderText("Ask a development question...") + self.input_box.setMinimumHeight(40) + input_palette = self.input_box.palette() + input_palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_SECONDARY)) + self.input_box.setPalette(input_palette) self.input_box.returnPressed.connect(self._on_send) input_row.addWidget(self.input_box) - self.send_button = QPushButton("Send") + self.send_button = QPushButton("Send →") + self.send_button.setObjectName("sendButton") + self.send_button.setMinimumHeight(40) + self.send_button.setMinimumWidth(96) + self.send_button.setCursor(Qt.CursorShape.PointingHandCursor) self.send_button.clicked.connect(self._on_send) input_row.addWidget(self.send_button) layout.addLayout(input_row) - self._n_ctx = 0 - self.context_status_label = QLabel("Context: --") - self.statusBar().addPermanentWidget(self.context_status_label) - - self.gpu_status_label = QLabel() - self.statusBar().addPermanentWidget(self.gpu_status_label) self._init_gpu_monitor() self.statusBar().showMessage("Loading model...") @@ -281,66 +560,69 @@ class ChatWindow(QMainWindow): self._set_input_enabled(True) def _append_user(self, text: str) -> None: - self._append_block("You", text, "#2b6cb0") + accent, bg = ROLE_STYLES["user"] + self._append_block("You", text, accent, bg) def _append_assistant(self, text: str) -> None: - self._append_markdown_block("Assistant", text, "#2f855a") + accent, bg = ROLE_STYLES["assistant"] + self._append_markdown_block("Assistant", text, accent, bg) def _append_system(self, text: str) -> None: - self._append_block("System", text, "#718096") + accent, bg = ROLE_STYLES["system"] + self._append_block("System", text, accent, bg) - def _append_block(self, label: str, text: str, color: str) -> None: + def _append_block(self, label: str, text: str, accent: str, bg: str) -> None: escaped = ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\n", "
") ) - self.chat_view.append(f'

{label}:
{escaped}

') - self.chat_view.moveCursor(QTextCursor.MoveOperation.End) - - def _append_markdown_block(self, label: str, markdown_text: str, color: str) -> None: cursor = self.chat_view.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) - # Force a fresh, format-reset paragraph before the label - moving to - # End only puts the cursor after the previous message's last - # character (inheriting its block format, e.g. a Pygments code - # block's background color), not on a clean new line. - if not self.chat_view.document().isEmpty(): - _reset_cursor_format(cursor) - cursor.insertHtml(f'{label}:') + body = _insert_message_card(cursor, accent, bg) + body.insertHtml( + f'{label.upper()}' + f'
{escaped}' + ) + self.chat_view.moveCursor(QTextCursor.MoveOperation.End) + + def _append_markdown_block(self, label: str, markdown_text: str, accent: str, bg: str) -> None: + cursor = self.chat_view.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + # Each message gets its own table cell (see _insert_message_card), so + # unlike the previous shared-cursor version, there's no need to reset + # formatting before the label - a fresh cell starts with plain format. + body = _insert_message_card(cursor, accent, bg) + body.insertHtml(f'{label.upper()}') pos = 0 for match in _CODE_FENCE_RE.finditer(markdown_text): before = markdown_text[pos : match.start()] if before.strip(): - _reset_cursor_format(cursor) + _reset_cursor_format(body) fragment = QTextDocumentFragment.fromMarkdown(before, _MARKDOWN_FEATURES) - cursor.insertFragment(fragment) + body.insertFragment(fragment) lang, code = match.group(1), match.group(2) for line in _highlight_code_lines(code, lang): - cursor.insertBlock(_CODE_BLOCK_FORMAT) - start = cursor.position() - cursor.insertHtml(line) + body.insertBlock(_CODE_BLOCK_FORMAT) + start = body.position() + body.insertHtml(line) # insertHtml() builds its own char formats from the parsed # HTML and ignores the cursor's format, so the monospace # font has to be merged on afterwards; merging (rather than # replacing) keeps Pygments' per-token colors and bold. - line_cursor = QTextCursor(cursor) + line_cursor = QTextCursor(body) line_cursor.setPosition(start) - line_cursor.setPosition(cursor.position(), QTextCursor.MoveMode.KeepAnchor) + line_cursor.setPosition(body.position(), QTextCursor.MoveMode.KeepAnchor) line_cursor.mergeCharFormat(_CODE_CHAR_FORMAT) pos = match.end() remainder = markdown_text[pos:] if remainder.strip(): - _reset_cursor_format(cursor) + _reset_cursor_format(body) fragment = QTextDocumentFragment.fromMarkdown(remainder, _MARKDOWN_FEATURES) - cursor.insertFragment(fragment) + body.insertFragment(fragment) - # Reset the block format one more time so a code block's background/ - # monospace font doesn't bleed into whatever message gets appended next. - _reset_cursor_format(cursor) - self.chat_view.setTextCursor(cursor) self.chat_view.moveCursor(QTextCursor.MoveOperation.End) def closeEvent(self, event) -> None: @@ -357,6 +639,10 @@ class ChatWindow(QMainWindow): def main() -> None: app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setPalette(_dark_palette()) + app.setStyleSheet(_STYLESHEET) + app.setWindowIcon(_make_app_icon()) window = ChatWindow() window.show() sys.exit(app.exec())