Initial commit: Bifrost accessible fediverse client
- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon - Screen reader optimized interface with PySide6 - Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4) - Threaded conversation navigation with expand/collapse - Cross-platform desktop notifications via plyer - Customizable sound pack system with audio feedback - Complete keyboard navigation and accessibility features - XDG Base Directory compliant configuration - Multiple account support with OAuth authentication 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
src/widgets/__init__.py
Normal file
1
src/widgets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# UI widgets
|
101
src/widgets/account_selector.py
Normal file
101
src/widgets/account_selector.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""
|
||||
Account selector widget for switching between multiple accounts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
from config.accounts import AccountManager, Account
|
||||
|
||||
|
||||
class AccountSelector(QWidget):
|
||||
"""Widget for selecting and managing accounts"""
|
||||
|
||||
account_changed = Signal(str) # account_id
|
||||
add_account_requested = Signal()
|
||||
|
||||
def __init__(self, account_manager: AccountManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.account_manager = account_manager
|
||||
self.setup_ui()
|
||||
self.refresh_accounts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the account selector UI"""
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Account label
|
||||
self.account_label = QLabel("Account:")
|
||||
layout.addWidget(self.account_label)
|
||||
|
||||
# Account selector combo
|
||||
self.account_combo = AccessibleComboBox()
|
||||
self.account_combo.setAccessibleName("Account Selection")
|
||||
self.account_combo.setAccessibleDescription("Select active account. Use Left/Right arrows or Page Up/Down to switch accounts quickly.")
|
||||
self.account_combo.currentTextChanged.connect(self.on_account_selected)
|
||||
layout.addWidget(self.account_combo)
|
||||
|
||||
# Add account button
|
||||
self.add_button = QPushButton("&Add Account")
|
||||
self.add_button.setAccessibleName("Add New Account")
|
||||
self.add_button.clicked.connect(self.add_account_requested.emit)
|
||||
layout.addWidget(self.add_button)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def refresh_accounts(self):
|
||||
"""Refresh the account list"""
|
||||
self.account_combo.clear()
|
||||
|
||||
if not self.account_manager.has_accounts():
|
||||
self.account_combo.addItem("No accounts configured")
|
||||
self.account_combo.setEnabled(False)
|
||||
return
|
||||
|
||||
self.account_combo.setEnabled(True)
|
||||
|
||||
# Add all accounts
|
||||
for account in self.account_manager.get_all_accounts():
|
||||
display_text = account.get_display_text()
|
||||
self.account_combo.addItem(display_text)
|
||||
|
||||
# Select active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if active_account:
|
||||
active_display = active_account.get_display_text()
|
||||
index = self.account_combo.findText(active_display)
|
||||
if index >= 0:
|
||||
self.account_combo.setCurrentIndex(index)
|
||||
|
||||
def on_account_selected(self, display_name: str):
|
||||
"""Handle account selection"""
|
||||
if display_name == "No accounts configured":
|
||||
return
|
||||
|
||||
account_id = self.account_manager.find_account_by_display_name(display_name)
|
||||
if account_id:
|
||||
self.account_manager.set_active_account(account_id)
|
||||
self.account_changed.emit(account_id)
|
||||
|
||||
def add_account(self, account_data: dict):
|
||||
"""Add a new account"""
|
||||
account_id = self.account_manager.add_account(account_data)
|
||||
self.refresh_accounts()
|
||||
|
||||
# Select the new account
|
||||
new_account = self.account_manager.get_account_by_id(account_id)
|
||||
if new_account:
|
||||
display_text = new_account.get_display_text()
|
||||
index = self.account_combo.findText(display_text)
|
||||
if index >= 0:
|
||||
self.account_combo.setCurrentIndex(index)
|
||||
|
||||
def get_current_account(self) -> Account:
|
||||
"""Get the currently selected account"""
|
||||
return self.account_manager.get_active_account()
|
||||
|
||||
def has_accounts(self) -> bool:
|
||||
"""Check if there are any accounts"""
|
||||
return self.account_manager.has_accounts()
|
326
src/widgets/autocomplete_textedit.py
Normal file
326
src/widgets/autocomplete_textedit.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""
|
||||
Text edit widget with autocomplete for mentions and emojis
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTextEdit, QCompleter, QListWidget, QListWidgetItem
|
||||
from PySide6.QtCore import Qt, Signal, QStringListModel, QRect
|
||||
from PySide6.QtGui import QTextCursor, QKeyEvent
|
||||
import re
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
class AutocompleteTextEdit(QTextEdit):
|
||||
"""Text edit with @ mention and : emoji autocomplete"""
|
||||
|
||||
mention_requested = Signal(str) # Emitted when user types @ to request user list
|
||||
emoji_requested = Signal(str) # Emitted when user types : to request emoji list
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Lists for autocomplete
|
||||
self.mention_list = [] # Will be populated from followers/following
|
||||
self.emoji_list = [] # Will be populated from instance custom emojis
|
||||
|
||||
# Autocomplete state
|
||||
self.completer = None
|
||||
self.completion_prefix = ""
|
||||
self.completion_start = 0
|
||||
self.completion_type = None # 'mention' or 'emoji'
|
||||
|
||||
# Load default emojis
|
||||
self.load_default_emojis()
|
||||
|
||||
def load_default_emojis(self):
|
||||
"""Load a comprehensive set of Unicode emojis"""
|
||||
self.emoji_list = [
|
||||
# Faces
|
||||
{"shortcode": "smile", "emoji": "😄", "keywords": ["smile", "happy", "joy", "grin"]},
|
||||
{"shortcode": "laughing", "emoji": "😆", "keywords": ["laugh", "haha", "funny", "lol"]},
|
||||
{"shortcode": "wink", "emoji": "😉", "keywords": ["wink", "flirt", "hint"]},
|
||||
{"shortcode": "thinking", "emoji": "🤔", "keywords": ["thinking", "hmm", "consider", "ponder"]},
|
||||
{"shortcode": "shrug", "emoji": "🤷", "keywords": ["shrug", "dunno", "whatever", "idk"]},
|
||||
{"shortcode": "facepalm", "emoji": "🤦", "keywords": ["facepalm", "disappointed", "doh", "frustrated"]},
|
||||
{"shortcode": "crying", "emoji": "😭", "keywords": ["crying", "tears", "sad", "sob"]},
|
||||
{"shortcode": "angry", "emoji": "😠", "keywords": ["angry", "mad", "furious", "upset"]},
|
||||
{"shortcode": "cool", "emoji": "😎", "keywords": ["cool", "sunglasses", "awesome", "rad"]},
|
||||
{"shortcode": "joy", "emoji": "😂", "keywords": ["joy", "laugh", "tears", "funny"]},
|
||||
{"shortcode": "heart_eyes", "emoji": "😍", "keywords": ["heart", "eyes", "love", "crush"]},
|
||||
{"shortcode": "kiss", "emoji": "😘", "keywords": ["kiss", "love", "smooch", "mwah"]},
|
||||
{"shortcode": "tired", "emoji": "😴", "keywords": ["tired", "sleep", "sleepy", "zzz"]},
|
||||
{"shortcode": "shocked", "emoji": "😱", "keywords": ["shocked", "surprised", "scared", "omg"]},
|
||||
|
||||
# Hearts and symbols
|
||||
{"shortcode": "heart", "emoji": "❤️", "keywords": ["heart", "love", "red", "romance"]},
|
||||
{"shortcode": "blue_heart", "emoji": "💙", "keywords": ["blue", "heart", "love", "cold"]},
|
||||
{"shortcode": "green_heart", "emoji": "💚", "keywords": ["green", "heart", "love", "nature"]},
|
||||
{"shortcode": "yellow_heart", "emoji": "💛", "keywords": ["yellow", "heart", "love", "happy"]},
|
||||
{"shortcode": "purple_heart", "emoji": "💜", "keywords": ["purple", "heart", "love", "royal"]},
|
||||
{"shortcode": "black_heart", "emoji": "🖤", "keywords": ["black", "heart", "love", "dark"]},
|
||||
{"shortcode": "broken_heart", "emoji": "💔", "keywords": ["broken", "heart", "sad", "breakup"]},
|
||||
{"shortcode": "sparkling_heart", "emoji": "💖", "keywords": ["sparkling", "heart", "love", "sparkle"]},
|
||||
|
||||
# Gestures
|
||||
{"shortcode": "thumbsup", "emoji": "👍", "keywords": ["thumbs", "up", "good", "ok", "yes"]},
|
||||
{"shortcode": "thumbsdown", "emoji": "👎", "keywords": ["thumbs", "down", "bad", "no", "dislike"]},
|
||||
{"shortcode": "wave", "emoji": "👋", "keywords": ["wave", "hello", "hi", "goodbye", "bye"]},
|
||||
{"shortcode": "clap", "emoji": "👏", "keywords": ["clap", "applause", "bravo", "good"]},
|
||||
{"shortcode": "pray", "emoji": "🙏", "keywords": ["pray", "thanks", "please", "gratitude"]},
|
||||
{"shortcode": "ok_hand", "emoji": "👌", "keywords": ["ok", "hand", "perfect", "good"]},
|
||||
{"shortcode": "peace", "emoji": "✌️", "keywords": ["peace", "victory", "two", "fingers"]},
|
||||
{"shortcode": "crossed_fingers", "emoji": "🤞", "keywords": ["crossed", "fingers", "luck", "hope"]},
|
||||
|
||||
# Objects and symbols
|
||||
{"shortcode": "fire", "emoji": "🔥", "keywords": ["fire", "hot", "flame", "lit"]},
|
||||
{"shortcode": "star", "emoji": "⭐", "keywords": ["star", "favorite", "best", "top"]},
|
||||
{"shortcode": "rainbow", "emoji": "🌈", "keywords": ["rainbow", "colorful", "pride", "weather"]},
|
||||
{"shortcode": "lightning", "emoji": "⚡", "keywords": ["lightning", "bolt", "fast", "electric"]},
|
||||
{"shortcode": "snowflake", "emoji": "❄️", "keywords": ["snowflake", "cold", "winter", "frozen"]},
|
||||
{"shortcode": "sun", "emoji": "☀️", "keywords": ["sun", "sunny", "bright", "weather"]},
|
||||
{"shortcode": "moon", "emoji": "🌙", "keywords": ["moon", "night", "crescent", "sleep"]},
|
||||
{"shortcode": "cloud", "emoji": "☁️", "keywords": ["cloud", "weather", "sky", "cloudy"]},
|
||||
|
||||
# Food and drinks
|
||||
{"shortcode": "coffee", "emoji": "☕", "keywords": ["coffee", "drink", "morning", "caffeine"]},
|
||||
{"shortcode": "tea", "emoji": "🍵", "keywords": ["tea", "drink", "hot", "green"]},
|
||||
{"shortcode": "beer", "emoji": "🍺", "keywords": ["beer", "drink", "alcohol", "party"]},
|
||||
{"shortcode": "wine", "emoji": "🍷", "keywords": ["wine", "drink", "alcohol", "red"]},
|
||||
{"shortcode": "pizza", "emoji": "🍕", "keywords": ["pizza", "food", "italian", "slice"]},
|
||||
{"shortcode": "burger", "emoji": "🍔", "keywords": ["burger", "food", "meat", "american"]},
|
||||
{"shortcode": "cake", "emoji": "🎂", "keywords": ["cake", "birthday", "dessert", "sweet"]},
|
||||
{"shortcode": "cookie", "emoji": "🍪", "keywords": ["cookie", "dessert", "sweet", "snack"]},
|
||||
{"shortcode": "apple", "emoji": "🍎", "keywords": ["apple", "fruit", "red", "healthy"]},
|
||||
{"shortcode": "banana", "emoji": "🍌", "keywords": ["banana", "fruit", "yellow", "monkey"]},
|
||||
|
||||
# Animals
|
||||
{"shortcode": "cat", "emoji": "🐱", "keywords": ["cat", "kitten", "meow", "feline"]},
|
||||
{"shortcode": "dog", "emoji": "🐶", "keywords": ["dog", "puppy", "woof", "canine"]},
|
||||
{"shortcode": "mouse", "emoji": "🐭", "keywords": ["mouse", "small", "rodent", "squeak"]},
|
||||
{"shortcode": "bear", "emoji": "🐻", "keywords": ["bear", "large", "forest", "cute"]},
|
||||
{"shortcode": "panda", "emoji": "🐼", "keywords": ["panda", "bear", "black", "white"]},
|
||||
{"shortcode": "lion", "emoji": "🦁", "keywords": ["lion", "king", "mane", "roar"]},
|
||||
{"shortcode": "tiger", "emoji": "🐯", "keywords": ["tiger", "stripes", "orange", "wild"]},
|
||||
{"shortcode": "fox", "emoji": "🦊", "keywords": ["fox", "red", "clever", "sly"]},
|
||||
{"shortcode": "wolf", "emoji": "🐺", "keywords": ["wolf", "pack", "howl", "wild"]},
|
||||
{"shortcode": "unicorn", "emoji": "🦄", "keywords": ["unicorn", "magic", "rainbow", "fantasy"]},
|
||||
|
||||
# Activities and celebrations
|
||||
{"shortcode": "party", "emoji": "🎉", "keywords": ["party", "celebration", "confetti", "fun"]},
|
||||
{"shortcode": "birthday", "emoji": "🎂", "keywords": ["birthday", "cake", "celebration", "age"]},
|
||||
{"shortcode": "gift", "emoji": "🎁", "keywords": ["gift", "present", "box", "surprise"]},
|
||||
{"shortcode": "balloon", "emoji": "🎈", "keywords": ["balloon", "party", "float", "celebration"]},
|
||||
{"shortcode": "music", "emoji": "🎵", "keywords": ["music", "notes", "song", "melody"]},
|
||||
{"shortcode": "dance", "emoji": "💃", "keywords": ["dance", "woman", "party", "fun"]},
|
||||
|
||||
# Halloween and seasonal
|
||||
{"shortcode": "jack_o_lantern", "emoji": "🎃", "keywords": ["jack", "lantern", "pumpkin", "halloween"]},
|
||||
{"shortcode": "ghost", "emoji": "👻", "keywords": ["ghost", "spooky", "halloween", "boo"]},
|
||||
{"shortcode": "skull", "emoji": "💀", "keywords": ["skull", "death", "spooky", "halloween"]},
|
||||
{"shortcode": "spider", "emoji": "🕷️", "keywords": ["spider", "web", "spooky", "halloween"]},
|
||||
{"shortcode": "bat", "emoji": "🦇", "keywords": ["bat", "fly", "night", "halloween"]},
|
||||
{"shortcode": "christmas_tree", "emoji": "🎄", "keywords": ["christmas", "tree", "holiday", "winter"]},
|
||||
{"shortcode": "santa", "emoji": "🎅", "keywords": ["santa", "christmas", "holiday", "ho"]},
|
||||
{"shortcode": "snowman", "emoji": "⛄", "keywords": ["snowman", "winter", "cold", "carrot"]},
|
||||
|
||||
# Technology
|
||||
{"shortcode": "computer", "emoji": "💻", "keywords": ["computer", "laptop", "tech", "work"]},
|
||||
{"shortcode": "phone", "emoji": "📱", "keywords": ["phone", "mobile", "cell", "smartphone"]},
|
||||
{"shortcode": "camera", "emoji": "📷", "keywords": ["camera", "photo", "picture", "snap"]},
|
||||
{"shortcode": "video", "emoji": "📹", "keywords": ["video", "camera", "record", "film"]},
|
||||
{"shortcode": "robot", "emoji": "🤖", "keywords": ["robot", "ai", "artificial", "intelligence"]},
|
||||
|
||||
# Transportation
|
||||
{"shortcode": "car", "emoji": "🚗", "keywords": ["car", "drive", "vehicle", "auto"]},
|
||||
{"shortcode": "bike", "emoji": "🚲", "keywords": ["bike", "bicycle", "ride", "cycle"]},
|
||||
{"shortcode": "plane", "emoji": "✈️", "keywords": ["plane", "airplane", "fly", "travel"]},
|
||||
{"shortcode": "rocket", "emoji": "🚀", "keywords": ["rocket", "space", "launch", "fast"]},
|
||||
]
|
||||
|
||||
def set_mention_list(self, mentions: List[str]):
|
||||
"""Set the list of available mentions (usernames)"""
|
||||
self.mention_list = mentions
|
||||
|
||||
def set_emoji_list(self, emojis: List[Dict]):
|
||||
"""Set custom emoji list from instance"""
|
||||
# Combine with default emojis
|
||||
self.emoji_list.extend(emojis)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
"""Handle key press events for autocomplete"""
|
||||
key = event.key()
|
||||
|
||||
# Handle autocomplete navigation
|
||||
if self.completer and self.completer.popup().isVisible():
|
||||
if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
|
||||
self.handle_completer_key(key)
|
||||
return
|
||||
elif key == Qt.Key_Escape:
|
||||
self.hide_completer()
|
||||
return
|
||||
|
||||
# Normal key processing
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Check for autocomplete triggers
|
||||
self.check_autocomplete_trigger()
|
||||
|
||||
def handle_completer_key(self, key):
|
||||
"""Handle navigation keys in completer"""
|
||||
popup = self.completer.popup()
|
||||
|
||||
if key in [Qt.Key_Up, Qt.Key_Down]:
|
||||
# Let the popup handle up/down
|
||||
if key == Qt.Key_Up:
|
||||
current = popup.currentIndex().row()
|
||||
if current > 0:
|
||||
popup.setCurrentIndex(popup.model().index(current - 1, 0))
|
||||
else:
|
||||
current = popup.currentIndex().row()
|
||||
if current < popup.model().rowCount() - 1:
|
||||
popup.setCurrentIndex(popup.model().index(current + 1, 0))
|
||||
|
||||
elif key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
|
||||
# Insert the selected completion
|
||||
self.insert_completion()
|
||||
|
||||
def check_autocomplete_trigger(self):
|
||||
"""Check if we should show autocomplete"""
|
||||
cursor = self.textCursor()
|
||||
text = self.toPlainText()
|
||||
pos = cursor.position()
|
||||
|
||||
# Find the current word being typed
|
||||
if pos == 0:
|
||||
return
|
||||
|
||||
# Look backwards for @ or :
|
||||
start_pos = pos - 1
|
||||
while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']:
|
||||
start_pos -= 1
|
||||
start_pos += 1
|
||||
|
||||
current_word = text[start_pos:pos]
|
||||
|
||||
if current_word.startswith('@') and len(current_word) > 1:
|
||||
# Check if this is a completed mention (ends with space)
|
||||
if current_word.endswith(' '):
|
||||
self.hide_completer()
|
||||
return
|
||||
# Mention autocomplete
|
||||
prefix = current_word[1:] # Remove @
|
||||
self.show_mention_completer(prefix, start_pos)
|
||||
|
||||
elif current_word.startswith(':') and len(current_word) > 1:
|
||||
# Check if this is a completed emoji (ends with :)
|
||||
if current_word.endswith(':') and len(current_word) > 2:
|
||||
self.hide_completer()
|
||||
return
|
||||
# Emoji autocomplete - remove trailing : if present for matching
|
||||
prefix = current_word[1:] # Remove initial :
|
||||
if prefix.endswith(':'):
|
||||
prefix = prefix[:-1] # Remove trailing : for matching
|
||||
self.show_emoji_completer(prefix, start_pos)
|
||||
|
||||
else:
|
||||
# Hide completer if not in autocomplete mode
|
||||
self.hide_completer()
|
||||
|
||||
def show_mention_completer(self, prefix: str, start_pos: int):
|
||||
"""Show mention autocomplete"""
|
||||
if not self.mention_list:
|
||||
# Request mention list from parent
|
||||
self.mention_requested.emit(prefix)
|
||||
return
|
||||
|
||||
# Filter mentions
|
||||
matches = [name for name in self.mention_list if name.lower().startswith(prefix.lower())]
|
||||
|
||||
if matches:
|
||||
self.show_completer(matches, prefix, start_pos, 'mention')
|
||||
else:
|
||||
self.hide_completer()
|
||||
|
||||
def show_emoji_completer(self, prefix: str, start_pos: int):
|
||||
"""Show emoji autocomplete"""
|
||||
# Filter emojis by shortcode and keywords
|
||||
matches = []
|
||||
prefix_lower = prefix.lower()
|
||||
|
||||
for emoji in self.emoji_list:
|
||||
shortcode = emoji['shortcode']
|
||||
keywords = emoji.get('keywords', [])
|
||||
|
||||
# Check if prefix matches shortcode or any keyword
|
||||
if (shortcode.startswith(prefix_lower) or
|
||||
any(keyword.startswith(prefix_lower) for keyword in keywords)):
|
||||
display_text = f"{shortcode} {emoji['emoji']}"
|
||||
matches.append(display_text)
|
||||
|
||||
if matches:
|
||||
self.show_completer(matches, prefix, start_pos, 'emoji')
|
||||
else:
|
||||
self.hide_completer()
|
||||
|
||||
def show_completer(self, items: List[str], prefix: str, start_pos: int, completion_type: str):
|
||||
"""Show the completer with given items"""
|
||||
self.completion_prefix = prefix
|
||||
self.completion_start = start_pos
|
||||
self.completion_type = completion_type
|
||||
|
||||
# Create or update completer
|
||||
if not self.completer:
|
||||
self.completer = QCompleter(self)
|
||||
self.completer.setWidget(self)
|
||||
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
|
||||
# Set up model
|
||||
model = QStringListModel(items)
|
||||
self.completer.setModel(model)
|
||||
|
||||
# Position the popup
|
||||
cursor = self.textCursor()
|
||||
cursor.setPosition(start_pos)
|
||||
rect = self.cursorRect(cursor)
|
||||
rect.setWidth(200)
|
||||
self.completer.complete(rect)
|
||||
|
||||
# Set accessible name for screen readers
|
||||
popup = self.completer.popup()
|
||||
popup.setAccessibleName(f"{completion_type.title()} Autocomplete")
|
||||
|
||||
def hide_completer(self):
|
||||
"""Hide the completer"""
|
||||
if self.completer:
|
||||
self.completer.popup().hide()
|
||||
|
||||
def insert_completion(self):
|
||||
"""Insert the selected completion"""
|
||||
if not self.completer:
|
||||
return
|
||||
|
||||
popup = self.completer.popup()
|
||||
current_index = popup.currentIndex()
|
||||
if not current_index.isValid():
|
||||
return
|
||||
|
||||
completion = self.completer.currentCompletion()
|
||||
|
||||
# Replace the current prefix with the completion
|
||||
cursor = self.textCursor()
|
||||
cursor.setPosition(self.completion_start)
|
||||
cursor.setPosition(cursor.position() + len(self.completion_prefix) + 1, QTextCursor.KeepAnchor) # +1 for @ or :
|
||||
|
||||
if self.completion_type == 'mention':
|
||||
cursor.insertText(f"@{completion} ")
|
||||
elif self.completion_type == 'emoji':
|
||||
# Extract just the shortcode (before the emoji)
|
||||
shortcode = completion.split()[0]
|
||||
cursor.insertText(f":{shortcode}: ")
|
||||
|
||||
self.hide_completer()
|
||||
|
||||
def update_mention_list(self, mentions: List[str]):
|
||||
"""Update mention list (called from parent when data is ready)"""
|
||||
self.mention_list = mentions
|
||||
# Don't re-trigger completer to avoid recursion
|
||||
# The completer will use the updated list on next keystroke
|
255
src/widgets/compose_dialog.py
Normal file
255
src/widgets/compose_dialog.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""
|
||||
Compose post dialog for creating new posts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
|
||||
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
|
||||
QComboBox, QGroupBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
from audio.sound_manager import SoundManager
|
||||
from config.settings import SettingsManager
|
||||
from activitypub.client import ActivityPubClient
|
||||
from widgets.autocomplete_textedit import AutocompleteTextEdit
|
||||
|
||||
|
||||
class PostThread(QThread):
|
||||
"""Background thread for posting content"""
|
||||
|
||||
post_success = Signal(dict) # Emitted with post data on success
|
||||
post_failed = Signal(str) # Emitted with error message on failure
|
||||
|
||||
def __init__(self, account, content, visibility, content_warning=None):
|
||||
super().__init__()
|
||||
self.account = account
|
||||
self.content = content
|
||||
self.visibility = visibility
|
||||
self.content_warning = content_warning
|
||||
|
||||
def run(self):
|
||||
"""Post the content in background"""
|
||||
try:
|
||||
client = ActivityPubClient(self.account.instance_url, self.account.access_token)
|
||||
|
||||
result = client.post_status(
|
||||
content=self.content,
|
||||
visibility=self.visibility,
|
||||
content_warning=self.content_warning
|
||||
)
|
||||
|
||||
self.post_success.emit(result)
|
||||
|
||||
except Exception as e:
|
||||
self.post_failed.emit(str(e))
|
||||
|
||||
|
||||
class ComposeDialog(QDialog):
|
||||
"""Dialog for composing new posts"""
|
||||
|
||||
post_sent = Signal(dict) # Emitted when a post is ready to send
|
||||
|
||||
def __init__(self, account_manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.account_manager = account_manager
|
||||
self.setup_ui()
|
||||
self.setup_shortcuts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the compose dialog UI"""
|
||||
self.setWindowTitle("Compose Post")
|
||||
self.setMinimumSize(500, 300)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Character count label
|
||||
self.char_count_label = QLabel("Characters: 0/500")
|
||||
self.char_count_label.setAccessibleName("Character Count")
|
||||
layout.addWidget(self.char_count_label)
|
||||
|
||||
# Main text area with autocomplete
|
||||
self.text_edit = AutocompleteTextEdit()
|
||||
self.text_edit.setAccessibleName("Post Content")
|
||||
self.text_edit.setAccessibleDescription("Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options.")
|
||||
self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis")
|
||||
self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area
|
||||
self.text_edit.textChanged.connect(self.update_char_count)
|
||||
self.text_edit.mention_requested.connect(self.load_mention_suggestions)
|
||||
self.text_edit.emoji_requested.connect(self.load_emoji_suggestions)
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
# Options group
|
||||
options_group = QGroupBox("Post Options")
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
|
||||
# Visibility settings
|
||||
visibility_layout = QHBoxLayout()
|
||||
visibility_layout.addWidget(QLabel("Visibility:"))
|
||||
|
||||
self.visibility_combo = AccessibleComboBox()
|
||||
self.visibility_combo.setAccessibleName("Post Visibility")
|
||||
self.visibility_combo.addItems([
|
||||
"Public",
|
||||
"Unlisted",
|
||||
"Followers Only",
|
||||
"Direct Message"
|
||||
])
|
||||
visibility_layout.addWidget(self.visibility_combo)
|
||||
visibility_layout.addStretch()
|
||||
options_layout.addLayout(visibility_layout)
|
||||
|
||||
# Content warnings
|
||||
self.cw_checkbox = QCheckBox("Add Content Warning")
|
||||
self.cw_checkbox.setAccessibleName("Content Warning Toggle")
|
||||
self.cw_checkbox.toggled.connect(self.toggle_content_warning)
|
||||
options_layout.addWidget(self.cw_checkbox)
|
||||
|
||||
self.cw_edit = QTextEdit()
|
||||
self.cw_edit.setAccessibleName("Content Warning Text")
|
||||
self.cw_edit.setAccessibleDescription("Enter content warning description. Press Tab to move to next field.")
|
||||
self.cw_edit.setPlaceholderText("Describe what this post contains...")
|
||||
self.cw_edit.setMaximumHeight(60)
|
||||
self.cw_edit.setTabChangesFocus(True) # Allow Tab to exit the content warning field
|
||||
self.cw_edit.hide()
|
||||
options_layout.addWidget(self.cw_edit)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox()
|
||||
|
||||
# Post button
|
||||
self.post_button = QPushButton("&Post")
|
||||
self.post_button.setAccessibleName("Send Post")
|
||||
self.post_button.setDefault(True)
|
||||
self.post_button.clicked.connect(self.send_post)
|
||||
button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole)
|
||||
|
||||
# Cancel button
|
||||
cancel_button = QPushButton("&Cancel")
|
||||
cancel_button.setAccessibleName("Cancel Post")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Set initial focus
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Ctrl+Enter to send post
|
||||
send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self)
|
||||
send_shortcut.activated.connect(self.send_post)
|
||||
|
||||
# Escape to cancel
|
||||
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
|
||||
cancel_shortcut.activated.connect(self.reject)
|
||||
|
||||
def toggle_content_warning(self, enabled: bool):
|
||||
"""Toggle content warning field visibility"""
|
||||
if enabled:
|
||||
self.cw_edit.show()
|
||||
# Don't automatically focus - let user tab to it naturally
|
||||
else:
|
||||
self.cw_edit.hide()
|
||||
self.cw_edit.clear()
|
||||
|
||||
def update_char_count(self):
|
||||
"""Update character count display"""
|
||||
text = self.text_edit.toPlainText()
|
||||
char_count = len(text)
|
||||
self.char_count_label.setText(f"Characters: {char_count}/500")
|
||||
|
||||
# Enable/disable post button based on content
|
||||
has_content = bool(text.strip())
|
||||
within_limit = char_count <= 500
|
||||
self.post_button.setEnabled(has_content and within_limit)
|
||||
|
||||
# Update accessibility
|
||||
if char_count > 500:
|
||||
self.char_count_label.setAccessibleDescription("Character limit exceeded")
|
||||
else:
|
||||
self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining")
|
||||
|
||||
def send_post(self):
|
||||
"""Send the post"""
|
||||
content = self.text_edit.toPlainText().strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Get active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
QMessageBox.warning(self, "No Account", "Please add an account before posting.")
|
||||
return
|
||||
|
||||
# Get post settings
|
||||
visibility_text = self.visibility_combo.currentText()
|
||||
visibility_map = {
|
||||
"Public": "public",
|
||||
"Unlisted": "unlisted",
|
||||
"Followers Only": "private",
|
||||
"Direct Message": "direct"
|
||||
}
|
||||
visibility = visibility_map.get(visibility_text, "public")
|
||||
|
||||
content_warning = None
|
||||
if self.cw_checkbox.isChecked():
|
||||
content_warning = self.cw_edit.toPlainText().strip()
|
||||
|
||||
# Start background posting
|
||||
post_data = {
|
||||
'account': active_account,
|
||||
'content': content,
|
||||
'visibility': visibility,
|
||||
'content_warning': content_warning
|
||||
}
|
||||
|
||||
# Play sound when post button is pressed
|
||||
self.sound_manager.play_event("post")
|
||||
|
||||
# Emit signal with all post data for background processing
|
||||
self.post_sent.emit(post_data)
|
||||
|
||||
# Close dialog immediately
|
||||
self.accept()
|
||||
|
||||
def load_mention_suggestions(self, prefix: str):
|
||||
"""Load mention suggestions based on prefix"""
|
||||
# TODO: Implement fetching followers/following from API
|
||||
# For now, use expanded sample suggestions with realistic fediverse usernames
|
||||
sample_mentions = [
|
||||
"alice", "bob", "charlie", "diana", "eve", "frank", "grace", "henry", "ivy", "jack",
|
||||
"admin", "moderator", "announcements", "news", "updates", "support", "help",
|
||||
"alex_dev", "jane_artist", "mike_writer", "sarah_photographer", "tom_musician",
|
||||
"community", "local_news", "tech_updates", "fedi_tips", "open_source",
|
||||
"cat_lover", "dog_walker", "book_reader", "movie_fan", "game_dev", "web_designer",
|
||||
"climate_activist", "space_enthusiast", "food_blogger", "travel_tales", "art_gallery"
|
||||
]
|
||||
|
||||
# Filter by prefix (case insensitive)
|
||||
filtered = [name for name in sample_mentions if name.lower().startswith(prefix.lower())]
|
||||
self.text_edit.update_mention_list(filtered)
|
||||
|
||||
def load_emoji_suggestions(self, prefix: str):
|
||||
"""Load emoji suggestions based on prefix"""
|
||||
# The AutocompleteTextEdit already has a built-in emoji list
|
||||
# This method is called when the signal is emitted, but the
|
||||
# autocomplete logic is handled internally by the text edit widget
|
||||
# We don't need to do anything here since emojis are pre-loaded
|
||||
pass
|
||||
|
||||
def get_post_data(self) -> dict:
|
||||
"""Get the composed post data"""
|
||||
return {
|
||||
'content': self.text_edit.toPlainText().strip(),
|
||||
'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"),
|
||||
'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None
|
||||
}
|
274
src/widgets/login_dialog.py
Normal file
274
src/widgets/login_dialog.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
Login dialog for adding new fediverse accounts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QLabel, QDialogButtonBox, QMessageBox,
|
||||
QProgressBar, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
import requests
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from activitypub.client import ActivityPubClient
|
||||
from activitypub.oauth import OAuth2Handler
|
||||
|
||||
|
||||
class InstanceTestThread(QThread):
|
||||
"""Thread for testing instance connectivity"""
|
||||
|
||||
result_ready = Signal(bool, str) # success, message
|
||||
|
||||
def __init__(self, instance_url):
|
||||
super().__init__()
|
||||
self.instance_url = instance_url
|
||||
|
||||
def run(self):
|
||||
"""Test if the instance is reachable and supports ActivityPub"""
|
||||
try:
|
||||
# Clean up the URL
|
||||
if not self.instance_url.startswith(('http://', 'https://')):
|
||||
test_url = f"https://{self.instance_url}"
|
||||
else:
|
||||
test_url = self.instance_url
|
||||
|
||||
# Test instance info endpoint
|
||||
response = requests.get(f"{test_url}/api/v1/instance", timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
instance_name = data.get('title', 'Unknown Instance')
|
||||
version = data.get('version', 'Unknown')
|
||||
self.result_ready.emit(True, f"Connected to {instance_name} (Version: {version})")
|
||||
else:
|
||||
self.result_ready.emit(False, f"Instance returned status {response.status_code}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.result_ready.emit(False, "Could not connect to instance. Check URL and network connection.")
|
||||
except requests.exceptions.Timeout:
|
||||
self.result_ready.emit(False, "Connection timed out. Instance may be slow or unreachable.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.result_ready.emit(False, f"Connection error: {str(e)}")
|
||||
except Exception as e:
|
||||
self.result_ready.emit(False, f"Unexpected error: {str(e)}")
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
"""Dialog for adding new fediverse accounts"""
|
||||
|
||||
account_added = Signal(dict) # Emitted when account is successfully added
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.test_thread = None
|
||||
self.oauth_handler = None
|
||||
self.setup_ui()
|
||||
self.setup_shortcuts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the login dialog UI"""
|
||||
self.setWindowTitle("Add Fediverse Account")
|
||||
self.setMinimumSize(500, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Enter your fediverse instance URL\n"
|
||||
"Examples: mastodon.social, social.wolfe.casa, fediverse.social\n\n"
|
||||
"You will be redirected to your instance to authorize Bifrost."
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
instructions.setAccessibleName("Login Instructions")
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Instance URL input
|
||||
instance_layout = QHBoxLayout()
|
||||
instance_layout.addWidget(QLabel("Instance URL:"))
|
||||
|
||||
self.instance_edit = QLineEdit()
|
||||
self.instance_edit.setAccessibleName("Instance URL")
|
||||
self.instance_edit.setAccessibleDescription("Enter your fediverse instance URL")
|
||||
self.instance_edit.setPlaceholderText("Example: mastodon.social")
|
||||
self.instance_edit.textChanged.connect(self.on_instance_changed)
|
||||
self.instance_edit.returnPressed.connect(self.test_instance)
|
||||
instance_layout.addWidget(self.instance_edit)
|
||||
|
||||
self.test_button = QPushButton("&Test Connection")
|
||||
self.test_button.setAccessibleName("Test Instance Connection")
|
||||
self.test_button.clicked.connect(self.test_instance)
|
||||
self.test_button.setEnabled(False)
|
||||
instance_layout.addWidget(self.test_button)
|
||||
|
||||
layout.addLayout(instance_layout)
|
||||
|
||||
# Progress bar for testing
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setAccessibleName("Connection Test Progress")
|
||||
self.progress_bar.setVisible(False)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Results area
|
||||
self.result_text = QTextEdit()
|
||||
self.result_text.setAccessibleName("Connection Test Results")
|
||||
self.result_text.setMaximumHeight(100)
|
||||
self.result_text.setReadOnly(True)
|
||||
self.result_text.setVisible(False)
|
||||
layout.addWidget(self.result_text)
|
||||
|
||||
# Username input (for display purposes)
|
||||
username_layout = QHBoxLayout()
|
||||
username_layout.addWidget(QLabel("Username (optional):"))
|
||||
|
||||
self.username_edit = QLineEdit()
|
||||
self.username_edit.setAccessibleName("Username")
|
||||
self.username_edit.setAccessibleDescription("Your username on this instance (for display only)")
|
||||
self.username_edit.setPlaceholderText("username")
|
||||
username_layout.addWidget(self.username_edit)
|
||||
|
||||
layout.addLayout(username_layout)
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox()
|
||||
|
||||
# Login button
|
||||
self.login_button = QPushButton("&Login")
|
||||
self.login_button.setAccessibleName("Login to Instance")
|
||||
self.login_button.setDefault(True)
|
||||
self.login_button.clicked.connect(self.start_login)
|
||||
self.login_button.setEnabled(False)
|
||||
button_box.addButton(self.login_button, QDialogButtonBox.AcceptRole)
|
||||
|
||||
# Cancel button
|
||||
cancel_button = QPushButton("&Cancel")
|
||||
cancel_button.setAccessibleName("Cancel Login")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Set initial focus
|
||||
self.instance_edit.setFocus()
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Escape to cancel
|
||||
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
|
||||
cancel_shortcut.activated.connect(self.reject)
|
||||
|
||||
def on_instance_changed(self, text):
|
||||
"""Handle instance URL text changes"""
|
||||
has_text = bool(text.strip())
|
||||
self.test_button.setEnabled(has_text)
|
||||
self.login_button.setEnabled(False) # Require testing first
|
||||
|
||||
if not has_text:
|
||||
self.result_text.setVisible(False)
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
def test_instance(self):
|
||||
"""Test connection to the instance"""
|
||||
instance_url = self.instance_edit.text().strip()
|
||||
if not instance_url:
|
||||
return
|
||||
|
||||
self.test_button.setEnabled(False)
|
||||
self.login_button.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate progress
|
||||
self.result_text.setVisible(False)
|
||||
|
||||
# Start test thread
|
||||
self.test_thread = InstanceTestThread(instance_url)
|
||||
self.test_thread.result_ready.connect(self.on_test_result)
|
||||
self.test_thread.start()
|
||||
|
||||
def on_test_result(self, success, message):
|
||||
"""Handle instance test results"""
|
||||
self.progress_bar.setVisible(False)
|
||||
self.result_text.setVisible(True)
|
||||
self.result_text.setText(message)
|
||||
|
||||
self.test_button.setEnabled(True)
|
||||
self.login_button.setEnabled(success)
|
||||
|
||||
if success:
|
||||
self.result_text.setStyleSheet("color: green;")
|
||||
# Show success message box
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Connection Test Successful",
|
||||
f"Connection test successful!\n\n{message}\n\nYou can now proceed to login."
|
||||
)
|
||||
else:
|
||||
self.result_text.setStyleSheet("color: red;")
|
||||
# Show error message box
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Connection Test Failed",
|
||||
f"Connection test failed:\n\n{message}\n\nPlease check your instance URL and try again."
|
||||
)
|
||||
|
||||
def start_login(self):
|
||||
"""Start the OAuth login process"""
|
||||
instance_url = self.instance_edit.text().strip()
|
||||
|
||||
if not instance_url:
|
||||
return
|
||||
|
||||
# Clean up URL
|
||||
if not instance_url.startswith(('http://', 'https://')):
|
||||
instance_url = f"https://{instance_url}"
|
||||
|
||||
# Disable UI during authentication
|
||||
self.login_button.setEnabled(False)
|
||||
self.test_button.setEnabled(False)
|
||||
self.instance_edit.setEnabled(False)
|
||||
self.username_edit.setEnabled(False)
|
||||
|
||||
# Start real OAuth2 flow
|
||||
self.oauth_handler = OAuth2Handler(instance_url)
|
||||
self.oauth_handler.authentication_complete.connect(self.on_auth_success)
|
||||
self.oauth_handler.authentication_failed.connect(self.on_auth_failed)
|
||||
|
||||
if not self.oauth_handler.start_authentication():
|
||||
self.on_auth_failed("Failed to start authentication process")
|
||||
|
||||
def on_auth_success(self, account_data):
|
||||
"""Handle successful authentication"""
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Login Successful",
|
||||
f"Successfully authenticated as {account_data['display_name']} "
|
||||
f"({account_data['username']}) on {account_data['instance_url']}"
|
||||
)
|
||||
|
||||
self.account_added.emit(account_data)
|
||||
self.accept()
|
||||
|
||||
def on_auth_failed(self, error_message):
|
||||
"""Handle authentication failure"""
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Authentication Failed",
|
||||
f"Failed to authenticate with the instance:\n\n{error_message}"
|
||||
)
|
||||
|
||||
# Re-enable UI
|
||||
self.login_button.setEnabled(True)
|
||||
self.test_button.setEnabled(True)
|
||||
self.instance_edit.setEnabled(True)
|
||||
self.username_edit.setEnabled(True)
|
||||
|
||||
def get_instance_url(self) -> str:
|
||||
"""Get the cleaned instance URL"""
|
||||
url = self.instance_edit.text().strip()
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
url = f"https://{url}"
|
||||
return url
|
||||
|
||||
def get_username(self) -> str:
|
||||
"""Get the entered username"""
|
||||
return self.username_edit.text().strip()
|
308
src/widgets/settings_dialog.py
Normal file
308
src/widgets/settings_dialog.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""
|
||||
Settings dialog for Bifrost configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QComboBox, QPushButton, QDialogButtonBox,
|
||||
QGroupBox, QCheckBox, QSpinBox, QTabWidget, QWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from config.settings import SettingsManager
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Main settings dialog for Bifrost"""
|
||||
|
||||
settings_changed = Signal() # Emitted when settings are saved
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = SettingsManager()
|
||||
self.setup_ui()
|
||||
self.load_current_settings()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the settings dialog UI"""
|
||||
self.setWindowTitle("Bifrost Settings")
|
||||
self.setMinimumSize(500, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create tab widget for organized settings
|
||||
self.tabs = QTabWidget()
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Audio settings tab
|
||||
self.setup_audio_tab()
|
||||
|
||||
# Desktop notifications tab
|
||||
self.setup_notifications_tab()
|
||||
|
||||
# Accessibility settings tab
|
||||
self.setup_accessibility_tab()
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Apply
|
||||
)
|
||||
button_box.accepted.connect(self.save_and_close)
|
||||
button_box.rejected.connect(self.reject)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply_settings)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def setup_audio_tab(self):
|
||||
"""Set up the audio settings tab"""
|
||||
audio_widget = QWidget()
|
||||
layout = QVBoxLayout(audio_widget)
|
||||
|
||||
# Sound pack selection
|
||||
sound_group = QGroupBox("Sound Pack")
|
||||
sound_layout = QFormLayout(sound_group)
|
||||
|
||||
self.sound_pack_combo = AccessibleComboBox()
|
||||
self.sound_pack_combo.setAccessibleName("Sound Pack Selection")
|
||||
self.sound_pack_combo.setAccessibleDescription("Choose which sound pack to use for audio feedback")
|
||||
|
||||
# Populate sound packs
|
||||
self.load_sound_packs()
|
||||
|
||||
sound_layout.addRow("Sound Pack:", self.sound_pack_combo)
|
||||
layout.addWidget(sound_group)
|
||||
|
||||
# Audio volume settings
|
||||
volume_group = QGroupBox("Volume Settings")
|
||||
volume_layout = QFormLayout(volume_group)
|
||||
|
||||
self.master_volume = QSpinBox()
|
||||
self.master_volume.setRange(0, 100)
|
||||
self.master_volume.setSuffix("%")
|
||||
self.master_volume.setAccessibleName("Master Volume")
|
||||
self.master_volume.setAccessibleDescription("Overall volume for all sounds")
|
||||
volume_layout.addRow("Master Volume:", self.master_volume)
|
||||
|
||||
self.notification_volume = QSpinBox()
|
||||
self.notification_volume.setRange(0, 100)
|
||||
self.notification_volume.setSuffix("%")
|
||||
self.notification_volume.setAccessibleName("Notification Volume")
|
||||
self.notification_volume.setAccessibleDescription("Volume for notification sounds")
|
||||
volume_layout.addRow("Notification Volume:", self.notification_volume)
|
||||
|
||||
layout.addWidget(volume_group)
|
||||
|
||||
# Audio enable/disable options
|
||||
options_group = QGroupBox("Audio Options")
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
|
||||
self.enable_sounds = QCheckBox("Enable sound effects")
|
||||
self.enable_sounds.setAccessibleName("Enable Sound Effects")
|
||||
self.enable_sounds.setAccessibleDescription("Turn sound effects on or off globally")
|
||||
options_layout.addWidget(self.enable_sounds)
|
||||
|
||||
self.enable_post_sounds = QCheckBox("Play sounds for post actions")
|
||||
self.enable_post_sounds.setAccessibleName("Post Action Sounds")
|
||||
self.enable_post_sounds.setAccessibleDescription("Play sounds when posting, boosting, or favoriting")
|
||||
options_layout.addWidget(self.enable_post_sounds)
|
||||
|
||||
self.enable_timeline_sounds = QCheckBox("Play sounds for timeline updates")
|
||||
self.enable_timeline_sounds.setAccessibleName("Timeline Update Sounds")
|
||||
self.enable_timeline_sounds.setAccessibleDescription("Play sounds when the timeline refreshes")
|
||||
options_layout.addWidget(self.enable_timeline_sounds)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(audio_widget, "&Audio")
|
||||
|
||||
def setup_notifications_tab(self):
|
||||
"""Set up the desktop notifications settings tab"""
|
||||
notifications_widget = QWidget()
|
||||
layout = QVBoxLayout(notifications_widget)
|
||||
|
||||
# Desktop notifications group
|
||||
desktop_group = QGroupBox("Desktop Notifications")
|
||||
desktop_layout = QVBoxLayout(desktop_group)
|
||||
|
||||
self.enable_desktop_notifications = QCheckBox("Enable desktop notifications")
|
||||
self.enable_desktop_notifications.setAccessibleName("Enable Desktop Notifications")
|
||||
self.enable_desktop_notifications.setAccessibleDescription("Show desktop notifications for various events")
|
||||
desktop_layout.addWidget(self.enable_desktop_notifications)
|
||||
|
||||
# Notification types
|
||||
types_group = QGroupBox("Notification Types")
|
||||
types_layout = QVBoxLayout(types_group)
|
||||
|
||||
self.notify_direct_messages = QCheckBox("Direct/Private messages")
|
||||
self.notify_direct_messages.setAccessibleName("Direct Message Notifications")
|
||||
self.notify_direct_messages.setAccessibleDescription("Show notifications for direct messages")
|
||||
types_layout.addWidget(self.notify_direct_messages)
|
||||
|
||||
self.notify_mentions = QCheckBox("Mentions")
|
||||
self.notify_mentions.setAccessibleName("Mention Notifications")
|
||||
self.notify_mentions.setAccessibleDescription("Show notifications when you are mentioned")
|
||||
types_layout.addWidget(self.notify_mentions)
|
||||
|
||||
self.notify_boosts = QCheckBox("Boosts/Reblogs")
|
||||
self.notify_boosts.setAccessibleName("Boost Notifications")
|
||||
self.notify_boosts.setAccessibleDescription("Show notifications when your posts are boosted")
|
||||
types_layout.addWidget(self.notify_boosts)
|
||||
|
||||
self.notify_favorites = QCheckBox("Favorites")
|
||||
self.notify_favorites.setAccessibleName("Favorite Notifications")
|
||||
self.notify_favorites.setAccessibleDescription("Show notifications when your posts are favorited")
|
||||
types_layout.addWidget(self.notify_favorites)
|
||||
|
||||
self.notify_follows = QCheckBox("New followers")
|
||||
self.notify_follows.setAccessibleName("Follow Notifications")
|
||||
self.notify_follows.setAccessibleDescription("Show notifications for new followers")
|
||||
types_layout.addWidget(self.notify_follows)
|
||||
|
||||
self.notify_timeline_updates = QCheckBox("Timeline updates")
|
||||
self.notify_timeline_updates.setAccessibleName("Timeline Update Notifications")
|
||||
self.notify_timeline_updates.setAccessibleDescription("Show notifications for new posts in timeline")
|
||||
types_layout.addWidget(self.notify_timeline_updates)
|
||||
|
||||
layout.addWidget(desktop_group)
|
||||
layout.addWidget(types_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(notifications_widget, "&Notifications")
|
||||
|
||||
def setup_accessibility_tab(self):
|
||||
"""Set up the accessibility settings tab"""
|
||||
accessibility_widget = QWidget()
|
||||
layout = QVBoxLayout(accessibility_widget)
|
||||
|
||||
# Navigation settings
|
||||
nav_group = QGroupBox("Navigation Settings")
|
||||
nav_layout = QFormLayout(nav_group)
|
||||
|
||||
self.page_step_size = QSpinBox()
|
||||
self.page_step_size.setRange(1, 20)
|
||||
self.page_step_size.setAccessibleName("Page Step Size")
|
||||
self.page_step_size.setAccessibleDescription("Number of posts to jump when using Page Up/Down")
|
||||
nav_layout.addRow("Page Step Size:", self.page_step_size)
|
||||
|
||||
layout.addWidget(nav_group)
|
||||
|
||||
# Screen reader options
|
||||
sr_group = QGroupBox("Screen Reader Options")
|
||||
sr_layout = QVBoxLayout(sr_group)
|
||||
|
||||
self.verbose_announcements = QCheckBox("Verbose announcements")
|
||||
self.verbose_announcements.setAccessibleName("Verbose Announcements")
|
||||
self.verbose_announcements.setAccessibleDescription("Provide detailed descriptions for screen readers")
|
||||
sr_layout.addWidget(self.verbose_announcements)
|
||||
|
||||
self.announce_thread_state = QCheckBox("Announce thread expand/collapse state")
|
||||
self.announce_thread_state.setAccessibleName("Thread State Announcements")
|
||||
self.announce_thread_state.setAccessibleDescription("Announce when threads are expanded or collapsed")
|
||||
sr_layout.addWidget(self.announce_thread_state)
|
||||
|
||||
layout.addWidget(sr_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(accessibility_widget, "A&ccessibility")
|
||||
|
||||
def load_sound_packs(self):
|
||||
"""Load available sound packs from the sounds directory"""
|
||||
self.sound_pack_combo.clear()
|
||||
|
||||
# Add default "None" option
|
||||
self.sound_pack_combo.addItem("None (No sounds)", "none")
|
||||
|
||||
# Look for sound pack directories
|
||||
sounds_dir = "sounds"
|
||||
if os.path.exists(sounds_dir):
|
||||
for item in os.listdir(sounds_dir):
|
||||
pack_dir = os.path.join(sounds_dir, item)
|
||||
if os.path.isdir(pack_dir):
|
||||
pack_json = os.path.join(pack_dir, "pack.json")
|
||||
if os.path.exists(pack_json):
|
||||
# Valid sound pack
|
||||
self.sound_pack_combo.addItem(item, item)
|
||||
|
||||
# Also check in XDG data directory
|
||||
try:
|
||||
data_sounds_dir = self.settings.get_sounds_dir()
|
||||
if os.path.exists(data_sounds_dir) and data_sounds_dir != sounds_dir:
|
||||
for item in os.listdir(data_sounds_dir):
|
||||
pack_dir = os.path.join(data_sounds_dir, item)
|
||||
if os.path.isdir(pack_dir):
|
||||
pack_json = os.path.join(pack_dir, "pack.json")
|
||||
if os.path.exists(pack_json):
|
||||
# Avoid duplicates
|
||||
if self.sound_pack_combo.findData(item) == -1:
|
||||
self.sound_pack_combo.addItem(f"{item} (System)", item)
|
||||
except Exception:
|
||||
pass # Ignore errors in system sound pack detection
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load current settings into the dialog"""
|
||||
# Audio settings
|
||||
current_pack = self.settings.get('audio', 'sound_pack', 'none')
|
||||
index = self.sound_pack_combo.findData(current_pack)
|
||||
if index >= 0:
|
||||
self.sound_pack_combo.setCurrentIndex(index)
|
||||
|
||||
self.master_volume.setValue(int(self.settings.get('audio', 'master_volume', 100) or 100))
|
||||
self.notification_volume.setValue(int(self.settings.get('audio', 'notification_volume', 100) or 100))
|
||||
|
||||
self.enable_sounds.setChecked(bool(self.settings.get('audio', 'enabled', True)))
|
||||
self.enable_post_sounds.setChecked(bool(self.settings.get('audio', 'post_sounds', True)))
|
||||
self.enable_timeline_sounds.setChecked(bool(self.settings.get('audio', 'timeline_sounds', True)))
|
||||
|
||||
# Desktop notification settings (defaults: DMs, mentions, follows ON; others OFF)
|
||||
self.enable_desktop_notifications.setChecked(bool(self.settings.get('notifications', 'enabled', True)))
|
||||
self.notify_direct_messages.setChecked(bool(self.settings.get('notifications', 'direct_messages', True)))
|
||||
self.notify_mentions.setChecked(bool(self.settings.get('notifications', 'mentions', True)))
|
||||
self.notify_boosts.setChecked(bool(self.settings.get('notifications', 'boosts', False)))
|
||||
self.notify_favorites.setChecked(bool(self.settings.get('notifications', 'favorites', False)))
|
||||
self.notify_follows.setChecked(bool(self.settings.get('notifications', 'follows', True)))
|
||||
self.notify_timeline_updates.setChecked(bool(self.settings.get('notifications', 'timeline_updates', False)))
|
||||
|
||||
# Accessibility settings
|
||||
self.page_step_size.setValue(int(self.settings.get('accessibility', 'page_step_size', 5) or 5))
|
||||
self.verbose_announcements.setChecked(bool(self.settings.get('accessibility', 'verbose_announcements', True)))
|
||||
self.announce_thread_state.setChecked(bool(self.settings.get('accessibility', 'announce_thread_state', True)))
|
||||
|
||||
def apply_settings(self):
|
||||
"""Apply the current settings without closing the dialog"""
|
||||
# Audio settings
|
||||
selected_pack = self.sound_pack_combo.currentData()
|
||||
self.settings.set('audio', 'sound_pack', selected_pack)
|
||||
self.settings.set('audio', 'master_volume', self.master_volume.value())
|
||||
self.settings.set('audio', 'notification_volume', self.notification_volume.value())
|
||||
|
||||
self.settings.set('audio', 'enabled', self.enable_sounds.isChecked())
|
||||
self.settings.set('audio', 'post_sounds', self.enable_post_sounds.isChecked())
|
||||
self.settings.set('audio', 'timeline_sounds', self.enable_timeline_sounds.isChecked())
|
||||
|
||||
# Desktop notification settings
|
||||
self.settings.set('notifications', 'enabled', self.enable_desktop_notifications.isChecked())
|
||||
self.settings.set('notifications', 'direct_messages', self.notify_direct_messages.isChecked())
|
||||
self.settings.set('notifications', 'mentions', self.notify_mentions.isChecked())
|
||||
self.settings.set('notifications', 'boosts', self.notify_boosts.isChecked())
|
||||
self.settings.set('notifications', 'favorites', self.notify_favorites.isChecked())
|
||||
self.settings.set('notifications', 'follows', self.notify_follows.isChecked())
|
||||
self.settings.set('notifications', 'timeline_updates', self.notify_timeline_updates.isChecked())
|
||||
|
||||
# Accessibility settings
|
||||
self.settings.set('accessibility', 'page_step_size', self.page_step_size.value())
|
||||
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
|
||||
self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.isChecked())
|
||||
|
||||
# Save to file
|
||||
self.settings.save_settings()
|
||||
|
||||
# Emit signal so other components can update
|
||||
self.settings_changed.emit()
|
||||
|
||||
def save_and_close(self):
|
||||
"""Save settings and close the dialog"""
|
||||
self.apply_settings()
|
||||
self.accept()
|
298
src/widgets/timeline_view.py
Normal file
298
src/widgets/timeline_view.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
Timeline view widget for displaying posts and threads
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction
|
||||
from typing import Optional
|
||||
|
||||
from accessibility.accessible_tree import AccessibleTreeWidget
|
||||
from audio.sound_manager import SoundManager
|
||||
from notifications.notification_manager import NotificationManager
|
||||
from config.settings import SettingsManager
|
||||
from config.accounts import AccountManager
|
||||
from activitypub.client import ActivityPubClient
|
||||
from models.post import Post
|
||||
|
||||
|
||||
class TimelineView(AccessibleTreeWidget):
|
||||
"""Main timeline display widget"""
|
||||
|
||||
# Signals for post actions
|
||||
reply_requested = Signal(object) # Post object
|
||||
boost_requested = Signal(object) # Post object
|
||||
favorite_requested = Signal(object) # Post object
|
||||
profile_requested = Signal(object) # Post object
|
||||
|
||||
def __init__(self, account_manager: AccountManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.timeline_type = "home"
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.notification_manager = NotificationManager(self.settings)
|
||||
self.account_manager = account_manager
|
||||
self.activitypub_client = None
|
||||
self.posts = [] # Store loaded posts
|
||||
self.setup_ui()
|
||||
self.refresh()
|
||||
|
||||
# Connect sound events
|
||||
self.item_state_changed.connect(self.on_state_changed)
|
||||
|
||||
# Enable context menu
|
||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.show_context_menu)
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the timeline UI"""
|
||||
# Set up columns
|
||||
self.setColumnCount(1)
|
||||
self.setHeaderLabels(["Posts"])
|
||||
|
||||
# Hide the header
|
||||
self.header().hide()
|
||||
|
||||
# Set selection behavior
|
||||
self.setSelectionBehavior(QTreeWidget.SelectRows)
|
||||
self.setSelectionMode(QTreeWidget.SingleSelection)
|
||||
|
||||
# Enable keyboard navigation
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
|
||||
# Set accessible properties
|
||||
self.setAccessibleName("Timeline")
|
||||
self.setAccessibleDescription("Timeline showing posts and conversations")
|
||||
|
||||
def set_timeline_type(self, timeline_type: str):
|
||||
"""Set the timeline type (home, local, federated)"""
|
||||
self.timeline_type = timeline_type
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the timeline content"""
|
||||
self.clear()
|
||||
|
||||
# Get active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
self.show_empty_message("Nothing to see here. To get content connect to an instance.")
|
||||
return
|
||||
|
||||
# Create ActivityPub client for active account
|
||||
self.activitypub_client = ActivityPubClient(
|
||||
active_account.instance_url,
|
||||
active_account.access_token
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch timeline or notifications
|
||||
if self.timeline_type == "notifications":
|
||||
timeline_data = self.activitypub_client.get_notifications(limit=20)
|
||||
else:
|
||||
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=20)
|
||||
self.load_timeline_data(timeline_data)
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch timeline: {e}")
|
||||
# Show error message instead of sample data
|
||||
self.show_empty_message(f"Failed to load timeline: {str(e)}\nCheck your connection and try refreshing.")
|
||||
|
||||
def load_timeline_data(self, timeline_data):
|
||||
"""Load real timeline data from ActivityPub API"""
|
||||
self.posts = []
|
||||
|
||||
if self.timeline_type == "notifications":
|
||||
# Handle notifications data structure
|
||||
for notification_data in timeline_data:
|
||||
try:
|
||||
notification_type = notification_data['type']
|
||||
sender = notification_data['account']['display_name'] or notification_data['account']['username']
|
||||
|
||||
# Notifications with status (mentions, boosts, favorites)
|
||||
if 'status' in notification_data:
|
||||
post = Post.from_api_dict(notification_data['status'])
|
||||
# Add notification metadata to post
|
||||
post.notification_type = notification_type
|
||||
post.notification_account = notification_data['account']['acct']
|
||||
self.posts.append(post)
|
||||
|
||||
# Show desktop notification
|
||||
content_preview = post.get_content_text()[:100] + "..." if len(post.get_content_text()) > 100 else post.get_content_text()
|
||||
|
||||
if notification_type == 'mention':
|
||||
self.notification_manager.notify_mention(sender, content_preview)
|
||||
elif notification_type == 'reblog':
|
||||
self.notification_manager.notify_boost(sender, content_preview)
|
||||
elif notification_type == 'favourite':
|
||||
self.notification_manager.notify_favorite(sender, content_preview)
|
||||
elif notification_type == 'follow':
|
||||
# Handle follow notifications without status
|
||||
self.notification_manager.notify_follow(sender)
|
||||
except Exception as e:
|
||||
print(f"Error parsing notification: {e}")
|
||||
continue
|
||||
else:
|
||||
# Handle regular timeline data structure
|
||||
new_posts = []
|
||||
for status_data in timeline_data:
|
||||
try:
|
||||
post = Post.from_api_dict(status_data)
|
||||
self.posts.append(post)
|
||||
new_posts.append(post)
|
||||
except Exception as e:
|
||||
print(f"Error parsing post: {e}")
|
||||
continue
|
||||
|
||||
# Show timeline update notification if new posts were loaded
|
||||
if new_posts and len(new_posts) > 0:
|
||||
timeline_name = {
|
||||
'home': 'home timeline',
|
||||
'local': 'local timeline',
|
||||
'federated': 'federated timeline'
|
||||
}.get(self.timeline_type, 'timeline')
|
||||
|
||||
self.notification_manager.notify_timeline_update(len(new_posts), timeline_name)
|
||||
|
||||
# Build thread structure
|
||||
self.build_threaded_timeline()
|
||||
|
||||
def build_threaded_timeline(self):
|
||||
"""Build threaded timeline from posts"""
|
||||
# Group posts by conversation
|
||||
conversations = {}
|
||||
top_level_posts = []
|
||||
|
||||
for post in self.posts:
|
||||
if post.in_reply_to_id:
|
||||
# This is a reply
|
||||
if post.in_reply_to_id not in conversations:
|
||||
conversations[post.in_reply_to_id] = []
|
||||
conversations[post.in_reply_to_id].append(post)
|
||||
else:
|
||||
# This is a top-level post
|
||||
top_level_posts.append(post)
|
||||
|
||||
# Create tree items
|
||||
for post in top_level_posts:
|
||||
post_item = self.create_post_item(post)
|
||||
self.addTopLevelItem(post_item)
|
||||
|
||||
# Add replies if any
|
||||
if post.id in conversations:
|
||||
self.add_replies(post_item, conversations[post.id], conversations)
|
||||
|
||||
# Collapse all initially
|
||||
self.collapseAll()
|
||||
|
||||
def create_post_item(self, post: Post) -> QTreeWidgetItem:
|
||||
"""Create a tree item for a post"""
|
||||
# Get display text
|
||||
summary = post.get_summary_for_screen_reader()
|
||||
|
||||
# Create item
|
||||
item = QTreeWidgetItem([summary])
|
||||
item.setData(0, Qt.UserRole, post) # Store post object
|
||||
item.setData(0, Qt.AccessibleTextRole, summary)
|
||||
|
||||
return item
|
||||
|
||||
def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations):
|
||||
"""Recursively add replies to a post"""
|
||||
for reply in replies:
|
||||
reply_item = self.create_post_item(reply)
|
||||
parent_item.addChild(reply_item)
|
||||
|
||||
# Add nested replies
|
||||
if reply.id in all_conversations:
|
||||
self.add_replies(reply_item, all_conversations[reply.id], all_conversations)
|
||||
|
||||
def show_empty_message(self, message: str):
|
||||
"""Show an empty timeline with a message"""
|
||||
item = QTreeWidgetItem([message])
|
||||
item.setData(0, Qt.AccessibleTextRole, message)
|
||||
item.setDisabled(True) # Make it non-selectable
|
||||
self.addTopLevelItem(item)
|
||||
|
||||
def get_selected_post(self) -> Optional[Post]:
|
||||
"""Get the currently selected post"""
|
||||
current = self.currentItem()
|
||||
if current:
|
||||
return current.data(0, Qt.UserRole)
|
||||
return None
|
||||
|
||||
def get_current_post_info(self) -> str:
|
||||
"""Get information about the currently selected post"""
|
||||
current = self.currentItem()
|
||||
if not current:
|
||||
return "No post selected"
|
||||
|
||||
# Check if this is a top-level post with replies
|
||||
if current.parent() is None and current.childCount() > 0:
|
||||
child_count = current.childCount()
|
||||
expanded = "expanded" if current.isExpanded() else "collapsed"
|
||||
return f"{current.text(0)} ({child_count} replies, {expanded})"
|
||||
elif current.parent() is not None:
|
||||
return f"Reply: {current.text(0)}"
|
||||
else:
|
||||
return current.text(0)
|
||||
|
||||
def on_state_changed(self, item, state):
|
||||
"""Handle item state changes with sound feedback"""
|
||||
if state == "expanded":
|
||||
self.sound_manager.play_expand()
|
||||
elif state == "collapsed":
|
||||
self.sound_manager.play_collapse()
|
||||
|
||||
def add_new_posts(self, posts):
|
||||
"""Add new posts to timeline with sound notification"""
|
||||
# TODO: Implement adding real posts from API
|
||||
if posts:
|
||||
self.sound_manager.play_timeline_update()
|
||||
|
||||
def show_context_menu(self, position):
|
||||
"""Show context menu for post actions"""
|
||||
item = self.itemAt(position)
|
||||
if not item:
|
||||
return
|
||||
|
||||
post = item.data(0, Qt.UserRole)
|
||||
if not post:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Reply action
|
||||
reply_action = QAction("&Reply", self)
|
||||
reply_action.setStatusTip("Reply to this post")
|
||||
reply_action.triggered.connect(lambda: self.reply_requested.emit(post))
|
||||
menu.addAction(reply_action)
|
||||
|
||||
# Boost action
|
||||
boost_text = "Un&boost" if post.reblogged else "&Boost"
|
||||
boost_action = QAction(boost_text, self)
|
||||
boost_action.setStatusTip(f"{boost_text.replace('&', '')} this post")
|
||||
boost_action.triggered.connect(lambda: self.boost_requested.emit(post))
|
||||
menu.addAction(boost_action)
|
||||
|
||||
# Favorite action
|
||||
fav_text = "&Unfavorite" if post.favourited else "&Favorite"
|
||||
fav_action = QAction(fav_text, self)
|
||||
fav_action.setStatusTip(f"{fav_text.replace('&', '')} this post")
|
||||
fav_action.triggered.connect(lambda: self.favorite_requested.emit(post))
|
||||
menu.addAction(fav_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View profile action
|
||||
profile_action = QAction("View &Profile", self)
|
||||
profile_action.setStatusTip(f"View profile of {post.account.display_name}")
|
||||
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
|
||||
menu.addAction(profile_action)
|
||||
|
||||
# Show menu
|
||||
menu.exec(self.mapToGlobal(position))
|
||||
|
||||
def announce_current_item(self):
|
||||
"""Announce the current item for screen readers"""
|
||||
# This will be handled by the AccessibleTreeWidget
|
||||
pass
|
Reference in New Issue
Block a user