UNSTABLE | switch to urwid (fixing chat list)

This commit is contained in:
wheelchairy 2025-03-27 11:23:05 +03:00
parent fca2d580b0
commit 4992e71948
12 changed files with 104 additions and 1072 deletions

View File

@ -2,6 +2,10 @@
# Их использование крайне нежелательно! # Их использование крайне нежелательно!
# Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже. # Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже.
# Спасибо за понимание! # Спасибо за понимание!
API_ID=2040 API_ID=2040
API_HASH=b18441a1ff607e10a989891a5462e627 API_HASH=b18441a1ff607e10a989891a5462e627
# Настройки приложения
APP_NAME=Telegram TUI
APP_VERSION=1.0
DEVICE_MODEL=Desktop

25
.gitignore vendored
View File

@ -1,10 +1,19 @@
test.py # Python
*.session __pycache__/
*.session-journal *.py[cod]
__pycache__ *$py.class
*/__pycache__ *.so
tokens.py .Python
.env
venv*
venv/ venv/
# Telegram session files
*.session
*.session-journal
# Environment
.env
# IDE
.vscode/
.idea/

View File

@ -1,26 +1,26 @@
# Тальк # Telegram TUI Client
Тальк — клиент Telegram с текстовым пользовательским интерфейсом, написанный на Python, Telethon и Textual. Консольный клиент Telegram на базе urwid с поддержкой:
- Просмотра чатов и сообщений
## Требования - Поиска по чатам
- Навигации с помощью клавиатуры
- Python 3.12 - Поддержки папок (Архив)
- pyenv (рекомендуется для управления версиями Python) - Корректного отображения эмодзи и Unicode
## Установка ## Установка
1. Установите Python 3.12 с помощью pyenv: 1. Клонируйте репозиторий:
```bash ```bash
pyenv install 3.12 git clone https://github.com/yourusername/talc.git
pyenv local 3.12 cd talc
``` ```
2. Создайте и активируйте виртуальное окружение: 2. Создайте виртуальное окружение и активируйте его:
```bash ```bash
python -m venv .venv python -m venv venv
source .venv/bin/activate # для Linux/macOS source venv/bin/activate # Linux/macOS
# или # или
.venv\Scripts\activate # для Windows venv\Scripts\activate # Windows
``` ```
3. Установите зависимости: 3. Установите зависимости:
@ -28,15 +28,42 @@ source .venv/bin/activate # для Linux/macOS
pip install -r requirements.txt pip install -r requirements.txt
``` ```
4. Настройте переменные окружения: 4. Скопируйте `.env.example` в `.env`:
```bash ```bash
cp .env.example .env cp .env.example .env
# Отредактируйте .env файл, добавив свои API ключи
# Получите ключи на https://my.telegram.org/apps
``` ```
5. Получите API ключи на https://my.telegram.org/apps и добавьте их в `.env`
## Запуск ## Запуск
```bash ```bash
python src/app.py python main_urwid.py
``` ```
## Управление
- Tab: Переключение фокуса между поиском и списком чатов
- ↑↓: Выбор чата
- Enter: Открыть выбранный чат
- Esc: Вернуться к списку чатов
- /: Быстрый доступ к поиску
- []: Переключение между основными чатами и архивом
- Q: Выход
## Структура проекта
```
talc/
├── main_urwid.py # Основной файл запуска
├── requirements.txt # Зависимости проекта
├── .env.example # Пример конфигурации
├── .env # Конфигурация (не включена в git)
└── urwid_client/ # Основной код приложения
├── __init__.py
└── telegram_tui.py # Реализация клиента
```
## Лицензия
MIT

View File

@ -1,8 +0,0 @@
#!/usr/bin/python
"""Файл инициализации приложения"""
from src.app import TelegramTUI
if __name__ == "__main__":
tg = TelegramTUI()
tg.run()

View File

