#include "app.h" #include #include #include #include #include "app_ui.h" #include "build_config.h" #include "util.h" namespace telegram_tui { namespace { std::string truncate_to_width(std::string text, int max_width) { if (max_width <= 0) { return {}; } if (static_cast(text.size()) <= max_width) { return text; } if (max_width <= 3) { return text.substr(0, static_cast(max_width)); } text.resize(static_cast(max_width - 3)); text += "..."; return text; } std::vector split_preview_lines(const std::string &text) { std::vector lines; std::stringstream stream(text); std::string line; while (std::getline(stream, line)) { if (!line.empty() && line.back() == '\r') { line.pop_back(); } lines.push_back(line); } if (lines.empty()) { lines.push_back(text); } return lines; } } // namespace void App::init_curses() { std::setlocale(LC_ALL, ""); initscr(); init_colors(); cbreak(); noecho(); keypad(stdscr, TRUE); #ifdef NCURSES_VERSION set_escdelay(25); #endif timeout(kPollTimeoutMs); curs_set(0); } void App::init_colors() { if (!has_colors()) { return; } start_color(); use_default_colors(); init_pair(kColorPairSenderBlue, COLOR_BLUE, -1); init_pair(kColorPairSenderCyan, COLOR_CYAN, -1); init_pair(kColorPairSenderGreen, COLOR_GREEN, -1); init_pair(kColorPairSenderYellow, COLOR_YELLOW, -1); init_pair(kColorPairSenderMagenta, COLOR_MAGENTA, -1); init_pair(kColorPairSenderRed, COLOR_RED, -1); init_pair(kColorPairLink, COLOR_CYAN, -1); init_pair(kColorPairMarkdown, COLOR_MAGENTA, -1); init_pair(kColorPairTimestamp, COLOR_YELLOW, -1); init_pair(kColorPairOpenChat, COLOR_GREEN, -1); } void App::shutdown_curses() { endwin(); } void App::draw() { erase(); int height = 0; int width = 0; getmaxyx(stdscr, height, width); if (height < 8 || width < 40) { mvprintw(0, 0, "Terminal too small."); refresh(); return; } const int header_y = 0; const int content_top = 1; const int footer_y = height - 2; const int input_y = height - 1; const int content_height = footer_y - content_top; const int chat_width = std::max(24, width / 3); const int message_width = width - chat_width - 1; attron(A_REVERSE); mvhline(header_y, 0, ' ', width); std::string header_label = std::string("shinoa ") + TELEGRAM_TUI_PROJECT_VERSION; if (use_test_dc_) { header_label += " [TEST DC]"; } mvprintw(header_y, 1, "%s", header_label.c_str()); const std::string auth_label = authorized_ ? "ready" : current_auth_label(); const int auth_x = std::max(1, width - static_cast(auth_label.size()) - 2); if (!update_notice_.empty()) { const std::string centered_notice = truncate_to_width(update_notice_, std::max(1, width - 4)); const int notice_x = std::max(1, (width - static_cast(centered_notice.size())) / 2); if (notice_x > 1 + static_cast(header_label.size()) && notice_x + static_cast(centered_notice.size()) < auth_x - 1) { attron(A_BOLD); mvprintw(header_y, notice_x, "%s", centered_notice.c_str()); attroff(A_BOLD); } } mvprintw(header_y, auth_x, "%s", auth_label.c_str()); attroff(A_REVERSE); mvvline(content_top, chat_width, ACS_VLINE, content_height); draw_chat_pane(content_top, content_height, chat_width); draw_message_pane(content_top, content_height, chat_width + 1, message_width); attron(A_REVERSE); mvhline(footer_y, 0, ' ', width); const std::string footer_status = use_test_dc_ ? "[TEST DC] " + status_line_ : status_line_; mvprintw(footer_y, 1, "%s", footer_status.c_str()); const std::string footer_hint = "? for help"; const int footer_hint_x = std::max(1, width - static_cast(footer_hint.size()) - 2); if (footer_hint_x > 1 + static_cast(footer_status.size())) { attron(A_DIM); mvprintw(footer_y, footer_hint_x, "%s", footer_hint.c_str()); attroff(A_DIM); } attroff(A_REVERSE); const std::string help = input_mode_ == InputMode::None ? "? for help" : input_prompt_ + ": " + (input_hidden_ ? std::string(input_buffer_.size(), '*') : input_buffer_); mvhline(input_y, 0, ' ', width); mvprintw(input_y, 0, "%s", help.c_str()); if (input_mode_ != InputMode::None) { const std::string prefix = input_prompt_ + ": "; const int cursor_x = std::min( width - 1, utf8_display_width(prefix) + utf8_display_width(input_buffer_, input_cursor_)); move(input_y, std::max(0, cursor_x)); } refresh(); if (attachment_viewer_open_) { draw_attachment_viewer(height, width); } else if (attachment_action_menu_open_) { draw_attachment_action_menu(height, width); } else if (saved_animation_menu_open_) { clear_attachment_preview_graphics(); draw_saved_animation_menu(height, width); } else if (attachments_menu_open_) { draw_attachments_menu(height, width); } else if (forward_target_menu_open_) { draw_forward_target_menu(height, width); } else if (help_menu_open_) { draw_help_menu(height, width); } else { clear_attachment_preview_graphics(); } } void App::draw_forward_target_menu(int height, int width) { const std::vector target_chat_ids = forward_target_chat_ids(); if (target_chat_ids.empty()) { forward_target_menu_open_ = false; status_line_ = "No non-channel chats available to forward to."; return; } const int menu_width = std::min(width - 4, 72); const int menu_height = std::min(height - 4, 20); const int top = std::max(1, (height - menu_height) / 2); const int left = std::max(1, (width - menu_width) / 2); WINDOW *window = newwin(menu_height, menu_width, top, left); if (window == nullptr) { return; } if (forward_target_index_ < 0) { forward_target_index_ = 0; } if (forward_target_index_ >= static_cast(target_chat_ids.size())) { forward_target_index_ = static_cast(target_chat_ids.size()) - 1; } box(window, 0, 0); mvwprintw(window, 0, 2, " Forward To "); std::string source_label = forward_message_ids_.size() == 1 ? "1 message" : (std::to_string(forward_message_ids_.size()) + " messages"); const auto source_chat_it = chats_.find(forward_source_chat_id_); if (source_chat_it != chats_.end()) { if (forward_message_ids_.size() == 1) { const auto message_index = find_message_index(source_chat_it->second, forward_message_ids_.front()); if (message_index.has_value()) { source_label = "Message [" + std::to_string(*message_index + 1) + "]"; } } if (!source_chat_it->second.title.empty()) { source_label += " from " + source_chat_it->second.title; } } mvwaddnstr(window, 1, 2, truncate_to_width(source_label, menu_width - 4).c_str(), menu_width - 4); mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); const int list_top = 3; const int list_height = std::max(1, menu_height - 5); int first_index = 0; if (forward_target_index_ >= list_height) { first_index = forward_target_index_ - list_height + 1; } for (int row = 0; row < list_height; ++row) { const int item_index = first_index + row; const int y = list_top + row; mvwhline(window, y, 1, ' ', menu_width - 2); if (item_index >= static_cast(target_chat_ids.size())) { continue; } const std::int64_t chat_id = target_chat_ids[static_cast(item_index)]; const auto chat_it = chats_.find(chat_id); std::string label = chat_it != chats_.end() ? chat_it->second.title : ("Chat " + std::to_string(chat_id)); if (chat_id == forward_source_chat_id_) { label += " (current)"; } if (item_index == forward_target_index_) { wattron(window, A_REVERSE | A_BOLD); } mvwaddnstr(window, y, 2, truncate_to_width(label, menu_width - 4).c_str(), menu_width - 4); if (item_index == forward_target_index_) { wattroff(window, A_REVERSE | A_BOLD); } } mvwaddnstr(window, menu_height - 1, 2, "Enter forward Esc cancel Up/Down move", menu_width - 4); wrefresh(window); delwin(window); } void App::draw_saved_animation_menu(int height, int width) { const int menu_width = std::min(width - 4, 110); const int menu_height = std::min(height - 4, 28); const int top = std::max(1, (height - menu_height) / 2); const int left = std::max(1, (width - menu_width) / 2); WINDOW *window = newwin(menu_height, menu_width, top, left); if (window == nullptr) { return; } if (saved_animation_selection_index_ < 0) { saved_animation_selection_index_ = 0; } if (saved_animation_selection_index_ >= static_cast(saved_animations_.size())) { saved_animation_selection_index_ = std::max(0, static_cast(saved_animations_.size()) - 1); } box(window, 0, 0); mvwprintw(window, 0, 2, " Saved GIFs "); std::string subtitle = saved_animations_loading_ ? "Loading..." : std::to_string(saved_animations_.size()) + " saved"; mvwaddnstr(window, 1, 2, subtitle.c_str(), menu_width - 4); mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); const int list_width = std::max(28, menu_width / 2 - 1); const int preview_left = list_width + 2; const int preview_width = std::max(10, menu_width - preview_left - 2); const int list_top = 3; const int list_height = std::max(1, menu_height - 6); int first_index = 0; if (saved_animation_selection_index_ >= list_height) { first_index = saved_animation_selection_index_ - list_height + 1; } for (int row = 0; row < list_height; ++row) { const int item_index = first_index + row; const int y = list_top + row; mvwhline(window, y, 1, ' ', list_width); if (item_index >= static_cast(saved_animations_.size())) { continue; } const SavedAnimationInfo &animation = saved_animations_[static_cast(item_index)]; std::string label = animation.name; if (animation.is_downloading_active && !animation.is_downloaded) { label += " [dl]"; } else if (animation.is_downloaded) { label += " [ready]"; } if (item_index == saved_animation_selection_index_) { wattron(window, A_REVERSE | A_BOLD); } mvwaddnstr(window, y, 2, truncate_to_width(label, list_width - 2).c_str(), list_width - 2); if (item_index == saved_animation_selection_index_) { wattroff(window, A_REVERSE | A_BOLD); } } mvwvline(window, 3, preview_left - 1, ACS_VLINE, menu_height - 4); if (saved_animations_.empty()) { mvwaddnstr(window, 4, preview_left, "No saved GIFs on this account.", preview_width); } else { const SavedAnimationInfo &animation = saved_animations_[static_cast( saved_animation_selection_index_)]; const std::string title = truncate_to_width(animation.name, preview_width); mvwaddnstr(window, 3, preview_left, title.c_str(), preview_width); const std::string meta = truncate_to_width(format_file_size(animation.size_bytes) + " " + std::to_string(std::max(0, animation.width)) + "x" + std::to_string(std::max(0, animation.height)) + " " + std::to_string(std::max(0, animation.duration)) + "s", preview_width); mvwaddnstr(window, 4, preview_left, meta.c_str(), preview_width); mvwhline(window, 5, preview_left, ACS_HLINE, preview_width); AttachmentInfo preview_attachment; preview_attachment.type = AttachmentType::Animation; preview_attachment.name = animation.name; preview_attachment.size_bytes = animation.size_bytes; preview_attachment.downloaded_size = animation.downloaded_size; preview_attachment.file_id = animation.file_id; preview_attachment.local_path = animation.local_path; preview_attachment.is_downloading_active = animation.is_downloading_active; preview_attachment.can_be_downloaded = animation.can_be_downloaded; preview_attachment.can_be_deleted = animation.can_be_deleted; preview_attachment.is_downloaded = animation.is_downloaded; const std::string preview = render_attachment_preview( preview_attachment, preview_width, std::max(4, list_height - 4)); const std::vector preview_lines = split_preview_lines(preview); for (std::size_t i = 0; i < preview_lines.size() && static_cast(i) < list_height - 3; ++i) { mvwaddnstr(window, 6 + static_cast(i), preview_left, truncate_to_width(preview_lines[i], preview_width).c_str(), preview_width); } } mvwaddnstr(window, menu_height - 2, 2, "Enter send r refresh Up/Down move Esc close", menu_width - 4); wrefresh(window); delwin(window); } std::string App::current_auth_label() const { const std::string type = safe_string(authorization_state_, "@type"); if (type == "authorizationStateWaitTdlibParameters") { return "need API"; } if (type == "authorizationStateWaitPhoneNumber") { return "need phone"; } if (type == "authorizationStateWaitCode") { return "need code"; } if (type == "authorizationStateWaitEncryptionKey") { return "unlock db"; } if (type == "authorizationStateWaitPassword") { return "need password"; } if (type == "authorizationStateReady") { return "ready"; } if (type.empty()) { return "starting"; } return type; } } // namespace telegram_tui