Ztorrent/ui/ui.go

2281 lines
66 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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