@ -1,7 +1,5 @@
textual urwid>=2.1.2
telethon telethon>=1.34.0
python-dotenv python-dotenv>=1.0.0
emoji emoji>=2.10.1
Pillow nest_asyncio>=1.6.0
pywhatkit
urwid

View File

@ -1,5 +0,0 @@
urwid>=2.1.2
telethon>=1.34.0
python-dotenv>=1.0.0
emoji>=2.10.1
nest_asyncio>=1.6.0

View File

@ -1,111 +0,0 @@
"""Файл с основным классом приложения"""
from textual.app import App
from textual.binding import Binding
from telethon import TelegramClient
import os
import asyncio
from src.screens import AuthScreen, ChatScreen
from textual import log
from dotenv import load_dotenv
# Загружаем переменные окружения из .env файла
load_dotenv()
# Проверяем наличие API ключей
api_id = os.getenv("API_ID")
api_hash = os.getenv("API_HASH")
if not api_id or not api_hash:
raise ValueError(
"API_ID и API_HASH не найдены в .env файле. "
"Пожалуйста, скопируйте .env.example в .env и заполните свои ключи."
)
# Преобразуем API_ID в число
api_id = int(api_id)
class TelegramTUI(App):
"""Класс основного приложения"""
BINDINGS = [
Binding("ctrl+c,ctrl+q", "quit", "Выход", show=True),
]
CSS_PATH = "style.tcss"
TITLE = "Telegram TUI"
def __init__(
self,
driver_class=None,
css_path=None,
watch_css=False
):
super().__init__(
driver_class=driver_class,
css_path=css_path,
watch_css=watch_css
)
# Инициализируем клиент Telegram
session_file = "talc.session"
# Если сессия существует и заблокирована, удаляем её
if os.path.exists(session_file):
try:
os.remove(session_file)
log("Старая сессия удалена")
except Exception as e:
log(f"Ошибка удаления сессии: {e}")
self.telegram_client = TelegramClient(
session_file,
api_id=api_id,
api_hash=api_hash,
system_version="macOS 14.3.1",
device_model="MacBook",
app_version="1.0"
)
async def on_mount(self) -> None:
"""Действия при запуске приложения"""
try:
# Подключаемся к Telegram
await self.telegram_client.connect()
log("Подключено к Telegram")
# Устанавливаем экраны
chat_screen = ChatScreen(telegram_client=self.telegram_client)
self.install_screen(chat_screen, name="chats")
auth_screen = AuthScreen(telegram_client=self.telegram_client)
self.install_screen(auth_screen, name="auth")
# Проверяем авторизацию и показываем нужный экран
if await self.telegram_client.is_user_authorized():
await self.push_screen("chats")
else:
await self.push_screen("auth")
except Exception as e:
log(f"Ошибка при запуске: {e}")
self.exit()
async def on_unmount(self) -> None:
"""Действия при закрытии приложения"""
try:
if self.telegram_client and self.telegram_client.is_connected():
await self.telegram_client.disconnect()
log("Отключено от Telegram")
except Exception as e:
log(f"Ошибка при закрытии: {e}")
async def action_quit(self) -> None:
"""Действие при выходе из приложения"""
try:
if self.telegram_client and self.telegram_client.is_connected():
await self.telegram_client.disconnect()
log("Отключено от Telegram")
except Exception as e:
log(f"Ошибка при выходе: {e}")
self.exit()

View File

