diff --git a/.env.example b/.env.example index 5580919..fa8f97d 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ # Их использование крайне нежелательно! # Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже. # Спасибо за понимание! - API_ID=2040 API_HASH=b18441a1ff607e10a989891a5462e627 + +# Настройки приложения +APP_NAME=Telegram TUI +APP_VERSION=1.0 +DEVICE_MODEL=Desktop diff --git a/.gitignore b/.gitignore index a7a669e..6220c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ -test.py -*.session -*.session-journal -__pycache__ -*/__pycache__ -tokens.py -.env -venv* +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python venv/ +# Telegram session files +*.session +*.session-journal + +# Environment +.env + +# IDE +.vscode/ +.idea/ + diff --git a/README.md b/README.md index 5f26c32..3196599 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ -# Тальк +# Telegram TUI Client -Тальк — клиент Telegram с текстовым пользовательским интерфейсом, написанный на Python, Telethon и Textual. - -## Требования - -- Python 3.12 -- pyenv (рекомендуется для управления версиями Python) +Консольный клиент Telegram на базе urwid с поддержкой: +- Просмотра чатов и сообщений +- Поиска по чатам +- Навигации с помощью клавиатуры +- Поддержки папок (Архив) +- Корректного отображения эмодзи и Unicode ## Установка -1. Установите Python 3.12 с помощью pyenv: +1. Клонируйте репозиторий: ```bash -pyenv install 3.12 -pyenv local 3.12 +git clone https://github.com/yourusername/talc.git +cd talc ``` -2. Создайте и активируйте виртуальное окружение: +2. Создайте виртуальное окружение и активируйте его: ```bash -python -m venv .venv -source .venv/bin/activate # для Linux/macOS +python -m venv venv +source venv/bin/activate # Linux/macOS # или -.venv\Scripts\activate # для Windows +venv\Scripts\activate # Windows ``` 3. Установите зависимости: @@ -28,15 +28,42 @@ source .venv/bin/activate # для Linux/macOS pip install -r requirements.txt ``` -4. Настройте переменные окружения: +4. Скопируйте `.env.example` в `.env`: ```bash cp .env.example .env -# Отредактируйте .env файл, добавив свои API ключи -# Получите ключи на https://my.telegram.org/apps ``` +5. Получите API ключи на https://my.telegram.org/apps и добавьте их в `.env` + ## Запуск ```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 diff --git a/main.py b/main.py deleted file mode 100755 index 6d72a2c..0000000 --- a/main.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/python -"""Файл инициализации приложения""" - -from src.app import TelegramTUI - -if __name__ == "__main__": - tg = TelegramTUI() - tg.run() diff --git a/requirements.txt b/requirements.txt index a0aec9c..6a48d7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -textual -telethon -python-dotenv -emoji -Pillow -pywhatkit -urwid +urwid>=2.1.2 +telethon>=1.34.0 +python-dotenv>=1.0.0 +emoji>=2.10.1 +nest_asyncio>=1.6.0 \ No newline at end of file diff --git a/requirements_urwid.txt b/requirements_urwid.txt deleted file mode 100644 index 6a48d7c..0000000 --- a/requirements_urwid.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/app.py b/src/app.py deleted file mode 100644 index ff8259a..0000000 --- a/src/app.py +++ /dev/null @@ -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() diff --git a/src/screens.py b/src/screens.py deleted file mode 100644 index bfdb14f..0000000 --- a/src/screens.py +++ /dev/null @@ -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") diff --git a/src/style.tcss b/src/style.tcss deleted file mode 100644 index dba5823..0000000 --- a/src/style.tcss +++ /dev/null @@ -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; -} diff --git a/src/widgets.py b/src/widgets.py deleted file mode 100644 index 054c1bb..0000000 --- a/src/widgets.py +++ /dev/null @@ -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" diff --git a/style.tcss b/style.tcss deleted file mode 100644 index 2daac7a..0000000 --- a/style.tcss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/urwid_client/telegram_tui.py b/urwid_client/telegram_tui.py index 8f87335..3a9e976 100644 --- a/urwid_client/telegram_tui.py +++ b/urwid_client/telegram_tui.py @@ -107,7 +107,9 @@ class ChatWidget(urwid.WidgetWrap): return True def keypress(self, size, key): - return key + if key in ('up', 'down'): + return key + return super().keypress(size, key) class MessageWidget(urwid.WidgetWrap): """Виджет сообщения""" @@ -276,10 +278,13 @@ class TelegramTUI: async def update_chat_list(self): """Обновляет список чатов""" try: + # Получаем папки + if not self.folders: + self.folders = await self.telegram_client.get_dialogs_folders() + # Получаем диалоги dialogs = await self.telegram_client.get_dialogs( limit=100, - archived=False, folder=self.current_folder ) @@ -305,9 +310,20 @@ class TelegramTUI: ) 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: 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): """Обновляет список сообщений""" try: @@ -377,6 +393,20 @@ class TelegramTUI: self.selected_chat_index = 0 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": # Открываем выбранный чат focused = self.chat_list.get_focus()[0]