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