@ -1,231 +0,0 @@
"""Файл с кастомными экранами приложения"""
from textual.screen import Screen
from textual.widgets import Label, Input, Footer, Static, ContentSwitcher
from textual.containers import Vertical, Horizontal, VerticalScroll
from textual.events import Key
from telethon.errors import SessionPasswordNeededError
from telethon import TelegramClient, events
from src.widgets import Dialog, Chat, normalize_text
from textual import log
class AuthScreen(Screen):
"""Класс экрана логина в аккаунт"""
def __init__(
self,
name = None,
id = None,
classes = None,
telegram_client: TelegramClient | None = None
):
super().__init__(name, id, classes)
self.client = telegram_client
def on_mount(self):
self.ac = self.query_one("#auth_container")
def compose(self):
with Vertical(id="auth_container"):
yield Label(normalize_text("Добро пожаловать в Telegram TUI"))
yield Input(placeholder=normalize_text("Номер телефона"), id="phone")
yield Input(placeholder=normalize_text("Код"), id="code", disabled=True)
yield Input(
placeholder=normalize_text("Пароль"),
id="password",
password=True,
disabled=True
)
async def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "phone":
self.phone = normalize_text(event.value)
self.ac.query_one("#phone").disabled = True
self.ac.query_one("#code").disabled = False
await self.client.send_code_request(phone=self.phone)
elif event.input.id == "code":
try:
self.code = normalize_text(event.value)
self.ac.query_one("#code").disabled = True
await self.client.sign_in(phone=self.phone, code=self.code)
self.app.pop_screen()
self.app.push_screen("chats")
except SessionPasswordNeededError:
self.ac.query_one("#code").disabled = True
self.ac.query_one("#password").disabled = False
elif event.input.id == "password":
self.password = normalize_text(event.value)
await self.client.sign_in(password=self.password)
await self.client.start()
self.app.pop_screen()
self.app.push_screen("chats")
class ChatScreen(Screen):
"""Класс экрана чатов, он же основной экран приложения"""
def __init__(
self,
name = None,
id = None,
classes = None,
telegram_client: TelegramClient | None = None
):
super().__init__(name, id, classes)
self.telegram_client = telegram_client
self.search_query = ""
self.selected_chat_index = 0
self.chats = []
self.focused_element = "search" # search, chat_list
self.current_folder = None # None - основная папка, число - ID папки
self.folders = [] # Список доступных папок
async def on_mount(self):
self.limit = 100
self.chat_container = self\
.query_one("#main_container")\
.query_one("#chats")\
.query_one("#chat_container")
self.search_input = self.query_one("#search_input")
self.help_label = self.query_one("#help_label")
# Получаем список папок
try:
self.folders = await self.telegram_client.get_dialogs(folder=1)
log(f"Найдено папок: {len(self.folders)}")
except Exception as e:
log(f"Ошибка получения папок: {e}")
self.folders = []
# Загружаем чаты
log("Первоначальная загрузка виджетов чатов...")
try:
dialogs = await self.telegram_client.get_dialogs(
limit=self.limit,
archived=False,
folder=self.current_folder
)
self.mount_chats(len(dialogs))
log("Первоначальная загрузка виджетов чата завершена")
except Exception as e:
log(f"Ошибка загрузки чатов: {e}")
self.is_chat_update_blocked = False
await self.update_chat_list()
log("Первоначальная загрузка чатов завершена")
for event in (
events.NewMessage,
events.MessageDeleted,
events.MessageEdited
):
self.telegram_client.on(event())(self.update_chat_list)
def mount_chats(self, limit: int):
log("Загрузка виджетов чатов...")
chats_amount = len(self.chat_container.query(Chat))
if limit > chats_amount:
for i in range(limit - chats_amount):
chat = Chat(id=f"chat-{i + chats_amount + 1}")
self.chat_container.mount(chat)
elif limit < chats_amount:
for i in range(chats_amount - limit):
self.chat_container.query(Chat).last().remove()
log("Виджеты чатов загружены")
async def update_chat_list(self, event = None):
log("Запрос обновления чатов")
if not self.is_chat_update_blocked:
self.is_chat_update_blocked = True
try:
# Получаем диалоги для текущей папки
dialogs = await self.telegram_client.get_dialogs(
limit=self.limit,
archived=False,
folder=self.current_folder
)
log(f"Получены диалоги для папки {self.current_folder}")
# Фильтруем диалоги по поисковому запросу
if self.search_query:
dialogs = [
d for d in dialogs
if self.search_query.lower() in normalize_text(d.name).lower()
]
limit = len(dialogs)
self.mount_chats(limit)
for i in range(limit):
chat = self.chat_container.query_one(f"#chat-{i + 1}")
chat.username = normalize_text(str(dialogs[i].name))
chat.msg = normalize_text(str(dialogs[i].message.message if dialogs[i].message else ""))
chat.peer_id = dialogs[i].id
chat.is_selected = (i == self.selected_chat_index)
chat.folder = 1 if self.current_folder else 0
except Exception as e:
log(f"Ошибка обновления чатов: {e}")
finally:
self.is_chat_update_blocked = False
log("Чаты обновлены")
else:
log("Обновление чатов невозможно: уже выполняется")
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "search_input":
self.search_query = normalize_text(event.value)
self.selected_chat_index = 0
self.update_chat_list()
def on_key(self, event: Key) -> None:
if event.key == "tab":
# Переключаем фокус между поиском и списком чатов
if self.focused_element == "search":
self.focused_element = "chat_list"
self.search_input.blur()
# Фокусируемся на первом чате
first_chat = self.chat_container.query_one("#chat-1")
if first_chat:
first_chat.focus()
elif self.focused_element == "chat_list":
self.focused_element = "search"
self.search_input.focus()
return
if event.key == "/":
# Фокус на поиск
self.focused_element = "search"
self.search_input.focus()
elif event.key == "[":
# Переход в предыдущую папку
if self.current_folder is not None:
self.current_folder = None
self.selected_chat_index = 0
self.update_chat_list()
elif event.key == "]":
# Переход в следующую папку
if self.current_folder is None and self.folders:
self.current_folder = 1 # Архив
self.selected_chat_index = 0
self.update_chat_list()
def compose(self):
yield Footer()
with Horizontal(id="main_container"):
with Vertical(id="chats"):
yield Label(
"Навигация: Tab - переключение фокуса, ↑↓ - выбор чата, Enter - открыть чат, Esc - назад, / - поиск, [] - папки",
id="help_label",
classes="help-text"
)
yield Input(placeholder=normalize_text("Поиск чатов..."), id="search_input")
yield VerticalScroll(id="chat_container")
yield ContentSwitcher(id="dialog_switcher")

