Ztorrent/ui/ui.go

2030 lines
62 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
streamingMode bool
// Таблицы
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 msg.String() == "v" || msg.String() == "V":
m.streamingMode = !m.streamingMode
m.controller.SetSequentialMode(m.streamingMode)
if m.streamingMode {
m.controller.StartStreamServer()
} else {
m.controller.StopStreamServer()
}
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),
)
var streamBadge string
if m.streamingMode {
streamBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1017")).Background(lipgloss.Color(ColorPink)).Bold(true).Padding(0, 1).Render("▶ STREAM") + " "
}
right := lipgloss.JoinHorizontal(lipgloss.Center,
streamBadge,
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("V", "стриминг"),
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)
}
}