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/history" "github.com/veggiedefender/torrent-client/internal/sessionlog" "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 TabPeers key.Binding TabFiles key.Binding TabTrackers 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 FilePicker key.Binding } var keys = keyMap{ TabOverview: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "обзор")), TabPeers: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "пиры")), TabFiles: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "файлы")), TabTrackers: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "трекеры")), 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", "выйти")), FilePicker: key.NewBinding(key.WithKeys("f"), key.WithHelp("F", "выбрать файл")), } // ── Модель ──────────────────────────────────────────────────────────────────── 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 // Экран нового торрента welcomeMode bool inputs []textinput.Model focusedInput int torrentPath string outputDir string validationErr string // Загрузка loadingMetadata bool spinner spinner.Model // Таблицы peersTable table.Model filesTable table.Model trackersTable table.Model // Лог сессий logWriter *sessionlog.LogWriter logsTable table.Model logEntries []sessionlog.Entry logViewMode bool // true = просмотр содержимого лога logContent string // содержимое открытого лога logScrollY int // позиция прокрутки в просмотрщике logsMode bool // true = экран логов (overlay) aboutMode bool // true = экран о программе (overlay) // Анимация логотипа logoEngine *LogoEngine } 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 if home, err := os.UserHomeDir(); err == nil { outputInput.SetValue(home) } else { outputInput.SetValue(".") } s := spinner.New() s.Spinner = spinner.Points s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)) // Лог по умолчанию (до старта торрента) var lw *sessionlog.LogWriter if w, err := sessionlog.NewLogWriter("startup"); err == nil { lw = w } else { log.SetOutput(io.Discard) } m := model{ controller: controller, journalTable: newJournalTable(), welcomeMode: true, inputs: []textinput.Model{torrentInput, outputInput}, focusedInput: 0, spinner: s, peersTable: newPeersTable(), filesTable: newFilesTable(), trackersTable: newTrackersTable(), logsTable: newLogsTable(), logWriter: lw, logoEngine: NewLogoEngine(28, 12, 4.0, 1.0), } m.loadHistory() m.loadLogs() 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) } } func (m *model) loadLogs() { entries, err := sessionlog.List() if err != nil { return } m.logEntries = entries var rows []table.Row for _, e := range entries { size := "-" if info, err := os.Stat(e.Path); err == nil { size = formatBytes(info.Size()) } rows = append(rows, table.Row{ e.Name, e.ModTime.Format("02.01 15:04"), size, }) } m.logsTable.SetRows(rows) } // ── Init ────────────────────────────────────────────────────────────────────── func (m model) Init() tea.Cmd { 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(50*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 string) tea.Cmd { return func() tea.Msg { err := controller.StartTorrent(torrentPath, outputDir) return startTorrentResultMsg{err: err} } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // Выход — работает всегда if key.Matches(msg, keys.Quit) { if m.logWriter != nil { m.logWriter.Close() } 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 { if m.logViewMode { switch msg.String() { case "esc", "b", "q": m.logViewMode = false case "up", "k": if m.logScrollY > 0 { m.logScrollY-- } case "down", "j": m.logScrollY++ case "pgup", "u": m.logScrollY -= 20 if m.logScrollY < 0 { m.logScrollY = 0 } case "pgdown", "d": m.logScrollY += 20 } return m, nil } switch msg.String() { case "esc", "q": m.logsMode = false m.loadLogs() return m, nil case "enter": row := m.logsTable.Cursor() if row >= 0 && row < len(m.logEntries) { content, err := sessionlog.Read(m.logEntries[row].Path) if err == nil { m.logContent = content m.logScrollY = 0 m.logViewMode = true } } return m, nil case "x", "X": row := m.logsTable.Cursor() if row >= 0 && row < len(m.logEntries) { sessionlog.Delete(m.logEntries[row].Path) m.loadLogs() } return m, nil case "o": if dir, err := sessionlog.Dir(); err == nil { exec.Command("open", dir).Start() } return m, nil } m.logsTable, cmd = m.logsTable.Update(msg) return m, cmd } // Открыть оверлей о программе if msg.String() == "?" || msg.String() == "7" { m.aboutMode = true return m, nil } // Открыть оверлей логов (работает с любого экрана) if msg.String() == "l" || msg.String() == "L" || msg.String() == "6" { m.logsMode = true m.logViewMode = false m.loadLogs() return m, nil } // Открыть оверлей журнала (работает с любого экрана) if msg.String() == "j" || msg.String() == "J" || msg.String() == "5" { if !m.journalMode { m.journalMode = true m.loadHistory() return m, nil } } // ── Журнал ── if m.journalMode { switch msg.String() { case "n", "esc", "q", "j", "J": m.journalMode = false m.welcomeMode = 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 return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) } } } if cmd, handled := m.handleJournalKeys(msg); handled { return m, cmd } m.journalTable, cmd = m.journalTable.Update(msg) return m, cmd } // ── Экран приветствия ── if m.welcomeMode { switch msg.String() { case "j", "J", "5": m.welcomeMode = false m.journalMode = true m.loadHistory() return m, nil case "tab", "down": m.inputs[m.focusedInput].Blur() m.focusedInput = (m.focusedInput + 1) % len(m.inputs) m.inputs[m.focusedInput].Focus() return m, nil case "shift+tab", "up": m.inputs[m.focusedInput].Blur() m.focusedInput = (m.focusedInput - 1 + len(m.inputs)) % len(m.inputs) m.inputs[m.focusedInput].Focus() return m, nil case "f": // Открыть Finder для выбора .torrent файла return m, filePickerCmd() case "enter": torrentPath := strings.TrimSpace(m.inputs[0].Value()) outputDir := strings.TrimSpace(m.inputs[1].Value()) if err := validateInputs(torrentPath, outputDir); err != nil { m.validationErr = err.Error() return m, nil } m.torrentPath = torrentPath m.outputDir = outputDir m.validationErr = "" m.loadingMetadata = true m.welcomeMode = false return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, torrentPath, outputDir)) } 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.welcomeMode = 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.TabPeers): m.activeTab = 1 case key.Matches(msg, keys.TabFiles): m.activeTab = 2 case key.Matches(msg, keys.TabTrackers): m.activeTab = 3 case key.Matches(msg, keys.NextTab): m.activeTab = (m.activeTab + 1) % 4 case key.Matches(msg, keys.PrevTab): m.activeTab = (m.activeTab - 1 + 4) % 4 case key.Matches(msg, keys.PauseResume): phase := m.status.Phase if phase == "stopped" || phase == "failed" || phase == "" { m.loadingMetadata = true return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) } m.controller.StopTorrent() case key.Matches(msg, keys.OpenNew): m.controller.StopTorrent() m.welcomeMode = true m.status = torrent.Status{} return m, nil case key.Matches(msg, keys.Clear): m.controller.StopTorrent() m.welcomeMode = true m.inputs[0].SetValue("") m.status = torrent.Status{} return m, nil } // Прокрутка в таблицах switch m.activeTab { case 1: m.peersTable, cmd = m.peersTable.Update(msg) return m, cmd case 2: m.filesTable, cmd = m.filesTable.Update(msg) return m, cmd case 3: m.trackersTable, cmd = m.trackersTable.Update(msg) return m, cmd case 4: switch msg.String() { 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.controller.StopTorrent() m.torrentPath = it.TorrentPath m.outputDir = it.OutputDir m.loadingMetadata = true m.activeTab = 0 return m, tea.Batch(m.spinner.Tick, startTorrentCmd(m.controller, m.torrentPath, m.outputDir)) } } } if cmd, handled := m.handleJournalKeys(msg); handled { return m, cmd } m.journalTable, cmd = m.journalTable.Update(msg) return m, cmd case 5: // Логи if m.logViewMode { // Просмотр лога — навигация switch msg.String() { case "esc", "b", "q": m.logViewMode = false return m, nil case "up", "k": if m.logScrollY > 0 { m.logScrollY-- } return m, nil case "down", "j": m.logScrollY++ return m, nil case "pgup", "u": m.logScrollY -= 20 if m.logScrollY < 0 { m.logScrollY = 0 } return m, nil case "pgdown", "d": m.logScrollY += 20 return m, nil } } else { // Список логов switch msg.String() { case "enter": row := m.logsTable.Cursor() if row >= 0 && row < len(m.logEntries) { content, err := sessionlog.Read(m.logEntries[row].Path) if err == nil { m.logContent = content m.logScrollY = 0 m.logViewMode = true } } return m, nil case "x", "X": row := m.logsTable.Cursor() if row >= 0 && row < len(m.logEntries) { sessionlog.Delete(m.logEntries[row].Path) m.loadLogs() } return m, nil case "o": if dir, err := sessionlog.Dir(); err == nil { exec.Command("open", dir).Start() } return m, nil } m.logsTable, cmd = m.logsTable.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.welcomeMode = true m.validationErr = msg.err.Error() return m, nil } m.status = m.controller.Status() // Открываем новый лог для этой сессии if m.logWriter != nil { m.logWriter.Close() } torrentName := m.status.Name if torrentName == "" { torrentName = filepath.Base(m.torrentPath) } if lw, err := sessionlog.NewLogWriter(torrentName); err == nil { m.logWriter = lw } // 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.welcomeMode { 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.welcomeMode = 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() } else if m.activeTab == 5 { m.loadLogs() } return m, tickCmd() case animTickMsg: if m.logoEngine != nil { m.logoEngine.Update(time.Now()) } 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) m.logsTable.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 { fileRows = append(fileRows, table.Row{ 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 { // Режимы-overlays работают поверх любого экрана if m.aboutMode { return m.viewOverlayAbout() } if m.logsMode { return m.viewOverlayLogs() } if m.journalMode { return m.viewJournal() } if m.welcomeMode { return m.viewWelcome() } if m.loadingMetadata { return m.viewLoading() } return m.viewDashboard() } // ── 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("1-7 / Tab", "переключение вкладок"), row("J / 5", "журнал загрузок"), row("L / 6", "журнал логов"), row("? / 7", "эта страница"), row("Пробел / S", "пауза / продолжить"), row("O", "открыть новый торрент"), row("F", "выбрать .torrent файл"), row("C", "очистить и вернуться"), 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)) var content string if m.logViewMode { lines := strings.Split(m.logContent, "\n") viewH := h - 12 if viewH < 1 { viewH = 1 } start := m.logScrollY if start < 0 { start = 0 } if start >= len(lines) { start = len(lines) - 1 if start < 0 { start = 0 } } end := start + viewH if end > len(lines) { end = len(lines) } var colored []string for _, l := range lines[start:end] { switch { case strings.HasPrefix(l, "#"): colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(l)) case strings.Contains(l, "error") || strings.Contains(l, "failed"): colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render(l)) case strings.Contains(l, "DHT") || strings.Contains(l, "peer"): colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render(l)) default: colored = append(colored, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(l)) } } scrollInfo := fmt.Sprintf("Строка %d/%d", start+1, len(lines)) content = lipgloss.JoinVertical(lipgloss.Left, cyan.Render(" ► Просмотр лога"), "", strings.Join(colored, "\n"), "", dim.Render(" ↑↓/jk — прокрутка PgUp/PgDn — быстро Esc/B — назад "+scrollInfo), ) } else { var tableContent string if len(m.logEntries) == 0 { tableContent = dim.Render(" Логи появятся автоматически после первого запуска торрента.\n Директория: ~/.ztorrent/logs/") } else { m.logsTable.SetHeight(h - 14) tableContent = m.logsTable.View() } content = lipgloss.JoinVertical(lipgloss.Left, cyan.Render(" Журнал логов сессий"), "", tableContent, "", dim.Render(" Enter — открыть X — удалить O — в Finder Esc/Q — закрыть"), ) } box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorCyan)). Padding(1, 2). Width(w - 6). 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) viewWelcome() string { w := m.width if w < 80 { w = 80 } // ── Логотип: динамический (частицы) + статичный «torrent» ── var zArt string if m.logoEngine != nil { zArt = m.logoEngine.Render(time.Now()) } zLines := strings.Split(zArt, "\n") // Статичный текст "torrent" в стиле большого ASCII torrentLines := []string{ " ████████╗ ██████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗", " ██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝", " ██║ ██║ ██║██████╔╝██████╔╝█████╗ ██╔██╗██║ ██║ ", " ██║ ██║ ██║██╔══██╗██╔══██╗██╔══╝ ██║╚████║ ██║ ", " ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ╚███║ ██║ ", " ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝ ╚═╝ ", "", "", "", } // Выравниваем Z и torrent по вертикали (по центру) zH := len(zLines) tH := len(torrentLines) maxH := zH if tH > maxH { maxH = tH } zPad := (maxH - zH) / 2 tPad := (maxH - tH) / 2 tStyled := lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorText)). Bold(true) var headerLines []string for row := 0; row < maxH; row++ { zRow := row - zPad var zPart string const zWidth = 28 if zRow >= 0 && zRow < zH { zPart = zLines[zRow] } if zPart == "" { zPart = strings.Repeat(" ", zWidth) } tRow := row - tPad var tPart string if tRow >= 0 && tRow < tH { tPart = torrentLines[tRow] } headerLines = append(headerLines, zPart+tStyled.Render(tPart)) } header := strings.Join(headerLines, "\n") // Форма labels := []string{" Торрент-файл ", " Папка загрузки"} 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], ) formBox := lipgloss.NewStyle(). Border(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color(ColorBorder)). Padding(1, 3). MarginTop(1). Render(formInner) // Кнопка 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 J — журнал %s L — логи %s ? — о программе %s Enter — начать %s Q — выйти", lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color("#bd93f9")).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render("●"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).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 { 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) 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 = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Bold(true).Render(phase) case "failed": icon = "✗" phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Bold(true).Render(phase) case "seeding", "done": icon = "✓" phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true).Render(phase) case "downloading": icon = "⬇" phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(phase) default: icon = "◌" phaseColored = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true).Render(phase) } left := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render("⚡ ZTORRENT"), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(" │ "), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(name), ) right := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).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 := lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(lipgloss.Color(ColorBorder)). PaddingBottom(0). Render(headerLine) return headerStyled } func (m model) renderKPICards() string { progress := clampProgress(m.status.Progress) barWidth := 20 filled := int(math.Round(float64(barWidth) * progress)) bar := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Render(strings.Repeat("█", filled)) + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(strings.Repeat("░", barWidth-filled)) progressCard := makeCard("ПРОГРЕСС", fmt.Sprintf("%s %s", bar, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)).Render(formatPercent(progress))), ColorCyan, 36) downCard := makeCard("↓ СКАЧИВАНИЕ", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorGreen)).Render(formatBytesRate(m.status.DownloadSpeed)), ColorGreen, 16) upCard := makeCard("↑ ОТДАЧА", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorYellow)).Render(formatBytesRate(m.status.UploadSpeed)), ColorYellow, 16) etaCard := makeCard("⏱ ВРЕМЯ", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorPink)).Render(formatETA(m.status)), ColorPink, 14) peersCard := makeCard("◉ ПИРЫ", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorText)). 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, lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). Bold(true). 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.Files)}, {" Трекеры", 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: return m.peersTable.View() case 2: return m.filesTable.View() case 3: return m.trackersTable.View() default: return m.renderOverview(contentH) } } 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-7 / 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) renderLogsTab(h int) string { title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) if m.logViewMode { // Просмотр содержимого лога lines := strings.Split(m.logContent, "\n") viewH := h - 4 if viewH < 1 { viewH = 1 } start := m.logScrollY if start < 0 { start = 0 } if start >= len(lines) { start = len(lines) - 1 if start < 0 { start = 0 } } end := start + viewH if end > len(lines) { end = len(lines) } visibleLines := lines[start:end] // Цветовая подсветка лога var coloredLines []string for _, l := range visibleLines { var styled string switch { case strings.HasPrefix(l, "#"): styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorCyan)).Bold(true).Render(l) case strings.HasPrefix(l, "**") || strings.HasPrefix(l, "*"): styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render(l) case strings.Contains(l, "error") || strings.Contains(l, "failed") || strings.Contains(l, "Ошибка"): styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorRed)).Render(l) case strings.Contains(l, "DHT") || strings.Contains(l, "peer") || strings.Contains(l, "трекер"): styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render(l) case strings.HasPrefix(l, "---") || strings.HasPrefix(l, "```"): styled = dim.Render(l) default: styled = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)).Render(l) } coloredLines = append(coloredLines, styled) } content := strings.Join(coloredLines, "\n") scrollInfo := fmt.Sprintf("Строка %d / %d", start+1, len(lines)) help := dim.Render("↕ ↑↓/jk — прокрутка PgUp/PgDn — быстро Esc/B — назад   "+scrollInfo) return lipgloss.JoinVertical(lipgloss.Left, title.Render("► Просмотр лога"), "", content, "", help, ) } // Список логов if len(m.logEntries) == 0 { return lipgloss.JoinVertical(lipgloss.Left, title.Render("Журнал логов сессий"), "", dim.Render(" Логи появятся автоматически после первого запуска торрента"), dim.Render(" Директория: ~/.ztorrent/logs/"), ) } help := dim.Render("Enter — открыть X — удалить O — открыть папку") return lipgloss.JoinVertical(lipgloss.Left, title.Render("Журнал логов сессий"), "", m.logsTable.View(), "", help, ) } func (m model) renderOverview(h int) string { leftW := 44 rightW := m.width - leftW - 7 if rightW < 20 { rightW = 20 } // ── Левая панель: детали ── sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorCyan)) dim := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)) val := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) 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{ sectionTitle.Render("ИНФОРМАЦИЯ О ТОРРЕНТЕ"), "", dim.Render("Кусочки: ") + val.Render(fmt.Sprintf("%d / %d", m.status.CompletedPieces, m.status.PieceCount)), dim.Render("Размер кусочка: ") + val.Render(formatBytes(int64(m.status.PieceLength))), dim.Render("Скачано: ") + val.Render(formatBytes(m.status.DownloadedBytes)+" / "+totalSize), dim.Render("Отдано: ") + val.Render(formatBytes(m.status.UploadedBytes)), dim.Render("Путь сохранения:"), val.Render(" "+m.status.OutputPath), "", sectionTitle.Render("СКОРОСТЬ"), "", dim.Render("Скачивание: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Bold(true).Render(formatBytesRate(m.status.DownloadSpeed)), dim.Render("Отдача: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Bold(true).Render(formatBytesRate(m.status.UploadSpeed)), dim.Render("ETA: ") + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render(formatETA(m.status)), } if m.status.LastError != "" { rows = append(rows, "") rows = append(rows, lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorRed)).Bold(true). 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 := sectionTitle.Render(fmt.Sprintf("КАРТА КУСОЧКОВ (%d шт.)", m.status.PieceCount)) gridW := rightW - 6 // отступы панели gridH := h - 7 // заголовок + легенда + отступы gridStr := m.renderPiecesGrid(gridW, gridH) legend := lipgloss.JoinHorizontal(lipgloss.Center, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)).Render("█ скачано "), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)).Render("█ качается "), lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).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 } } cGreen := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorGreen)) cYellow := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorYellow)) cGray := lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)) var sb strings.Builder for cell := 0; cell < cellCount; cell++ { state := cellState(pieces, cell, piecesPerCell) switch state { case PieceCompleted: sb.WriteString(cGreen.Render("█")) case PieceDownloading: sb.WriteString(cYellow.Render("▓")) default: sb.WriteString(cGray.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 lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPink)).Bold(true).Render(key) + lipgloss.NewStyle().Foreground(lipgloss.Color(ColorSubdued)).Render(" "+desc) } parts := []string{ k("Tab/1-7", "вкладки"), k("J", "журнал"), k("L", "логи"), k("?", "инфо"), k("Пробел", "пауза/старт"), k("O", "открыть"), k("C", "очистить"), k("Q", "выйти"), } line := strings.Join(parts, lipgloss.NewStyle().Foreground(lipgloss.Color(ColorBorder)).Render(" │ ")) return lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorSubdued)). Border(lipgloss.NormalBorder(), true, false, false, false). BorderForeground(lipgloss.Color(ColorBorder)). MarginTop(1). PaddingTop(0). 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.welcomeMode = false m.loadingMetadata = true m.inputs[0].SetValue(torrentPath) m.inputs[1].SetValue(outputDir) } } p := tea.NewProgram(m, tea.WithAltScreen()) if !m.welcomeMode && 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) } }