View File

@ -1,64 +0,0 @@
#chats {
width: 30%;
}
#dialog {
width: 70%;
}
Chat {
height: 3;
}
Rule {
color: #FFFFFF;
}
Message {
height: auto;
width: auto;
}
Message Container {
height: auto;
}
#input_place {
height: 3;
width: 70%;
align-horizontal: center;
}
#msg_input {
width: 65%;
}
#auth_container{
align: center middle;
}
.is_me_true Container {
padding: 0 0 0 15;
align: right middle;
}
.is_me_true Static {
border: solid $primary;
width: auto;
height: auto;
text-align: right;
min-width: 11;
}
.is_me_false Container {
padding: 0 15 0 0;
align: left middle;
}
.is_me_false Static {
border: solid $foreground;
width: auto;
height: auto;
text-align: left;
min-width: 11;
}

View File

@ -1,465 +0,0 @@
"""Файл с кастомными виджетами приложения"""
from textual.containers import Horizontal, Vertical, Container, VerticalScroll
from textual.widget import Widget
from textual.reactive import Reactive
from textual.widgets import Input, Button, Label, Static, ContentSwitcher
from textual.app import ComposeResult, RenderResult
from textual.keys import Keys
from telethon import TelegramClient, events, utils
import datetime
import unicodedata
import re
import emoji
import os
import tempfile
from PIL import Image
import pywhatkit as kit
from textual import log
def remove_emoji(text: str) -> str:
"""Удаляет эмодзи из текста"""
if not text:
return ""
return emoji.replace_emoji(text, '')
def normalize_text(text: str) -> str:
"""Нормализует текст для корректного отображения"""
if not text:
return ""
try:
# Преобразуем в строку, если это не строка
text = str(text)
# Удаляем эмодзи
text = remove_emoji(text)
# Нормализуем Unicode
text = unicodedata.normalize('NFKC', text)
# Заменяем специальные символы на их ASCII-эквиваленты
text = text.replace('', '-').replace('', '-').replace('', '...')
# Удаляем все управляющие символы, кроме новой строки и табуляции
text = ''.join(char for char in text if unicodedata.category(char)[0] != 'C'
or char in ('\n', '\t'))
# Удаляем множественные пробелы
text = ' '.join(text.split())
return text
except Exception as e:
log(f"Ошибка нормализации текста: {e}")
return "Ошибка отображения"
def safe_ascii(text: str) -> str:
"""Преобразует текст в безопасный ASCII-формат"""
if not text:
return ""
# Удаляем эмодзи
text = remove_emoji(text)
# Оставляем только ASCII символы и пробелы
return ''.join(char for char in text if ord(char) < 128 or char.isspace())
def convert_image_to_ascii(image_path: str, width: int = 50) -> str:
"""Конвертирует изображение в ASCII-арт"""
try:
# Открываем изображение
img = Image.open(image_path)
# Конвертируем в RGB если нужно
if img.mode != 'RGB':
img = img.convert('RGB')
# Изменяем размер, сохраняя пропорции
aspect_ratio = img.height / img.width
height = int(width * aspect_ratio * 0.5) # * 0.5 потому что символы выше чем шире
img = img.resize((width, height))
# Конвертируем в ASCII
ascii_str = kit.image_to_ascii_art(image_path, output_file=None)
# Очищаем временный файл
os.remove(image_path)
return ascii_str
except Exception as e:
log(f"Ошибка конвертации изображения: {e}")
return "Ошибка загрузки изображения"
class Chat(Static):
"""Класс виджета чата для панели чатов"""
DEFAULT_CSS = """
Chat {
width: 100%;
height: auto;
min-height: 3;
padding: 1 2;
border: solid $accent;
margin: 1 0;
background: $surface;
}
Chat:hover {
background: $accent 20%;
}
Chat.-selected {
background: $accent 30%;
}
Chat:focus {
background: $accent 40%;
border: double $accent;
}
.chat-avatar {
width: 3;
height: 3;
content-align: center middle;
border: solid $accent;
margin-right: 1;
background: $boost;
}
.chat-content {
width: 100%;
height: auto;
}
.chat-name {
width: 100%;
color: $text;
text-style: bold;
}
.chat-message {
width: 100%;
color: $text-muted;
}
"""
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.can_focus = True
self._username = ""
self._msg = ""
self._peer_id = 0
self._is_selected = False
self._folder = 0
def on_mount(self) -> None:
self.switcher = self.screen.query_one("#dialog_switcher", ContentSwitcher)
@property
def username(self) -> str:
return self._username
@username.setter
def username(self, value: str) -> None:
self._username = value
self.refresh()
@property
def msg(self) -> str:
return self._msg
@msg.setter
def msg(self, value: str) -> None:
self._msg = value
self.refresh()
@property
def peer_id(self) -> int:
return self._peer_id
@peer_id.setter
def peer_id(self, value: int) -> None:
self._peer_id = value
@property
def is_selected(self) -> bool:
return self._is_selected
@is_selected.setter
def is_selected(self, value: bool) -> None:
self._is_selected = value
self.set_class(value, "-selected")
@property
def folder(self) -> int:
return self._folder
@folder.setter
def folder(self, value: int) -> None:
self._folder = value
self.refresh()
def on_focus(self) -> None:
# Снимаем выделение со всех чатов
for chat in self.screen.query(Chat):
chat.is_selected = False
# Выделяем текущий чат
self.is_selected = True
# Прокручиваем к этому чату
self.screen.chat_container.scroll_to(self, animate=False)
def on_click(self) -> None:
self.focus()
dialog_id = f"dialog-{str(self.peer_id)}"
try:
self.switcher.mount(Dialog(
telegram_client=self.app.telegram_client,
chat_id=self.peer_id,
id=dialog_id
))
except Exception as e:
log(f"Ошибка открытия диалога: {e}")
return
self.switcher.current = dialog_id
def on_key(self, event: Keys) -> None:
if event.key == "enter":
self.on_click()
elif event.key == "up" and self.id != "chat-1":
# Фокусируемся на предыдущем чате
prev_chat = self.screen.chat_container.query_one(f"#chat-{int(self.id.split('-')[1]) - 1}")
if prev_chat:
prev_chat.focus()
elif event.key == "down":
# Фокусируемся на следующем чате
next_chat = self.screen.chat_container.query_one(f"#chat-{int(self.id.split('-')[1]) + 1}")
if next_chat:
next_chat.focus()
def compose(self) -> ComposeResult:
"""Компонуем виджет чата"""
try:
# Подготавливаем данные
name = normalize_text(self._username)
if not name:
name = "Без названия"
msg = normalize_text(self._msg)
if not msg:
msg = "Нет сообщений"
elif len(msg) > 50:
msg = msg[:47] + "..."
# Добавляем метку папки если нужно
if self._folder == 1:
name += " [Архив]"
# Получаем первую букву для аватара (используем только видимые символы)
first_letter = next((c for c in name if c.isprintable()), "?")
# Создаем виджеты
with Horizontal():
yield Static(first_letter, classes="chat-avatar")
with Vertical(classes="chat-content"):
yield Static(name, classes="chat-name")
yield Static(msg, classes="chat-message")
except Exception as e:
log(f"Ошибка отображения чата: {e}")
# Показываем запасной вариант в случае ошибки
with Horizontal():
yield Static("?", classes="chat-avatar")
with Vertical(classes="chat-content"):
yield Static("Ошибка отображения", classes="chat-name")
yield Static("Попробуйте обновить список", classes="chat-message")
class Dialog(Widget):
"""Класс окна диалога"""
def __init__(
self,
id=None,
classes=None,
disabled=None,
telegram_client: TelegramClient | None = None,
chat_id = None
) -> None:
super().__init__(id=id, classes=classes, disabled=disabled)
self.telegram_client = telegram_client
self.chat_id = chat_id
self.is_msg_update_blocked = False
self.messages_loaded = 0
self.is_loading = False
async def on_mount(self) -> None:
self.limit = 50 # Увеличиваем начальное количество сообщений
self.messages_loaded = self.limit
self.msg_input = self.query_one("#msg_input")
self.dialog = self.query_one(Vertical).query_one("#dialog")
self.load_more_btn = self.query_one("#load_more")
self.me = await self.telegram_client.get_me()
self.dialog.scroll_end(animate=False)
await self.update_dialog()
for event in (
events.NewMessage,
events.MessageDeleted,
events.MessageEdited
):
self.telegram_client.on(
event(chats=(self.chat_id))
)(self.update_dialog)
def mount_messages(self, limit: int) -> None:
print("Загрузка виджетов сообщений...")
msg_amount = len(self.dialog.query(Message))
if limit > msg_amount:
for i in range(limit - msg_amount):
self.dialog.mount(
Message(id=f"msg-{i + msg_amount + 1}"),
before=0
)
elif limit < msg_amount:
for i in range(msg_amount - limit):
self.dialog.query(Message).last().remove()
async def update_dialog(self, event = None) -> None:
log("Запрос обновления сообщений")
if not self.is_msg_update_blocked:
self.is_msg_update_blocked = True
messages = await self.telegram_client.get_messages(
entity=self.chat_id, limit=self.limit
)
log("Получены сообщения")
limit = len(messages)
self.mount_messages(limit)
for i in range(limit):
msg = self.dialog.query_one(f"#msg-{i + 1}")
msg.message = ""
# Обрабатываем изображения
if messages[i].media:
try:
# Скачиваем изображение
image_data = await self.telegram_client.download_media(
messages[i].media,
bytes
)
if image_data:
await msg.set_image(image_data)
except Exception as e:
log(f"Ошибка загрузки изображения: {e}")
# Обрабатываем текст
if str(messages[i].message):
msg.message = normalize_text(str(messages[i].message))
try:
is_me = messages[i].from_id.user_id == self.me.id
except:
is_me = False
msg.is_me = is_me
msg.username = normalize_text(utils.get_display_name(messages[i].sender))
msg.send_time = messages[i]\
.date\
.astimezone(datetime.timezone.utc)\
.strftime("%H:%M")
self.is_msg_update_blocked = False
log("Сообщения обновлены")
else:
log("Обновление сообщений невозможно: уже выполняется")
async def load_more_messages(self) -> None:
if not self.is_loading:
self.is_loading = True
self.load_more_btn.disabled = True
self.load_more_btn.label = "Загрузка..."
try:
messages = await self.telegram_client.get_messages(
entity=self.chat_id,
limit=self.limit,
offset_id=self.messages_loaded
)
if messages:
self.messages_loaded += len(messages)
self.mount_messages(self.messages_loaded)
await self.update_dialog()
finally:
self.is_loading = False
self.load_more_btn.disabled = False
self.load_more_btn.label = "Загрузить еще"
def compose(self) -> ComposeResult:
with Vertical():
yield Button("Загрузить еще", id="load_more", variant="default")
yield VerticalScroll(id="dialog")
with Horizontal(id="input_place"):
yield Input(placeholder="Сообщение", id="msg_input")
yield Button(label=">", id="send", variant="primary")
async def on_button_pressed(self, event) -> None:
if event.button.id == "load_more":
await self.load_more_messages()
else:
await self.send_message()
async def on_input_submitted(self, event = None) -> None:
await self.send_message()
async def send_message(self) -> None:
try:
await self.telegram_client.send_message(
self.chat_id,
normalize_text(str(self.msg_input.value))
)
except ValueError:
self.app.notify("Ошибка отправки")
self.msg_input.value = ""
await self.update_dialog()
class Message(Widget):
"""Класс виджета сообщений для окна диалога"""
message: Reactive[str] = Reactive("", recompose=True)
is_me: Reactive[bool] = Reactive(False, recompose=True)
username: Reactive[str] = Reactive("", recompose=True)
send_time: Reactive[str] = Reactive("", recompose=True)
def __init__(
self,
name=None,
id=None,
classes=None,
disabled=False
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
def on_mount(self) -> None:
pass
def compose(self) -> ComposeResult:
static = Static(normalize_text(self.message))
static.border_title = normalize_text(self.username) * (not self.is_me)
static.border_subtitle = self.send_time
with Container():
yield static
if self.is_me:
self.classes = "is_me_true"
else:
self.classes = "is_me_false"

View File

@ -1,152 +0,0 @@
/* Основные стили */
Screen {
background: $surface;
color: $text;
}
/* Стили для подсказки */
.help-text {
color: $text-muted;
text-align: center;
padding: 1;
background: $surface;
border: solid $accent;
margin: 1;
}
/* Стили для чатов */
Chat {
width: 100%;
height: auto;
min-height: 3;
padding: 1 2;
border: solid $accent;
margin: 1 0;
background: $surface;
}
Chat:hover {
background: $accent 20%;
}
Chat.-selected {
background: $accent 30%;
border: solid $accent;
}
Chat:focus {
background: $accent 40%;
border: double $accent;
}
.chat-avatar {
width: 3;
height: 3;
content-align: center middle;
border: solid $accent;
margin-right: 1;
background: $boost;
}
.chat-content {
width: 100%;
height: auto;
}
.chat-name {
width: 100%;
color: $text;
text-style: bold;
}
.chat-message {
width: 100%;
color: $text-muted;
}
/* Стили для диалога */
#dialog {
height: 100%;
border: solid $accent;
background: $surface;
padding: 1;
}
#input_place {
height: auto;
padding: 1;
background: $surface;
border: solid $accent;
}
/* Стили для сообщений */
.message {
margin: 1 0;
padding: 1;
border: solid $accent;
}
.message.is_me_true {
background: $accent 20%;
margin-left: 20%;
}
.message.is_me_false {
background: $surface;
margin-right: 20%;
}
/* Стили для ASCII-арта */
.ascii-art {
font-family: monospace;
white-space: pre;
margin: 1 0;
padding: 1;
background: $surface;
border: solid $accent;
}
/* Стили для поиска */
#search_input {
margin: 1;
border: solid $accent;
}
/* Стили для кнопок */
Button {
margin: 1;
min-width: 10;
}
Button#load_more {
width: 100%;
margin: 0;
border: none;
background: $accent 20%;
}
Button#load_more:hover {
background: $accent 30%;
}
Button#load_more:disabled {
background: $accent 10%;
color: $text-muted;
}
/* Стили для контейнеров */
#chats {
width: 30%;
border-right: solid $accent;
}
#dialog_switcher {
width: 70%;
}
#chat_container {
height: 100%;
border: solid $accent;
background: $surface;
padding: 1;
}

