Fix desktop chat wrapping, character rendering, and unread alerts
This commit is contained in:
@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal
|
||||
class ChatBubble(QWidget):
|
||||
def __init__(self, author, text, time_str, is_self, on_link_clicked):
|
||||
super().__init__()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(5, 2, 5, 2)
|
||||
|
||||
@@ -39,6 +40,7 @@ class ChatBubble(QWidget):
|
||||
|
||||
# Author
|
||||
author_lbl = QLabel("You" if is_self else author)
|
||||
author_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
||||
author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;")
|
||||
bubble_layout.addWidget(author_lbl)
|
||||
|
||||
@@ -47,6 +49,7 @@ class ChatBubble(QWidget):
|
||||
# width of 0 lets the label wrap within whatever space the bubble
|
||||
# actually receives rather than expanding the bubble outward.
|
||||
self.text_lbl = QLabel(text)
|
||||
self.text_lbl.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.text_lbl.setWordWrap(True)
|
||||
self.text_lbl.setMinimumWidth(0)
|
||||
self.text_lbl.setSizePolicy(
|
||||
@@ -71,6 +74,14 @@ class ChatBubble(QWidget):
|
||||
layout.addWidget(self.bubble)
|
||||
layout.addStretch()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
# Recalculate after the scrollbar or splitter changes the available width.
|
||||
# A fixed row height prevents the scroll layout squeezing wrapped text.
|
||||
height = self.layout().heightForWidth(event.size().width())
|
||||
if height >= 0:
|
||||
self.setFixedHeight(height)
|
||||
|
||||
|
||||
class SystemMessageWidget(QWidget):
|
||||
def __init__(self, text):
|
||||
|
||||
@@ -45,6 +45,8 @@ class RoomWidget(QWidget):
|
||||
self.popout_window = None
|
||||
|
||||
self._setup_ui()
|
||||
self._unread_messages = 0
|
||||
QApplication.instance().focusChanged.connect(self._on_chat_focus_changed)
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -174,7 +176,7 @@ class RoomWidget(QWidget):
|
||||
self.chat_container.setMaximumWidth(500)
|
||||
chat_layout = QVBoxLayout(self.chat_container)
|
||||
|
||||
chat_header = QLabel("Live Chat")
|
||||
chat_header = self.chat_header = QLabel("Live Chat")
|
||||
chat_header.setObjectName("chatHeader")
|
||||
|
||||
self.popout_btn = QPushButton("↗")
|
||||
@@ -323,6 +325,7 @@ class RoomWidget(QWidget):
|
||||
self.room_file_badge.setText(f"📄 {file_name}")
|
||||
|
||||
# Clear chat
|
||||
self._clear_chat_alert()
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
self.chat_content_layout.itemAt(i).widget().setParent(None)
|
||||
|
||||
@@ -335,6 +338,7 @@ class RoomWidget(QWidget):
|
||||
self.vlc_player.set_volume(self.volume_slider.value())
|
||||
|
||||
def cleanup(self):
|
||||
self._clear_chat_alert()
|
||||
self.vlc_player.stop()
|
||||
self.current_users = []
|
||||
self._is_first_user_update = True
|
||||
@@ -723,13 +727,20 @@ class RoomWidget(QWidget):
|
||||
time_str = dt.strftime("%I:%M %p")
|
||||
|
||||
safe_text = html.escape(text)
|
||||
safe_author = html.escape(author)
|
||||
|
||||
|
||||
# Regex to find timestamps like 1:23 or 01:23:45
|
||||
pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b'
|
||||
linked_text = re.sub(pattern, r'<a href="seek:\1" style="color: #66b3ff; text-decoration: none;">\1</a>', safe_text)
|
||||
linked_text = linked_text.replace('\n', '<br>')
|
||||
|
||||
is_self = author == self.username
|
||||
if not is_self and not self._chat_has_focus():
|
||||
self._unread_messages += 1
|
||||
self.chat_header.setText(f"Live Chat • {self._unread_messages} new")
|
||||
self.chat_toggle_btn.setText("💬•")
|
||||
self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages")
|
||||
self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
|
||||
QApplication.alert(self.chat_container.window(), 3000)
|
||||
|
||||
# Auto-extract tags added by users directly via /tag command
|
||||
# We look for the pattern generated by /tag: "MM:SS tag text"
|
||||
@@ -742,6 +753,24 @@ class RoomWidget(QWidget):
|
||||
bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble)
|
||||
self.chat_content_layout.addWidget(bubble)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _chat_has_focus(self):
|
||||
focus = QApplication.focusWidget()
|
||||
return (self.chat_container.isVisible()
|
||||
and self.chat_container.window().isActiveWindow()
|
||||
and focus is not None
|
||||
and (focus is self.chat_container or self.chat_container.isAncestorOf(focus)))
|
||||
|
||||
def _on_chat_focus_changed(self, old, new):
|
||||
if self._chat_has_focus():
|
||||
self._clear_chat_alert()
|
||||
|
||||
def _clear_chat_alert(self):
|
||||
self._unread_messages = 0
|
||||
self.chat_header.setText("Live Chat")
|
||||
self.chat_toggle_btn.setText("💬")
|
||||
self.chat_toggle_btn.setToolTip("Toggle Chat")
|
||||
self.chat_toggle_btn.setStyleSheet("")
|
||||
|
||||
def _handle_link_from_bubble(self, url_str):
|
||||
if url_str.startswith("seek:"):
|
||||
@@ -782,6 +811,7 @@ class RoomWidget(QWidget):
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def clear_chat(self):
|
||||
self._clear_chat_alert()
|
||||
for i in reversed(range(self.chat_content_layout.count())):
|
||||
item = self.chat_content_layout.itemAt(i)
|
||||
if item.widget():
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtGui import QTextDocument
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
import room_widget
|
||||
|
||||
|
||||
class PlayerSignals(QObject):
|
||||
time_changed = pyqtSignal(int)
|
||||
state_changed = pyqtSignal(bool, int)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def room(qtbot, monkeypatch):
|
||||
player = Mock(signals=PlayerSignals())
|
||||
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
|
||||
widget = room_widget.RoomWidget()
|
||||
widget.username = 'Me'
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1050, 500)
|
||||
widget.show()
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text', ["It's someone's & my <message>",
|
||||
"It's at 1:23 & <b>literal</b>",
|
||||
'line one\nline two', 'literal ' entity'])
|
||||
def test_message_rendering(room, text):
|
||||
room.add_chat_message("<b>O'Brien</b>", text, 0)
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
doc = QTextDocument()
|
||||
doc.setHtml(bubble.text_lbl.text())
|
||||
assert doc.toPlainText() == text
|
||||
if '1:23' in text:
|
||||
assert 'href="seek:1:23"' in bubble.text_lbl.text()
|
||||
|
||||
|
||||
def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot):
|
||||
text = 'A wrapped message with several words. ' * 8
|
||||
room.add_chat_message('Other', text, 0)
|
||||
qtbot.wait(100)
|
||||
first = room.chat_content_layout.itemAt(0).widget()
|
||||
initial_height = first.height()
|
||||
for i in range(35):
|
||||
room.add_chat_message('Me' if i % 2 else 'Other', text, 0)
|
||||
qtbot.wait(200)
|
||||
assert room.chat_messages.verticalScrollBar().maximum() > 0
|
||||
assert first.height() >= initial_height
|
||||
for width in (220, 450, 280):
|
||||
room.splitter.setSizes([room.width() - width, width])
|
||||
qtbot.wait(100)
|
||||
for i in range(room.chat_content_layout.count()):
|
||||
bubble = room.chat_content_layout.itemAt(i).widget()
|
||||
label = bubble.text_lbl
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert bubble.bubble.rect().contains(label.geometry())
|
||||
|
||||
|
||||
def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch):
|
||||
alert = Mock()
|
||||
monkeypatch.setattr(QApplication, 'alert', alert)
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(50)
|
||||
room.add_chat_message('Other', 'hello', 0)
|
||||
assert room._unread_messages == 1
|
||||
alert.assert_called_once()
|
||||
room.add_chat_message('Me', 'reply', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(50)
|
||||
assert room._unread_messages == 0
|
||||
room.add_chat_message('Other', 'focused', 0)
|
||||
assert room._unread_messages == 0
|
||||
room.chat_container.hide()
|
||||
room.add_chat_message('Other', 'hidden', 0)
|
||||
assert room._unread_messages == 1
|
||||
room.clear_chat()
|
||||
assert room._unread_messages == 0
|
||||
|
||||
|
||||
def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
|
||||
monkeypatch.setattr(QApplication, 'alert', Mock())
|
||||
room.popout_chat()
|
||||
popout = room.popout_window
|
||||
qtbot.addWidget(popout)
|
||||
popout.activateWindow()
|
||||
room.chat_input.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0)
|
||||
qtbot.wait(100)
|
||||
assert room._unread_messages == 0
|
||||
bubble = room.chat_content_layout.itemAt(0).widget()
|
||||
assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width())
|
||||
room.activateWindow()
|
||||
room.setFocus()
|
||||
qtbot.wait(100)
|
||||
room.add_chat_message('Other', 'Background popout', 0)
|
||||
assert room._unread_messages == 1
|
||||
popout.close()
|
||||
Reference in New Issue
Block a user