package ui import ( "fmt" "io" "log" "math" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" appcore "github.com/veggiedefender/torrent-client/internal/app" "github.com/veggiedefender/torrent-client/internal/config" "github.com/veggiedefender/torrent-client/internal/history" "github.com/veggiedefender/torrent-client/internal/logger" "github.com/veggiedefender/torrent-client/internal/torrent" "github.com/veggiedefender/torrent-client/internal/version" ) // ── Типы сообщений ──────────────────────────────────────────────────────────── type tickMsg time.Time type animTickMsg time.Time type startTorrentResultMsg struct { err error } type filePickerMsg struct { path string } // ── Состояние кусков ────────────────────────────────────────────────────────── type PieceState uint8 const ( PieceMissing PieceState = iota PieceDownloading PieceCompleted ) // ── Раскладка клавиш ────────────────────────────────────────────────────────── type keyMap struct { TabOverview key.Binding TabNetwork key.Binding TabJournal key.Binding TabLogs key.Binding TabAbout key.Binding JournalOpenDir key.Binding JournalRemove key.Binding JournalDeleteAll key.Binding LogsOpen key.Binding LogsDelete key.Binding LogsBack key.Binding NextTab key.Binding PrevTab key.Binding PauseResume key.Binding OpenNew key.Binding Clear key.Binding Quit key.Binding ToggleFile key.Binding FilePicker key.Binding } var keys = keyMap{ TabOverview: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "обзор")), TabNetwork: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "сеть")), TabJournal: key.NewBinding(key.WithKeys("j", "5"), key.WithHelp("J/5", "журнал")), TabLogs: key.NewBinding(key.WithKeys("l", "6"), key.WithHelp("L/6", "логи")), TabAbout: key.NewBinding(key.WithKeys("?", "7"), key.WithHelp("?/7", "о программе")), JournalOpenDir: key.NewBinding(key.WithKeys("o"), key.WithHelp("O", "открыть папку")), JournalRemove: key.NewBinding(key.WithKeys("x"), key.WithHelp("X", "удалить из истории")), JournalDeleteAll: key.NewBinding(key.WithKeys("X"), key.WithHelp("Shift+X", "удалить файлы")), LogsOpen: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "открыть лог")), LogsDelete: key.NewBinding(key.WithKeys("x", "X"), key.WithHelp("X", "удалить лог")), LogsBack: key.NewBinding(key.WithKeys("esc", "b"), key.WithHelp("Esc/B", "назад")), NextTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("Tab", "след. вкладка")), PrevTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("Shift+Tab", "пред. вкладка")), PauseResume: key.NewBinding(key.WithKeys(" ", "s"), key.WithHelp("Пробел/S", "пауза/продолжить")), OpenNew: key.NewBinding(key.WithKeys("o"), key.WithHelp("O", "открыть торрент")), Clear: key.NewBinding(key.WithKeys("c"), key.WithHelp("C", "очистить")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("Q", "выйти")), ToggleFile: key.NewBinding(key.WithKeys("enter"), key.WithHelp("Enter", "вкл/выкл файл")), FilePicker: key.NewBinding(key.WithKeys("f"), key.WithHelp("F", "выбрать файл")), } // ── Кэш вида ────────────────────────────────────────────────────────────────── // View() имеет value receiver, поэтому кэш хранится на уровне пакета. var ( viewCache string viewCacheDirty = true ) // ── Модель ──────────────────────────────────────────────────────────────────── type model struct { controller *appcore.Controller status torrent.Status activeTab int // 0: Обзор, 1: Пиры, 2: Файлы, 3: Трекеры width int height int // Экран запуска (Journal) journalMode bool journalTable table.Model historyItems []history.Item // Главное меню mainMenuMode bool mainMenuCursor int // Настройки settingsMode bool settingsInputs []textinput.Model settingsFocus int // Экран нового торрента newDownloadMode bool inputs []textinput.Model focusedInput int torrentPath string outputDir string validationErr string // Загрузка loadingMetadata bool spinner spinner.Model showAdvanced bool // Таблицы peersTable table.Model filesTable table.Model trackersTable table.Model // Системный лог logScrollY int logAutoScroll bool // Overlays logsMode bool aboutMode bool additionalMode bool // Дополнительное меню (overlay) // Анимация логотипа logoEngine *LogoEngine // Matrix Rain boxMatrix *MatrixEngine // Отключение анимаций disableAnimations bool // Анимации переходов transitioning bool transitionProgress float64 } func (m *model) startTransition() { if m.disableAnimations { m.transitioning = false m.transitionProgress = 1.0 return } m.transitioning = true m.transitionProgress = 0.0 } func initialModel(controller *appcore.Controller) model { torrentInput := textinput.New() torrentInput.Placeholder = "magnet:?xt=... или /путь/к/файлу.torrent" torrentInput.Focus() torrentInput.CharLimit = 2048 torrentInput.Width = 62 outputInput := textinput.New() outputInput.Placeholder = "например: /home/user/Загрузки" outputInput.CharLimit = 512 outputInput.Width = 62 // Настройки cfgDir := "." if home, err := os.UserHomeDir(); err == nil { cfgDir = home } cfgToken := "" cfgDl := "0" cfgUl := "0" cfgAnim := "0" cfg, err := config.Load() if err == nil { if cfg.DefaultDir != "" { cfgDir = cfg.DefaultDir } cfgToken = cfg.TelegramToken cfgDl = fmt.Sprintf("%d", cfg.DownloadLimit) cfgUl = fmt.Sprintf("%d", cfg.UploadLimit) if cfg.DisableAnimations { cfgAnim = "1" } } else if envTok := os.Getenv("TG_BOT_TOKEN"); envTok != "" { cfgToken = envTok } outputInput.SetValue(cfgDir) setDirInp := textinput.New() setDirInp.Placeholder = "Папка по умолчанию" setDirInp.SetValue(cfgDir) setDirInp.Width = 40 setDlInp := textinput.New() setDlInp.Placeholder = "0 = без лимита" setDlInp.SetValue(cfgDl) setDlInp.Width = 40 setUlInp := textinput.New() setUlInp.Placeholder = "0 = без лимита" setUlInp.SetValue(cfgUl) setUlInp.Width = 40 setTgInp := textinput.New() setTgInp.Placeholder = "Токен BotFather" setTgInp.SetValue(cfgToken) setTgInp.Width = 40 setAnimInp := textinput.New() setAnimInp.Placeholder = "1 - отключить, 0 - включить" setAnimInp.SetValue(cfgAnim) setAnimInp.Width = 40 s := spinner.New() s.Spinner = spinner.Points s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)) log.SetOutput(io.Discard) m := model{ controller: controller, journalTable: newJournalTable(), mainMenuMode: true, mainMenuCursor: 0, settingsInputs: []textinput.Model{setDirInp, setDlInp, setUlInp, setTgInp, setAnimInp}, settingsFocus: 0, newDownloadMode: false, inputs: []textinput.Model{torrentInput, outputInput}, focusedInput: 0, spinner: s, peersTable: newPeersTable(), filesTable: newFilesTable(), trackersTable: newTrackersTable(), logoEngine: NewLogoEngine(28, 12, 4.0, 1.0), boxMatrix: NewMatrixEngine(85, 40), } if cfg != nil { m.disableAnimations = cfg.DisableAnimations } m.loadHistory() return m } func (m *model) loadHistory() { items, err := history.Load() if err == nil { m.historyItems = items var rows []table.Row for i := len(items) - 1; i >= 0; i-- { it := items[i] name := it.Name if name == "" { name = it.InfoHash } if name == "" { name = "Unknown" } rows = append(rows, table.Row{ name, phaseRU(it.Status), fmt.Sprintf("%.1f%%", it.Progress), formatBytes(it.Size), it.AddedAt.Format("02 Jan 15:04"), }) } m.journalTable.SetRows(rows) } } // ── Init ────────────────────────────────────────────────────────────────────── func (m model) Init() tea.Cmd { if m.disableAnimations { return tea.Batch(textinput.Blink, m.spinner.Tick) } return tea.Batch(textinput.Blink, m.spinner.Tick, animTickCmd()) } // ── Обновление состояния ────────────────────────────────────────────────────── func tickCmd() tea.Cmd { return tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) } func animTickCmd() tea.Cmd { return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { return animTickMsg(t) }) } // filePickerCmd открывает нативный диалог выбора файла macOS через osascript func filePickerCmd() tea.Cmd { return func() tea.Msg { script := `set theFile to choose file with prompt "Выберите .torrent файл" of type {"torrent", "public.data"} return POSIX path of theFile` out, err := exec.Command("osascript", "-e", script).Output() if err != nil { return filePickerMsg{path: ""} } path := strings.TrimSpace(string(out)) return filePickerMsg{path: path} } } func startTorrentCmd(controller *appcore.Controller, torrentPath, outputDir, tgToken string) tea.Cmd { return func() tea.Msg { if tgToken != "" { _ = controller.InitTelegramBot(tgToken) } err := controller.StartTorrent(torrentPath, outputDir) return startTorrentResultMsg{err: err} } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd // Любое событие, кроме animTickMsg, тоже делает вид грязным switch msg.(type) { case animTickMsg: // обрабатывается ниже, dirty ставится там default: viewCacheDirty = true } switch msg := msg.(type) { case tea.KeyMsg: // Выход — работает всегда if key.Matches(msg, keys.Quit) { m.controller.StopTorrent() return m, tea.Quit } // Глобальные оверлей — работают с любого экрана if m.aboutMode { if msg.String() == "esc" || msg.String() == "q" || msg.String() == "?" || msg.String() == "7" { m.aboutMode = false } return m, nil } if m.logsMode { switch msg.String() { case "esc", "l", "L", "q", "6": m.logsMode = false case "up", "k": m.logAutoScroll = false m.logScrollY-- if m.logScrollY < 0 { m.logScrollY = 0 } case "down", "j": m.logAutoScroll = false m.logScrollY++ case "pgup", "u": m.logAutoScroll = false m.logScrollY -= 20 if m.logScrollY < 0 { m.logScrollY = 0 } case "pgdown", "d": m.logAutoScroll = false m.logScrollY += 20 case "f", "F": m.logAutoScroll = true } return m, nil } // Дополнительное меню if m.additionalMode { switch msg.String() { case "esc", "q", "?": m.additionalMode = false return m, nil case "j", "J": m.additionalMode = false m.journalMode = true m.loadHistory() return m, nil case "l", "L": m.additionalMode = false m.logsMode = true m.logAutoScroll = true return m, nil case "a", "A": m.additionalMode = false m.aboutMode = true return m, nil } return m, nil } if msg.String() == "?" { m.additionalMode = true return m, nil } // ── Журнал ── if m.journalMode { switch msg.String() { case "n", "esc", "q", "j", "J": m.journalMode = false m.mainMenuMode = true return m, nil case "enter": if len(m.historyItems) > 0 { selectedRow := m.journalTable.Cursor() if selectedRow >= 0 && selectedRow < len(m.historyItems) { idx := len(m.historyItems) - 1 - selectedRow it := m.historyItems[idx] m.torrentPath = it.TorrentPath m.outputDir = it.OutputDir m.journalMode = false m.loadingMetadata = true cfg, _ := config.Load() if cfg != nil && cfg.TelegramToken != "" { _ = m.controller.InitTelegramBot(cfg.TelegramToken) } return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir, cfg.TelegramToken)) } } } if cmd, handled := m.handleJournalKeys(msg); handled { return m, cmd } m.journalTable, cmd = m.journalTable.Update(msg) return m, cmd } // ── Главное Меню ── if m.mainMenuMode { switch msg.String() { case "up", "k": m.mainMenuCursor-- if m.mainMenuCursor < 0 { m.mainMenuCursor = 3 } return m, nil case "down", "j", "tab": m.mainMenuCursor++ if m.mainMenuCursor > 3 { m.mainMenuCursor = 0 } return m, nil case "enter": switch m.mainMenuCursor { case 0: // Новая загрузка m.mainMenuMode = false m.newDownloadMode = true m.startTransition() return m, nil case 1: // История m.mainMenuMode = false m.journalMode = true m.loadHistory() m.startTransition() return m, nil case 2: // Настройки m.mainMenuMode = false m.settingsMode = true m.settingsFocus = 0 m.startTransition() return m, textinput.Blink case 3: // Выход return m, tea.Quit } case "q", "esc": return m, tea.Quit } return m, nil } // ── Настройки ── if m.settingsMode { switch msg.String() { case "esc": m.settingsMode = false m.mainMenuMode = true m.startTransition() return m, nil case "tab", "down": m.settingsInputs[m.settingsFocus].Blur() m.settingsFocus = (m.settingsFocus + 1) % len(m.settingsInputs) m.settingsInputs[m.settingsFocus].Focus() return m, nil case "shift+tab", "up": m.settingsInputs[m.settingsFocus].Blur() m.settingsFocus = (m.settingsFocus - 1 + len(m.settingsInputs)) % len(m.settingsInputs) m.settingsInputs[m.settingsFocus].Focus() return m, nil case "enter": // Сохранить cfg, _ := config.Load() if cfg == nil { cfg = &config.Config{} } cfg.DefaultDir = strings.TrimSpace(m.settingsInputs[0].Value()) var dlLimit, ulLimit int fmt.Sscanf(strings.TrimSpace(m.settingsInputs[1].Value()), "%d", &dlLimit) fmt.Sscanf(strings.TrimSpace(m.settingsInputs[2].Value()), "%d", &ulLimit) cfg.DownloadLimit = dlLimit cfg.UploadLimit = ulLimit cfg.TelegramToken = strings.TrimSpace(m.settingsInputs[3].Value()) oldDisable := m.disableAnimations animVal := strings.TrimSpace(m.settingsInputs[4].Value()) cfg.DisableAnimations = (animVal == "1" || animVal == "true" || animVal == "да") _ = config.Save(cfg) m.disableAnimations = cfg.DisableAnimations m.settingsMode = false m.mainMenuMode = true m.startTransition() if oldDisable && !m.disableAnimations { return m, animTickCmd() } return m, nil } m.settingsInputs[m.settingsFocus], cmd = m.settingsInputs[m.settingsFocus].Update(msg) return m, cmd } // ── Экран приветствия (Новая загрузка) ── if m.newDownloadMode { switch msg.String() { case "esc": m.newDownloadMode = false m.mainMenuMode = true m.startTransition() return m, nil case "tab", "down": m.inputs[m.focusedInput].Blur() m.focusedInput = (m.focusedInput + 1) % 2 m.inputs[m.focusedInput].Focus() return m, nil case "shift+tab", "up": m.inputs[m.focusedInput].Blur() m.focusedInput = (m.focusedInput - 1 + 2) % 2 m.inputs[m.focusedInput].Focus() return m, nil case "f": // Открыть Finder для выбора .torrent файла if m.focusedInput == 0 { return m, filePickerCmd() } case "enter": torrentPath := strings.TrimSpace(m.inputs[0].Value()) outputDir := strings.TrimSpace(m.inputs[1].Value()) cfg, _ := config.Load() tgToken := "" if cfg != nil { tgToken = cfg.TelegramToken } if err := validateInputs(torrentPath, outputDir); err != nil { m.validationErr = err.Error() return m, nil } m.torrentPath = torrentPath m.outputDir = outputDir m.validationErr = "" m.newDownloadMode = false m.loadingMetadata = true return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, torrentPath, outputDir, tgToken)) } m.inputs[m.focusedInput], cmd = m.inputs[m.focusedInput].Update(msg) return m, cmd } // ── Экран загрузки ── if m.loadingMetadata { if msg.String() == "esc" { m.controller.StopTorrent() m.loadingMetadata = false m.mainMenuMode = true return m, nil } m.spinner, cmd = m.spinner.Update(msg) return m, cmd } // ── Главный дашборд ── switch { case key.Matches(msg, keys.TabOverview): m.activeTab = 0 case key.Matches(msg, keys.TabNetwork): m.activeTab = 1 case key.Matches(msg, keys.NextTab): m.activeTab = (m.activeTab + 1) % 2 case key.Matches(msg, keys.PrevTab): m.activeTab = (m.activeTab - 1 + 2) % 2 case key.Matches(msg, keys.PauseResume): phase := m.status.Phase if phase == "stopped" || phase == "failed" { m.controller.StartTorrentDownload() return m, nil } else if phase != "" { m.controller.StopTorrent() } case key.Matches(msg, keys.OpenNew): m.controller.StopTorrent() m.mainMenuMode = true m.status = torrent.Status{} return m, nil case key.Matches(msg, keys.Clear): m.controller.StopTorrent() m.mainMenuMode = true m.inputs[0].SetValue("") m.status = torrent.Status{} return m, nil } // Прокрутка в таблицах switch m.activeTab { case 1: m.peersTable, cmd = m.peersTable.Update(msg) m.trackersTable, _ = m.trackersTable.Update(msg) return m, cmd default: if key.Matches(msg, keys.ToggleFile) { m.controller.ToggleFilePriority(m.filesTable.Cursor()) } m.filesTable, cmd = m.filesTable.Update(msg) return m, cmd } case filePickerMsg: if msg.path != "" { m.inputs[0].SetValue(msg.path) m.validationErr = "" } return m, nil case startTorrentResultMsg: if msg.err != nil { m.loadingMetadata = false m.mainMenuMode = true m.validationErr = msg.err.Error() return m, nil } m.status = m.controller.Status() // For magnet links, LoadTorrent returns nil immediately but metadata // is still downloading in the background. Keep loading state and poll. phase := m.status.Phase if phase == "loading_metadata" || phase == "downloading_metadata" || phase == "" { // Still loading metadata (magnet link) — stay in loading screen, start polling return m, tea.Batch(m.spinner.Tick, tickCmd()) } m.loadingMetadata = false m.updateTables() return m, tickCmd() case tickMsg: if m.mainMenuMode || m.newDownloadMode || m.settingsMode { return m, nil } // While loading metadata (esp. for magnet links), poll engine phase if m.loadingMetadata { m.status = m.controller.Status() phase := m.status.Phase if phase == "failed" { m.loadingMetadata = false m.mainMenuMode = true m.validationErr = m.status.LastError if m.validationErr == "" { m.validationErr = "загрузка метаданных не удалась" } return m, nil } if phase != "" && phase != "loading_metadata" && phase != "downloading_metadata" { // Metadata loaded, transition to dashboard m.loadingMetadata = false m.updateTables() return m, tickCmd() } // Still loading — keep polling return m, tea.Batch(m.spinner.Tick, tickCmd()) } m.status = m.controller.Status() m.updateTables() // Refresh data for active tab if m.activeTab == 4 || m.journalMode { m.loadHistory() } return m, tickCmd() case animTickMsg: if m.disableAnimations { return m, nil } if m.transitioning { m.transitionProgress += 0.2 // 80ms per tick -> 5 ticks for 400ms if m.transitionProgress >= 1.0 { m.transitioning = false m.transitionProgress = 1.0 } } if m.logoEngine != nil { m.logoEngine.Update(time.Now()) } if m.boxMatrix != nil { m.boxMatrix.Update() } viewCacheDirty = true return m, animTickCmd() case spinner.TickMsg: m.spinner, cmd = m.spinner.Update(msg) return m, cmd case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.recalculateTableSizes() } return m, nil } func (m *model) handleJournalKeys(msg tea.KeyMsg) (tea.Cmd, bool) { switch { case key.Matches(msg, keys.JournalOpenDir): if len(m.historyItems) > 0 { row := m.journalTable.Cursor() if row >= 0 && row < len(m.historyItems) { idx := len(m.historyItems) - 1 - row it := m.historyItems[idx] exec.Command("open", it.OutputDir).Start() } } return nil, true case key.Matches(msg, keys.JournalRemove): if len(m.historyItems) > 0 { row := m.journalTable.Cursor() if row >= 0 && row < len(m.historyItems) { idx := len(m.historyItems) - 1 - row it := m.historyItems[idx] history.Remove(it.InfoHash, it.TorrentPath) m.loadHistory() } } return nil, true case key.Matches(msg, keys.JournalDeleteAll): if len(m.historyItems) > 0 { row := m.journalTable.Cursor() if row >= 0 && row < len(m.historyItems) { idx := len(m.historyItems) - 1 - row it := m.historyItems[idx] // Delete files if it.Name != "" { os.RemoveAll(filepath.Join(it.OutputDir, it.Name)) } else { // Be very careful deleting just output dir if Name is empty, we only do it if it looks safe? // Actually, if Name is empty, we don't know the exact file name. It's safer to not delete. } history.Remove(it.InfoHash, it.TorrentPath) m.loadHistory() } } return nil, true } return nil, false } func (m *model) recalculateTableSizes() { // Высота под таблицы: убираем верхние 16 строк (шапка, карточки, вкладки, строка помощи) tableHeight := m.height - 17 if tableHeight < 4 { tableHeight = 4 } m.peersTable.SetHeight(tableHeight) m.filesTable.SetHeight(tableHeight) m.trackersTable.SetHeight(tableHeight) m.journalTable.SetHeight(tableHeight) } func (m *model) updateTables() { var peerRows []table.Row for _, p := range m.status.Peers { lat := "-" if p.LatencyMS > 0 { lat = fmt.Sprintf("%d мс", p.LatencyMS) } peerRows = append(peerRows, table.Row{ fmt.Sprintf("%s:%d", p.Address, p.Port), phaseRU(p.State), strconv.Itoa(p.DownloadedPieces), p.Source, formatBytesRate(p.DownloadSpeed), lat, fmt.Sprintf("%.1f", p.Score), strconv.Itoa(p.ErrorCount), p.Error, }) } m.peersTable.SetRows(peerRows) var fileRows []table.Row for _, f := range m.status.Files { prefix := "[ ] " if f.Priority > 0 { prefix = "[x] " } fileRows = append(fileRows, table.Row{ prefix + f.Path, formatBytes(int64(f.Length)), }) } m.filesTable.SetRows(fileRows) var trackerRows []table.Row for _, tr := range m.status.Trackers { trackerRows = append(trackerRows, table.Row{ tr.URL, phaseRU(tr.State), strconv.Itoa(tr.PeerCount), tr.Error, }) } m.trackersTable.SetRows(trackerRows) } // ── Отрисовка ───────────────────────────────────────────────────────────────── func (m model) View() string { // Если dirty флаг не установлен — вернуть закэшированный результат if !viewCacheDirty && viewCache != "" { return viewCache } // Режимы-overlays работают поверх любого экрана if m.additionalMode { return m.viewAdditionalMenu() } if m.aboutMode { return m.viewOverlayAbout() } if m.logsMode { return m.viewOverlayLogs() } if m.width == 0 || m.height == 0 { return "Инициализация..." } var content string if m.journalMode { content = m.viewJournal() } else if m.settingsMode { content = m.viewSettings() } else if m.newDownloadMode { content = m.viewNewDownload() } else if m.mainMenuMode { content = m.viewMainMenu() } else if m.loadingMetadata { content = m.viewLoading() } else { content = m.viewDashboard() } if m.transitioning { // Анимация плавного выезда снизу вверх offset := int((1.0 - m.transitionProgress) * 4.0) content = lipgloss.NewStyle().MarginTop(offset).Render(content) } box := lipgloss.NewStyle(). Width(m.width - 6). Height(m.height - 4). Render(content) result := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) viewCache = result viewCacheDirty = false return result } // ── Overlay: О программе ────────────────────────────────────────────────────── func (m model) viewOverlayAbout() string { w := m.width if w < 80 { w = 80 } cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) pink := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) green := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) yellow := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) row := func(label, value string) string { return dim.Render(fmt.Sprintf(" %-20s", label)) + val.Render(value) } buildDate := version.BuildDate if buildDate == "unknown" { buildDate = "нет данных" } commit := version.GitCommit if commit == "unknown" { commit = "нет данных" } logo := pink.Render(` ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗ ╚══███╔╝╚══██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝ ███╔╝ ██║ ██║ ██║██████╔╝██████╔╝█████╗ ██╔██╗ ██║ ██║ ███╔╝ ██║ ██║ ██║██╔══██╗██╔══██╗██╔══╝ ██║╚████║ ██║ ███████╗ ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ╚███║ ██║ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ╚═╝`) info := lipgloss.JoinVertical(lipgloss.Left, cyan.Render(" ВЕРСИЯ"), "", row("Версия:", yellow.Render(version.Version)), row("Дата сборки:", yellow.Render(buildDate)), row("Git commit:", dim.Render(commit)), "", cyan.Render(" ОПИСАНИЕ"), "", val.Render(" Минималистичный BitTorrent-клиент с TUI интерфейсом."), val.Render(" Поддерживает .torrent файлы и magnet-ссылки (через DHT)."), "", cyan.Render(" ГОРЯЧИЕ КЛАВИШИ"), "", row("Tab / 1-2", "переключение вкладок"), row("Enter", "вкл/выкл скачивание файла"), row("Пробел / S", "пауза / продолжить торрент"), row("O", "открыть новый торрент"), row("J", "журнал загрузок (история)"), row("L", "журнал логов (system logs)"), row("?", "эта страница (дополнительно)"), row("Q / Ctrl+C", "выйти"), "", cyan.Render(" ХРАНИЛИЩЕ"), "", green.Render(" Логи: ~/.ztorrent/logs/"), green.Render(" История: ~/.ztorrent/history.json"), "", dim.Render(" Esc / ? / Q — закрыть"), ) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(1, 2). Width(w - 10). Render(lipgloss.JoinVertical(lipgloss.Left, logo, "", info)) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } // ── Overlay: Журнал логов ───────────────────────────────────────────────────── func (m model) viewOverlayLogs() string { w := m.width if w < 80 { w = 80 } h := m.height cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) entries := logger.GlobalBuffer.Snapshot() viewH := h - 12 if viewH < 1 { viewH = 1 } if m.logAutoScroll { m.logScrollY = len(entries) - viewH } if m.logScrollY < 0 { m.logScrollY = 0 } if m.logScrollY > len(entries)-viewH && len(entries) > viewH { m.logScrollY = len(entries) - viewH } start := m.logScrollY end := start + viewH if end > len(entries) { end = len(entries) } var colored []string for _, entry := range entries[start:end] { timeStr := dim.Render(entry.Time.Format("15:04:05")) var catStyle lipgloss.Style switch entry.Category { case "ENGINE": catStyle = StyleLogEngine case "DHT": catStyle = StyleLogDHT case "PEER": catStyle = StyleLogPeer case "SYS": catStyle = StyleLogSys default: catStyle = StyleLogDefault } var levelStyle lipgloss.Style switch entry.Level { case logger.LevelError: levelStyle = StyleLogError case logger.LevelWarn: levelStyle = StyleLogWarn case logger.LevelInfo: levelStyle = StyleLogInfo case logger.LevelDebug: levelStyle = StyleLogDebug } msgStyle := StyleLogMsg if entry.Level == logger.LevelError { msgStyle = StyleLogMsgError } line := fmt.Sprintf("%s [%-5s] %s %s", timeStr, levelStyle.Render(entry.Level.String()), catStyle.Render(fmt.Sprintf("%-6s", entry.Category)), msgStyle.Render(entry.Message), ) colored = append(colored, line) } scrollInfo := fmt.Sprintf("Строка %d/%d", start+1, len(entries)) if m.logAutoScroll { scrollInfo += " (AutoScroll)" } content := lipgloss.JoinVertical(lipgloss.Left, cyan.Render(" ► Системный лог"), "", strings.Join(colored, "\n"), "", dim.Render(" ↑↓/jk/PgUp/PgDn — прокрутка F — автоскролл Esc/L — закрыть "+scrollInfo), ) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(1, 2). Width(w - 6). Height(h - 4). Render(content) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } // ── Журнал (Отдельный экран) ────────────────────────────────────────────────── func (m model) viewJournal() string { w := m.width if w < 80 { w = 80 } header := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(ColorCyan)). Padding(1, 2). Render("Журнал загрузок") help := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). Padding(1, 2). Render("Enter: продолжить загрузку • N/Esc: новый торрент • Q/Ctrl+C: выход") content := lipgloss.JoinVertical( lipgloss.Left, header, lipgloss.NewStyle().Padding(0, 2).Render(m.journalTable.View()), help, ) return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, content, ) } // ── Экран приветствия ────────────────────────────────────────────────────────── func (m model) renderLogo() string { var zArt string if m.disableAnimations { zArt = cachedStaticZArt } else if m.logoEngine != nil { zArt = m.logoEngine.Render(time.Now()) } return lipgloss.JoinHorizontal(lipgloss.Center, zArt, cachedTorrentArt) } func (m model) viewMainMenu() string { w := m.width if w < 80 { w = 80 } header := m.renderLogo() type menuItem struct { title string desc string } menuItems := []menuItem{ {"Новая загрузка", "Добавить торрент-файл или magnet-ссылку"}, {"История (Журнал)", "Посмотреть ранее загруженные файлы"}, {"Настройки", "Изменить папку по умолчанию и параметры"}, {"Выход", "Завершить работу Ztorrent"}, } var renderedItems []string for i, item := range menuItems { cursor := " " titleStyle := StyleMenuText descStyle := StyleMenuDesc if i == m.mainMenuCursor { cursor = StyleMenuCursor.Render("▶ ") titleStyle = StyleMenuActive } title := cursor + titleStyle.Render(item.title) desc := " " + descStyle.Render(item.desc) renderedItems = append(renderedItems, title, desc) if i < len(menuItems)-1 { renderedItems = append(renderedItems, "") // Пустая строка между пунктами } } itemsBlock := lipgloss.JoinVertical(lipgloss.Left, renderedItems...) contentW := 48 boxWidth := 85 leftW := (boxWidth - contentW) / 2 rightW := boxWidth - contentW - leftW h := lipgloss.Height(itemsBlock) var boxContent string if m.boxMatrix != nil && !m.disableAnimations { topRain := m.boxMatrix.RenderRegion(0, 0, boxWidth, 2) leftRain := m.boxMatrix.RenderRegion(0, 2, leftW, h) rightRain := m.boxMatrix.RenderRegion(boxWidth-rightW, 2, rightW, h) bottomRain := m.boxMatrix.RenderRegion(0, 2+h, boxWidth, 2) paddedItems := lipgloss.NewStyle().Width(contentW).Render(itemsBlock) // Быстрая склейка по строкам (обходим тяжелый regex-парсинг в lipgloss.JoinHorizontal) linesLeft := strings.Split(leftRain, "\n") linesMid := strings.Split(paddedItems, "\n") linesRight := strings.Split(rightRain, "\n") var midBuilder strings.Builder midBuilder.Grow(len(leftRain) + len(paddedItems) + len(rightRain)) for i := 0; i < len(linesMid); i++ { if i < len(linesLeft) { midBuilder.WriteString(linesLeft[i]) } midBuilder.WriteString(linesMid[i]) if i < len(linesRight) { midBuilder.WriteString(linesRight[i]) } if i < len(linesMid)-1 { midBuilder.WriteString("\n") } } middle := midBuilder.String() // Быстрая вертикальная склейка (без lipgloss.JoinVertical) boxContent = topRain + "\n" + middle + "\n" + bottomRain } else { centeredItems := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, itemsBlock) boxContent = lipgloss.NewStyle().Padding(2, 0).Render(centeredItems) } linesContent := strings.Split(boxContent, "\n") var menuBuilder strings.Builder menuBuilder.Grow(len(boxContent) + len(cachedMenuTopBorder) + len(cachedMenuBottomBorder) + 100) menuBuilder.WriteString("\n\n") // MarginTop(2) menuBuilder.WriteString(cachedMenuTopBorder) menuBuilder.WriteString("\n") for i, l := range linesContent { menuBuilder.WriteString(cachedMenuLeftEdge) menuBuilder.WriteString(l) menuBuilder.WriteString(cachedMenuRightEdge) if i < len(linesContent)-1 { menuBuilder.WriteString("\n") } } menuBuilder.WriteString("\n") menuBuilder.WriteString(cachedMenuBottomBorder) menuBox := menuBuilder.String() // Убрали создание нового стиля — используем кэш // help уже вычислен как cachedMenuHelp body := lipgloss.JoinVertical(lipgloss.Center, header, menuBox, cachedMenuHelp, ) return StyleMenuPadding.Render(body) } func (m model) viewSettings() string { w := m.width if w < 80 { w = 80 } header := lipgloss.NewStyle().MarginBottom(2).Render( lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(" ⚙ НАСТРОЙКИ"), ) labels := []string{ " Папка загрузок (по ум.) ", " Лимит скорости (Скач.) ", " Лимит скорости (Разд.) ", " Telegram Bot Token ", " Отключить анимации (1/0)", } var formRows []string for i := range m.settingsInputs { labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Width(26) if i == m.settingsFocus { labelStyle = labelStyle.Foreground(lipgloss.Color(ColorCyan)).Bold(true) } indicator := " " if i == m.settingsFocus { indicator = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("▶ ") } label := indicator + labelStyle.Render(labels[i]) inputView := m.settingsInputs[i].View() inputBoxStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(44) if i == m.settingsFocus { inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorCyan)) } else { inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorBorder)) } formRows = append(formRows, lipgloss.JoinHorizontal(lipgloss.Center, label, inputBoxStyle.Render(inputView))) } formInner := lipgloss.JoinVertical(lipgloss.Left, formRows[0], "", formRows[1], "", formRows[2], "", formRows[3], "", formRows[4], ) boxWidth := 85 centeredFormInner := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, formInner) boxContent := lipgloss.NewStyle().Padding(2, 0).Render(centeredFormInner) formBox := lipgloss.NewStyle(). Border(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color(ColorBorder)). Width(boxWidth). Render(boxContent) help := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). MarginTop(1). Render(fmt.Sprintf( "%s Tab — сменить поле %s Enter — сохранить %s Esc — отмена", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), )) errBlock := "" if m.validationErr != "" { errBlock = "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorRed)). Bold(true). Render("✗ "+m.validationErr) } body := lipgloss.JoinVertical(lipgloss.Left, header, formBox, errBlock, help, ) return lipgloss.NewStyle().Padding(2, 4).Render(body) } func (m model) viewNewDownload() string { w := m.width if w < 80 { w = 80 } header := m.renderLogo() // Форма labels := []string{" Торрент-файл ", " Папка загрузки", " TG Токен "} icons := []string{" ", " ", " "} var formRows []string for i := range m.inputs { labelStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). Width(18) if i == m.focusedInput { labelStyle = labelStyle.Foreground(lipgloss.Color(ColorCyan)).Bold(true) } indicator := " " if i == m.focusedInput { indicator = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("▶ ") } label := indicator + labelStyle.Render(icons[i]+labels[i]) // Кнопка «Обзор» только для поля торрент-файла var browseHint string if i == 0 { browseHint = " " + lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorPink)). Bold(true). Render("[F] Открыть проводник") } inputView := m.inputs[i].View() inputBoxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). Padding(0, 1). Width(66) if i == m.focusedInput { inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorCyan)) } else { inputBoxStyle = inputBoxStyle.BorderForeground(lipgloss.Color(ColorBorder)) } formRows = append(formRows, lipgloss.JoinVertical(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Center, label, browseHint), inputBoxStyle.Render(inputView), )) } formInner := lipgloss.JoinVertical(lipgloss.Left, formRows[0], "", formRows[1], ) boxWidth := 85 centeredFormInner := lipgloss.PlaceHorizontal(boxWidth, lipgloss.Center, formInner) boxContent := lipgloss.NewStyle().Padding(2, 0).Render(centeredFormInner) formBox := lipgloss.NewStyle(). Border(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color(ColorBorder)). MarginTop(1). Width(boxWidth). Render(boxContent) // Кнопка btnStyle := lipgloss.NewStyle(). Background(lipgloss.Color(ColorCyan)). Foreground(lipgloss.Color("#0b1017")). Bold(true). Padding(0, 4). MarginTop(1) btn := btnStyle.Render("▶ НАЧАТЬ СКАЧИВАНИЕ [ Enter ]") // Ошибка errBlock := "" if m.validationErr != "" { errBlock = "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorRed)). Bold(true). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorRed)). Padding(0, 2). Render("✗ "+m.validationErr) } // Подсказки help := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). MarginTop(1). Render(fmt.Sprintf( "%s Tab — сменить поле %s F — выбрать файл %s Enter — начать %s Q — выйти %s ? — дополнительно", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color("#bd93f9")).Render("●"), )) body := lipgloss.JoinVertical(lipgloss.Left, header, formBox, errBlock, btn, help, ) return lipgloss.NewStyle(). Padding(2, 4). Render(body) } // ── Экран загрузки ──────────────────────────────────────────────────────────── func (m model) viewLoading() string { fname := filepath.Base(m.torrentPath) loadingMsg := " Подключаемся к трекерам и загружаем метаданные..." if strings.HasPrefix(m.torrentPath, "magnet:") { fname = "Magnet-ссылка" if m.status.Name != "" { fname = m.status.Name } loadingMsg = " Ищем пиров через DHT и загружаем метаданные..." } spinLine := fmt.Sprintf( " %s %s", m.spinner.View(), loadingMsg, ) box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(2, 4). Render(lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render("⚡ ZTORRENT — Загрузка"), "", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(" "+fname), "", spinLine, "", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Render(" Нажмите Esc для отмены"), )) return lipgloss.NewStyle().Padding(4, 6).Render(box) } // ── Дашборд ─────────────────────────────────────────────────────────────────── func (m model) viewDashboard() string { if m.torrentPath == "" && m.status.Phase == "" { // Bot waiting mode return m.viewBotWaiting() } var parts []string parts = append(parts, m.renderHeader()) parts = append(parts, m.renderKPICards()) parts = append(parts, m.renderTabs()) parts = append(parts, m.renderTabContent()) parts = append(parts, m.renderHelpBar()) return lipgloss.NewStyle().Padding(0, 1).Render( lipgloss.JoinVertical(lipgloss.Left, parts...), ) } func (m model) viewBotWaiting() string { w := m.width - 4 if w < 40 { w = 40 } header := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(ColorCyan)). MarginBottom(1). Render("🤖 TELEGRAM БОТ АКТИВЕН") desc := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorText)). MarginBottom(2). Render("Ztorrent работает в фоновом режиме сервера.\nЖдем magnet-ссылки или .torrent файлы в Telegram!") box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(2, 4). Width(w). Align(lipgloss.Center). Render(lipgloss.JoinVertical(lipgloss.Center, header, desc, m.spinner.View())) help := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). MarginTop(1). Render(fmt.Sprintf("%s L — логи %s Q — выйти", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render("●"), )) return lipgloss.NewStyle(). Padding(4, 2). Render(lipgloss.JoinVertical(lipgloss.Center, box, help)) } func (m model) renderHeader() string { name := m.status.Name if name == "" { name = filepath.Base(m.torrentPath) } if name == "" { name = "—" } phase := phaseRU(m.status.Phase) var icon, phaseColored string switch m.status.Phase { case "stopped": icon = "⏸" phaseColored = StylePhaseStopped.Render(phase) case "failed": icon = "✗" phaseColored = StylePhaseFailed.Render(phase) case "seeding", "done": icon = "✓" phaseColored = StylePhaseSeeding.Render(phase) case "downloading": icon = "⬇" phaseColored = StylePhaseDownload.Render(phase) default: icon = "◌" phaseColored = StylePhaseDefault.Render(phase) } left := lipgloss.JoinHorizontal(lipgloss.Center, StyleHeaderBrand.Render("⚡ ZTORRENT"), StyleHeaderSep.Render(" │ "), StyleHeaderName.Render(name), ) right := lipgloss.JoinHorizontal(lipgloss.Center, StyleHeaderIcon.Render(icon+" "), phaseColored, ) // Заполнить пространство между left и right leftLen := lipgloss.Width(left) rightLen := lipgloss.Width(right) totalW := m.width - 4 padLen := totalW - leftLen - rightLen if padLen < 1 { padLen = 1 } pad := strings.Repeat(" ", padLen) headerLine := left + pad + right headerStyled := StyleHeaderLine.Render(headerLine) return headerStyled } func (m model) renderKPICards() string { progress := clampProgress(m.status.Progress) barWidth := 20 filled := int(math.Round(float64(barWidth) * progress)) bar := StyleKPIBarFill.Render(strings.Repeat("█", filled)) + StyleKPIBarEmpty.Render(strings.Repeat("░", barWidth-filled)) progressCard := makeCard("ПРОГРЕСС", fmt.Sprintf("%s %s", bar, StyleKPIProgress.Render(formatPercent(progress))), ColorCyan, 36) downCard := makeCard("↓ СКАЧИВАНИЕ", StyleKPIDown.Render(formatBytesRate(m.status.DownloadSpeed)), ColorGreen, 16) upCard := makeCard("↑ ОТДАЧА", StyleKPIUp.Render(formatBytesRate(m.status.UploadSpeed)), ColorYellow, 16) etaCard := makeCard("⏱ ВРЕМЯ", StyleKPIEta.Render(formatETA(m.status)), ColorPink, 14) peersCard := makeCard("◉ ПИРЫ", StyleKPIPeers.Render(fmt.Sprintf("%d / %d", m.status.ActivePeers, m.status.PeerCount)), ColorBorder, 12) row := lipgloss.JoinHorizontal(lipgloss.Top, progressCard, " ", downCard, " ", upCard, " ", etaCard, " ", peersCard, ) return row } func makeCard(title, value, borderColor string, w int) string { inner := lipgloss.JoinVertical(lipgloss.Left, StyleCardTitle.Render(title), value, ) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(borderColor)). Padding(0, 1). Width(w). Render(inner) } func (m model) renderTabs() string { tabDefs := []struct { label string count int }{ {" Обзор", -1}, {" Сеть", len(m.status.Peers) + len(m.status.Trackers)}, } var tabs []string for i, t := range tabDefs { label := t.label if t.count >= 0 { label = fmt.Sprintf("%s (%d)", t.label, t.count) } hint := fmt.Sprintf(" [%d]", i+1) if i == m.activeTab { tabs = append(tabs, lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(ColorCyan)). Background(lipgloss.Color(ColorCardBg)). Border(lipgloss.NormalBorder(), true, true, false, true). BorderForeground(lipgloss.Color(ColorCyan)). Padding(0, 1). Render(label+hint)) } else { tabs = append(tabs, lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). Background(lipgloss.Color(ColorBg)). Border(lipgloss.NormalBorder(), false, true, false, false). BorderForeground(lipgloss.Color(ColorBorder)). Padding(0, 1). Render(label+hint)) } } return lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) } func (m model) renderTabContent() string { contentH := m.height - 17 if contentH < 4 { contentH = 4 } switch m.activeTab { case 1: peersView := m.peersTable.View() trackersView := m.trackersTable.View() return lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ПИРЫ"), peersView, "", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ТРЕКЕРЫ"), trackersView, ) default: overview := m.renderOverview(contentH) filesView := m.filesTable.View() return lipgloss.JoinVertical(lipgloss.Left, overview, "", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(" ФАЙЛЫ"), filesView, ) } } func (m model) viewAdditionalMenu() string { w := 50 title := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(ColorCyan)). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(lipgloss.Color(ColorBorder)). Width(w). Render(" ДОПОЛНИТЕЛЬНО") inner := lipgloss.JoinVertical(lipgloss.Left, title, "", m.renderAdditionalMenu(), ) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorBorder)). Padding(0, 1). Width(w + 2). Render(inner) } func (m model) renderAdditionalMenu() string { cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) pink := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) shortcut := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) lines := []string{ pink.Render(" ⚡ ДОПОЛНИТЕЛЬНО"), "", " " + shortcut.Render("[J]") + val.Render(" Журнал скачиваний (История)"), " " + shortcut.Render("[L]") + val.Render(" Системные логи"), " " + shortcut.Render("[A]") + val.Render(" О программе"), "", cyan.Render(" [Esc] Назад"), } return lipgloss.JoinVertical(lipgloss.Left, lines...) } func renderAboutTab() string { cyan := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) pink := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true) green := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) row := func(label, value string) string { return dim.Render(fmt.Sprintf(" %-18s", label)) + val.Render(value) } buildDate := version.BuildDate if buildDate == "unknown" { buildDate = time.Now().Format("2006-01-02") } lines := []string{ pink.Render(" ⚡ ZTORRENT"), "", cyan.Render(" ВЕРСИЯ"), "", row("Версия:", version.Version), row("Дата сборки:", buildDate), row("Git commit:", version.GitCommit), "", cyan.Render(" ОПИСАНИЕ"), "", val.Render(" Минималистичный BitTorrent-клиент с TUI интерфейсом."), val.Render(" Поддерживает .torrent файлы и magnet-ссылки (через DHT)."), "", cyan.Render(" ГОРЯЧИЕ КЛАВИШИ"), "", row("1-2 / Tab", "переключение вкладок"), row("J", "журнал загрузок"), row("L", "системный лог"), row("?", "эта страница"), row("Пробел / S", "пауза / продолжить"), row("O", "открыть новый торрент"), row("F", "выбрать файл через Finder"), row("C", "очистить и вернуться"), row("Q / Ctrl+C", "выйти"), "", cyan.Render(" ЛОГИ СЕССИЙ"), "", green.Render(" ~/.ztorrent/logs/"), } return lipgloss.JoinVertical(lipgloss.Left, lines...) } func (m model) renderOverview(h int) string { leftW := 44 rightW := m.width - leftW - 7 if rightW < 20 { rightW = 20 } // ── Левая панель: детали ── totalSize := "—" if m.status.TotalBytes > 0 { totalSize = formatBytes(m.status.TotalBytes) } else if m.status.Length > 0 { totalSize = formatBytes(int64(m.status.Length)) } rows := []string{ StyleSectionTitle.Render("ИНФОРМАЦИЯ О ТОРРЕНТЕ"), "", StyleDim.Render("Кусочки: ") + StyleVal.Render(fmt.Sprintf("%d / %d", m.status.CompletedPieces, m.status.PieceCount)), StyleDim.Render("Размер кусочка: ") + StyleVal.Render(formatBytes(int64(m.status.PieceLength))), StyleDim.Render("Скачано: ") + StyleVal.Render(formatBytes(m.status.DownloadedBytes)+" / "+totalSize), StyleDim.Render("Отдано: ") + StyleVal.Render(formatBytes(m.status.UploadedBytes)), StyleDim.Render("Путь сохранения:"), StyleVal.Render(" "+m.status.OutputPath), "", StyleSectionTitle.Render("СКОРОСТЬ"), "", StyleDim.Render("Скачивание: ") + StyleSpeedDown.Render(formatBytesRate(m.status.DownloadSpeed)), StyleDim.Render("Отдача: ") + StyleSpeedUp.Render(formatBytesRate(m.status.UploadSpeed)), StyleDim.Render("ETA: ") + StyleETA.Render(formatETA(m.status)), } if m.status.LastError != "" { rows = append(rows, "") rows = append(rows, StyleErrText.Render("⚠ "+m.status.LastError)) } leftPanel := lipgloss.NewStyle(). Width(leftW). Height(h). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorBorder)). Padding(1, 2). Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) // ── Правая панель: карта кусочков ── gridTitle := StyleSectionTitle.Render(fmt.Sprintf("КАРТА КУСОЧКОВ (%d шт.)", m.status.PieceCount)) gridW := rightW - 6 // отступы панели gridH := h - 7 // заголовок + легенда + отступы gridStr := m.renderPiecesGrid(gridW, gridH) legend := lipgloss.JoinHorizontal(lipgloss.Center, StylePieceCompleted.Render("█ скачано "), StylePieceDownloading.Render("█ качается "), StylePieceMissing.Render("░ отсутствует"), ) rightInner := lipgloss.JoinVertical(lipgloss.Left, gridTitle, "", gridStr, "", legend, ) rightPanel := lipgloss.NewStyle(). Width(rightW). Height(h). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorBorder)). Padding(1, 2). Render(rightInner) return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel) } func (m model) renderPiecesGrid(w, h int) string { if m.status.PieceCount <= 0 || w < 1 || h < 1 { return lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Render(" Нет данных о кусочках") } capacity := w * h pieces := toPieceStates(m.status) piecesPerCell := 1 cellCount := len(pieces) if len(pieces) > capacity { piecesPerCell = (len(pieces) + capacity - 1) / capacity cellCount = (len(pieces) + piecesPerCell - 1) / piecesPerCell if cellCount > capacity { cellCount = capacity } } var sb strings.Builder for cell := 0; cell < cellCount; cell++ { state := cellState(pieces, cell, piecesPerCell) switch state { case PieceCompleted: sb.WriteString(StylePieceCompleted.Render("█")) case PieceDownloading: sb.WriteString(StylePieceDownloading.Render("▓")) default: sb.WriteString(StylePieceMissing.Render("░")) } if (cell+1)%w == 0 && cell < cellCount-1 { sb.WriteString("\n") } } return sb.String() } func (m model) renderHelpBar() string { k := func(key, desc string) string { return StyleHelpKey.Render(key) + StyleHelpDesc.Render(" "+desc) } parts := []string{ k("Tab/1-2", "вкладки"), k("Enter", "приоритет"), k("Пробел", "пауза/старт"), k("?", "дополнительно"), k("Q", "выйти"), } line := strings.Join(parts, cachedHelpSep) return StyleHelpBar.Render(line) } // ── Таблицы ─────────────────────────────────────────────────────────────────── func tableStyles() table.Styles { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(ColorBorder)). BorderBottom(true). Bold(true). Foreground(lipgloss.Color(ColorCyan)). Background(lipgloss.Color(ColorCardBg)) s.Selected = s.Selected. Background(lipgloss.Color("#1e3a4a")). Foreground(lipgloss.Color(ColorCyan)). Bold(true) s.Cell = s.Cell. Foreground(lipgloss.Color(ColorText)) return s } func newJournalTable() table.Model { t := table.New( table.WithColumns([]table.Column{ {Title: "Название", Width: 40}, {Title: "Статус", Width: 15}, {Title: "Прогресс", Width: 10}, {Title: "Размер", Width: 10}, {Title: "Добавлен", Width: 16}, }), table.WithFocused(true), ) t.SetStyles(tableStyles()) return t } func newLogsTable() table.Model { t := table.New( table.WithColumns([]table.Column{ {Title: "Сессия", Width: 55}, {Title: "Дата", Width: 14}, {Title: "Размер", Width: 10}, }), table.WithFocused(true), ) t.SetStyles(tableStyles()) return t } func newPeersTable() table.Model { t := table.New( table.WithColumns([]table.Column{ {Title: "Адрес", Width: 22}, {Title: "Статус", Width: 14}, {Title: "Кусоч.", Width: 7}, {Title: "Источник", Width: 14}, {Title: "Скачивание", Width: 12}, {Title: "Пинг", Width: 8}, {Title: "Рейтинг", Width: 8}, {Title: "Ошибок", Width: 7}, {Title: "Сообщение об ошибке", Width: 28}, }), table.WithFocused(true), ) t.SetStyles(tableStyles()) return t } func newFilesTable() table.Model { t := table.New( table.WithColumns([]table.Column{ {Title: "Файл", Width: 80}, {Title: "Размер", Width: 14}, }), table.WithFocused(true), ) t.SetStyles(tableStyles()) return t } func newTrackersTable() table.Model { t := table.New( table.WithColumns([]table.Column{ {Title: "Адрес трекера", Width: 55}, {Title: "Статус", Width: 14}, {Title: "Найдено пиров", Width: 14}, {Title: "Ошибка", Width: 36}, }), table.WithFocused(true), ) t.SetStyles(tableStyles()) return t } // ── Конвертеры кусочков ─────────────────────────────────────────────────────── func toPieceStates(status torrent.Status) []PieceState { if status.PieceCount <= 0 { return nil } states := make([]PieceState, status.PieceCount) if len(status.PieceStates) == status.PieceCount { for i, s := range status.PieceStates { switch s { case torrent.PieceCompleted: states[i] = PieceCompleted case torrent.PieceDownloading: states[i] = PieceDownloading default: states[i] = PieceMissing } } return states } completed := status.CompletedPieces if completed > status.PieceCount { completed = status.PieceCount } for i := 0; i < completed; i++ { states[i] = PieceCompleted } if completed < status.PieceCount && isBusyPhase(status.Phase) { states[completed] = PieceDownloading } return states } func cellState(pieces []PieceState, cellIdx, piecesPerCell int) PieceState { start := cellIdx * piecesPerCell if start >= len(pieces) { return PieceMissing } end := start + piecesPerCell if end > len(pieces) { end = len(pieces) } hasCompleted := false for i := start; i < end; i++ { switch pieces[i] { case PieceDownloading: return PieceDownloading case PieceCompleted: hasCompleted = true } } if hasCompleted { return PieceCompleted } return PieceMissing } // ── Валидация путей ─────────────────────────────────────────────────────────── func validateInputs(torrentPath, downloadRoot string) error { torrentPath = strings.TrimSpace(torrentPath) if torrentPath == "" { return fmt.Errorf("укажите путь к .torrent файлу") } if strings.HasPrefix(torrentPath, "magnet:") { // Это магнет-ссылка, пропускаем проверку файла } else { path := filepath.Clean(torrentPath) info, err := os.Stat(path) if err != nil { return fmt.Errorf("файл недоступен: %v", err) } if info.IsDir() { return fmt.Errorf("путь должен вести к файлу, а не к папке") } if ext := strings.ToLower(filepath.Ext(path)); ext != ".torrent" { return fmt.Errorf("файл должен иметь расширение .torrent") } } downloadRoot = strings.TrimSpace(downloadRoot) if downloadRoot == "" { return fmt.Errorf("укажите папку для загрузки") } downloadRoot = filepath.Clean(downloadRoot) dirInfo, err := os.Stat(downloadRoot) if err != nil { if mkErr := os.MkdirAll(downloadRoot, 0755); mkErr != nil { return fmt.Errorf("папка загрузки недоступна: %v", mkErr) } } else if !dirInfo.IsDir() { return fmt.Errorf("путь загрузки должен быть папкой") } return nil } // ── Форматирование ──────────────────────────────────────────────────────────── func formatBytesRate(bytesPerSec float64) string { return formatByteValue(bytesPerSec, true) } func formatBytes(bytes int64) string { return formatByteValue(float64(bytes), false) } func formatByteValue(value float64, perSecond bool) string { if value < 0 { value = 0 } units := []string{"Б", "КБ", "МБ", "ГБ", "ТБ"} unit := 0 for value >= 1024 && unit < len(units)-1 { value /= 1024 unit++ } suffix := "" if perSecond { suffix = "/с" } if unit == 0 { return fmt.Sprintf("%.0f %s%s", value, units[unit], suffix) } return fmt.Sprintf("%.2f %s%s", value, units[unit], suffix) } func formatPercent(progress float64) string { return fmt.Sprintf("%.1f%%", clampProgress(progress)*100) } // phaseRU переводит фазы на русский func phaseRU(phase string) string { switch strings.TrimSpace(phase) { case "": return "Ожидание" case "loading_metadata": return "Загрузка метаданных" case "downloading_metadata": return "Загрузка метаданных (DHT)" case "querying_trackers": return "Опрос трекеров" case "ready": return "Готово" case "preparing_download": return "Подготовка" case "downloading": return "Скачивание" case "writing_files": return "Запись файлов" case "seeding": return "Раздача" case "done": return "Завершено" case "stopped": return "Остановлено" case "failed": return "Ошибка" case "connected": return "Подключён" case "connecting": return "Подключение" case "disconnected": return "Отключён" case "announced": return "Анонсирован" case "error": return "Ошибка" default: // Капитализация по умолчанию parts := strings.Split(phase, "_") for i, p := range parts { if p == "" { continue } parts[i] = strings.ToUpper(p[:1]) + p[1:] } return strings.Join(parts, " ") } } func formatETA(status torrent.Status) string { if status.TotalBytes <= 0 { return "—" } if status.DownloadedBytes >= status.TotalBytes { return "Готово" } if status.DownloadSpeed <= 0 { return "—" } remaining := status.TotalBytes - status.DownloadedBytes seconds := int64(float64(remaining) / status.DownloadSpeed) if seconds < 0 { return "—" } hours := seconds / 3600 minutes := (seconds % 3600) / 60 secs := seconds % 60 if hours > 99 { return ">99ч" } return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) } func clampProgress(progress float64) float64 { if progress < 0 { return 0 } if progress > 1 { return 1 } return progress } func isBusyPhase(phase string) bool { switch phase { case "loading_metadata", "querying_trackers", "ready", "preparing_download", "downloading", "writing_files": return true default: return false } } // ── Точка входа ─────────────────────────────────────────────────────────────── func Start(controller *appcore.Controller) { m := initialModel(controller) if len(os.Args) > 1 { torrentPath := os.Args[1] outputDir := "." if home, err := os.UserHomeDir(); err == nil { outputDir = home } if err := validateInputs(torrentPath, outputDir); err == nil { m.torrentPath = torrentPath m.outputDir = outputDir m.mainMenuMode = false m.loadingMetadata = true m.inputs[0].SetValue(torrentPath) m.inputs[1].SetValue(outputDir) } } p := tea.NewProgram(m, tea.WithAltScreen()) if !m.mainMenuMode && m.loadingMetadata { go func() { time.Sleep(150 * time.Millisecond) p.Send(startTorrentResultMsg{ err: controller.StartTorrent(m.torrentPath, m.outputDir), }) }() } if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) os.Exit(1) } }