View File

@ -107,7 +107,9 @@ class ChatWidget(urwid.WidgetWrap):
return True return True
def keypress(self, size, key): def keypress(self, size, key):
return key if key in ('up', 'down'):
return key
return super().keypress(size, key)
class MessageWidget(urwid.WidgetWrap): class MessageWidget(urwid.WidgetWrap):
"""Виджет сообщения""" """Виджет сообщения"""
@ -276,10 +278,13 @@ class TelegramTUI:
async def update_chat_list(self): async def update_chat_list(self):
"""Обновляет список чатов""" """Обновляет список чатов"""
try: try:
# Получаем папки
if not self.folders:
self.folders = await self.telegram_client.get_dialogs_folders()
# Получаем диалоги # Получаем диалоги
dialogs = await self.telegram_client.get_dialogs( dialogs = await self.telegram_client.get_dialogs(
limit=100, limit=100,
archived=False,
folder=self.current_folder folder=self.current_folder
) )
@ -305,9 +310,20 @@ class TelegramTUI:
) )
self.chat_list.body.append(chat) self.chat_list.body.append(chat)
# Обновляем фокус
if self.chat_list.body:
self.chat_list.set_focus(self.selected_chat_index)
self.update_selected_chat()
except Exception as e: except Exception as e:
print(f"Ошибка обновления чатов: {e}") print(f"Ошибка обновления чатов: {e}")
def update_selected_chat(self):
"""Обновляет выделение выбранного чата"""
for i, chat in enumerate(self.chat_list.body):
chat.is_selected = (i == self.selected_chat_index)
chat.update_widget()
async def update_message_list(self, chat_id): async def update_message_list(self, chat_id):
"""Обновляет список сообщений""" """Обновляет список сообщений"""
try: try:
@ -377,6 +393,20 @@ class TelegramTUI:
self.selected_chat_index = 0 self.selected_chat_index = 0
await self.update_chat_list() await self.update_chat_list()
elif key == 'up' and self.focused_element == "chat_list":
# Выбор предыдущего чата
if self.chat_list.body:
self.selected_chat_index = max(0, self.selected_chat_index - 1)
self.chat_list.set_focus(self.selected_chat_index)
self.update_selected_chat()
elif key == 'down' and self.focused_element == "chat_list":
# Выбор следующего чата
if self.chat_list.body:
self.selected_chat_index = min(len(self.chat_list.body) - 1, self.selected_chat_index + 1)
self.chat_list.set_focus(self.selected_chat_index)
self.update_selected_chat()
elif key == 'enter' and self.focused_element == "chat_list": elif key == 'enter' and self.focused_element == "chat_list":
# Открываем выбранный чат # Открываем выбранный чат
focused = self.chat_list.get_focus()[0] focused = self.chat_list.get_focus()[0]