From bd5ebf2fd7f8726e2277ec957193ab8fb457c4ef Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:40:49 +0300 Subject: [PATCH] 228 --- ui/window.go | 529 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 376 insertions(+), 153 deletions(-) diff --git a/ui/window.go b/ui/window.go index 907f5ec..12635e2 100644 --- a/ui/window.go +++ b/ui/window.go @@ -13,7 +13,6 @@ import ( fyneapp "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/widget" appcore "github.com/veggiedefender/torrent-client/internal/app" @@ -23,29 +22,37 @@ import ( type desktopUI struct { controller *appcore.Controller - app fyne.App window fyne.Window pathEntry *widget.Entry downloadDirEntry *widget.Entry - progress *widget.ProgressBar - phaseLabel *widget.Label - phaseMetricLabel *widget.Label - messageLabel *widget.Label - errorLabel *widget.Label - nameLabel *widget.Label - sizeLabel *widget.Label - pieceLabel *widget.Label - pieceCountLabel *widget.Label - downloadedLabel *widget.Label - outputLabel *widget.Label - speedLabel *widget.Label - peerCountLabel *widget.Label - mu sync.RWMutex - trackers []torrent.TrackerStatus - peers []torrent.PeerStatus - files []torrentfile.File + browseButton *widget.Button + browseOutputButton *widget.Button + startButton *widget.Button + stopButton *widget.Button + + progress *widget.ProgressBar + phaseLabel *widget.Label + phaseMetricLabel *widget.Label + statusSummaryLabel *widget.Label + messageLabel *widget.Label + errorLabel *widget.Label + nameLabel *widget.Label + sizeLabel *widget.Label + pieceLabel *widget.Label + pieceCountLabel *widget.Label + downloadedLabel *widget.Label + outputLabel *widget.Label + speedLabel *widget.Label + peerCountLabel *widget.Label + + mu sync.RWMutex + trackers []torrent.TrackerStatus + peers []torrent.PeerStatus + files []torrentfile.File + inputError string + loading bool trackersTable *widget.Table peersTable *widget.Table @@ -61,55 +68,68 @@ func Start(controller *appcore.Controller) { func newDesktopUI(controller *appcore.Controller) *desktopUI { a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent") - w := a.NewWindow("Ztorrent") - w.Resize(fyne.NewSize(1200, 760)) + w := a.NewWindow("Ztorrent Desktop") + w.Resize(fyne.NewSize(1280, 800)) ui := &desktopUI{ - controller: controller, - app: a, - window: w, - stopRefresh: make(chan struct{}), - pathEntry: widget.NewEntry(), - downloadDirEntry: widget.NewEntry(), - progress: widget.NewProgressBar(), - phaseLabel: widget.NewLabel("Phase: idle"), - phaseMetricLabel: widget.NewLabel("idle"), - messageLabel: widget.NewLabel(""), - errorLabel: widget.NewLabel(""), - nameLabel: widget.NewLabel("-"), - sizeLabel: widget.NewLabel("-"), - pieceLabel: widget.NewLabel("-"), - pieceCountLabel: widget.NewLabel("-"), - downloadedLabel: widget.NewLabel("0 / 0"), - outputLabel: widget.NewLabel("-"), - speedLabel: widget.NewLabel("0 B/s"), - peerCountLabel: widget.NewLabel("0"), + controller: controller, + window: w, + stopRefresh: make(chan struct{}), + pathEntry: widget.NewEntry(), + downloadDirEntry: widget.NewEntry(), + progress: widget.NewProgressBar(), + phaseLabel: widget.NewLabel("Phase: Idle"), + phaseMetricLabel: widget.NewLabel("Idle"), + statusSummaryLabel: widget.NewLabel("0.0% | 0 B / - | 0 B/s"), + messageLabel: widget.NewLabel("Ready"), + errorLabel: widget.NewLabel(""), + nameLabel: widget.NewLabel("-"), + sizeLabel: widget.NewLabel("-"), + pieceLabel: widget.NewLabel("-"), + pieceCountLabel: widget.NewLabel("-"), + downloadedLabel: widget.NewLabel("0 B / -"), + outputLabel: widget.NewLabel("-"), + speedLabel: widget.NewLabel("0 B/s"), + peerCountLabel: widget.NewLabel("0"), } ui.pathEntry.SetPlaceHolder("/path/to/file.torrent") ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder") + ui.messageLabel.Wrapping = fyne.TextWrapWord + ui.errorLabel.Wrapping = fyne.TextWrapWord + ui.outputLabel.Wrapping = fyne.TextWrapWord + ui.nameLabel.Wrapping = fyne.TextWrapWord if home, err := os.UserHomeDir(); err == nil { ui.downloadDirEntry.SetText(home) } - browseButton := widget.NewButton("Browse Torrent", ui.onBrowse) - browseOutputButton := widget.NewButton("Browse Folder", ui.onBrowseOutputFolder) - loadButton := widget.NewButton("Load Torrent", ui.onLoad) - torrentRow := container.NewBorder(nil, nil, nil, browseButton, ui.pathEntry) - outputRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseOutputButton, loadButton), ui.downloadDirEntry) - topRows := container.NewVBox(torrentRow, outputRow) + ui.browseButton = widget.NewButton("Browse Torrent", ui.onBrowse) + ui.browseOutputButton = widget.NewButton("Browse Folder", ui.onBrowseOutputFolder) + ui.startButton = widget.NewButton("Start Download", ui.onLoad) + ui.stopButton = widget.NewButton("Stop", ui.onStop) + ui.stopButton.Disable() - metaGrid := container.NewGridWithColumns(4, + torrentRow := container.NewBorder(nil, nil, nil, ui.browseButton, ui.pathEntry) + actions := container.NewHBox(ui.browseOutputButton, ui.startButton, ui.stopButton) + outputRow := container.NewBorder(nil, nil, nil, actions, ui.downloadDirEntry) + inputCard := widget.NewCard( + "Input", + "Choose .torrent source and download output folder", + container.NewVBox(torrentRow, outputRow), + ) + + metaGrid := container.NewGridWithColumns(3, wrapMetric("Name", ui.nameLabel), - wrapMetric("Size (bytes)", ui.sizeLabel), - wrapMetric("Piece length", ui.pieceLabel), - wrapMetric("Completed pieces", ui.pieceCountLabel), - wrapMetric("Downloaded", ui.downloadedLabel), + wrapMetric("Size", ui.sizeLabel), + wrapMetric("Piece Length", ui.pieceLabel), + wrapMetric("Completed Pieces", ui.pieceCountLabel), + wrapMetric("Transferred", ui.downloadedLabel), wrapMetric("Speed", ui.speedLabel), wrapMetric("Output", ui.outputLabel), - wrapMetric("Discovered peers", ui.peerCountLabel), + wrapMetric("Discovered Peers", ui.peerCountLabel), wrapMetric("Phase", ui.phaseMetricLabel), ) + detailsCard := widget.NewCard("Details", "", metaGrid) ui.trackersTable = ui.newTrackersTable() ui.peersTable = ui.newPeersTable() @@ -120,33 +140,28 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI { container.NewTabItem("Peers", container.NewMax(ui.peersTable)), container.NewTabItem("Files", container.NewMax(ui.filesTable)), ) + tabs.SetTabLocation(container.TabLocationTop) statusColumn := container.NewVBox( ui.phaseLabel, ui.progress, + ui.statusSummaryLabel, ui.messageLabel, ui.errorLabel, ) + statusCard := widget.NewCard("Transfer Status", "", statusColumn) - content := container.NewBorder( - topRows, - nil, - nil, - nil, - container.NewVBox( - statusColumn, - layout.NewSpacer(), - metaGrid, - layout.NewSpacer(), - tabs, - ), - ) + header := container.NewVBox(inputCard, statusCard, detailsCard) + content := container.NewBorder(header, nil, nil, nil, tabs) w.SetContent(content) w.SetOnClosed(func() { close(ui.stopRefresh) + ui.controller.StopTorrent() }) + ui.updateFromStatus(ui.controller.Status()) + if len(os.Args) > 1 { ui.pathEntry.SetText(os.Args[1]) ui.onLoad() @@ -173,47 +188,108 @@ func (ui *desktopUI) onBrowse() { defer reader.Close() ui.pathEntry.SetText(reader.URI().Path()) + ui.setError("") }, ui.window) fd.SetFilter(filter) fd.Show() } func (ui *desktopUI) onLoad() { - path := strings.TrimSpace(ui.pathEntry.Text) - if path == "" { - ui.setError("select a .torrent file first") - return - } - downloadRoot := strings.TrimSpace(ui.downloadDirEntry.Text) - if downloadRoot == "" { - ui.setError("select a download folder first") - return - } - info, err := os.Stat(downloadRoot) + path, downloadRoot, err := ui.validateInputs() if err != nil { - ui.setError("download folder is not accessible: " + err.Error()) - return - } - if !info.IsDir() { - ui.setError("download path must be a folder") + ui.setError(err.Error()) return } - ui.setMessage("Loading torrent...") + ui.mu.Lock() + if ui.loading { + ui.mu.Unlock() + return + } + ui.loading = true + ui.inputError = "" + ui.mu.Unlock() + + ui.setMessage("Loading metadata and contacting trackers...") + ui.setError("") + ui.applyControlState("loading_metadata") + + go func(path, outputRoot string) { + err := ui.controller.StartTorrent(path, outputRoot) + status := ui.controller.Status() + + fyne.Do(func() { + ui.mu.Lock() + ui.loading = false + ui.mu.Unlock() + + ui.updateFromStatusOnUI(status) + if err != nil { + ui.setMessageOnUI("Failed to load " + filepath.Base(path)) + ui.setErrorOnUI(err.Error()) + return + } + ui.setMessageOnUI("Loaded " + filepath.Base(path)) + }) + }(path, downloadRoot) +} + +func (ui *desktopUI) onStop() { + ui.mu.Lock() + ui.loading = false + ui.mu.Unlock() + + ui.setMessage("Stopping...") ui.setError("") go func() { - err := ui.controller.StartTorrent(path, downloadRoot) + ui.controller.StopTorrent() status := ui.controller.Status() - ui.updateFromStatus(status) - if err != nil { - ui.setError(err.Error()) - } else { - ui.setMessage("Loaded " + filepath.Base(path)) - } + + fyne.Do(func() { + ui.updateFromStatusOnUI(status) + if status.Phase == "stopped" || status.Phase == "idle" { + ui.setMessageOnUI("Stopped") + } + }) }() } +func (ui *desktopUI) validateInputs() (string, string, error) { + pathRaw := strings.TrimSpace(ui.pathEntry.Text) + if pathRaw == "" { + return "", "", fmt.Errorf("select a .torrent file first") + } + path := filepath.Clean(pathRaw) + + info, err := os.Stat(path) + if err != nil { + return "", "", fmt.Errorf("torrent file is not accessible: %w", err) + } + if info.IsDir() { + return "", "", fmt.Errorf("torrent path must point to a file") + } + if ext := strings.ToLower(filepath.Ext(path)); ext != ".torrent" { + return "", "", fmt.Errorf("selected file must have .torrent extension") + } + + downloadRootRaw := strings.TrimSpace(ui.downloadDirEntry.Text) + if downloadRootRaw == "" { + return "", "", fmt.Errorf("select a download folder first") + } + downloadRoot := filepath.Clean(downloadRootRaw) + + dirInfo, err := os.Stat(downloadRoot) + if err != nil { + return "", "", fmt.Errorf("download folder is not accessible: %w", err) + } + if !dirInfo.IsDir() { + return "", "", fmt.Errorf("download path must be a folder") + } + + return path, downloadRoot, nil +} + func (ui *desktopUI) onBrowseOutputFolder() { fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil { @@ -224,12 +300,13 @@ func (ui *desktopUI) onBrowseOutputFolder() { return } ui.downloadDirEntry.SetText(uri.Path()) + ui.setError("") }, ui.window) fd.Show() } func (ui *desktopUI) refreshLoop() { - ticker := time.NewTicker(1200 * time.Millisecond) + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { @@ -244,73 +321,130 @@ func (ui *desktopUI) refreshLoop() { func (ui *desktopUI) updateFromStatus(status torrent.Status) { fyne.Do(func() { - ui.progress.SetValue(status.Progress) - ui.phaseLabel.SetText("Phase: " + status.Phase) - ui.phaseMetricLabel.SetText(status.Phase) - ui.nameLabel.SetText(status.Name) - ui.sizeLabel.SetText(strconv.Itoa(status.Length)) - ui.pieceLabel.SetText(strconv.Itoa(status.PieceLength)) - if status.PieceCount > 0 { - ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d", status.CompletedPieces, status.PieceCount)) - } else { - ui.pieceCountLabel.SetText("-") - } - ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes)) - ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed)) - ui.outputLabel.SetText(status.OutputPath) - ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount)) - - ui.mu.Lock() - ui.trackers = append(ui.trackers[:0], status.Trackers...) - ui.peers = append(ui.peers[:0], status.Peers...) - ui.files = append(ui.files[:0], status.Files...) - ui.mu.Unlock() - - ui.trackersTable.Refresh() - ui.peersTable.Refresh() - ui.filesTable.Refresh() - - if status.LastError != "" { - ui.errorLabel.SetText(status.LastError) - } else { - ui.errorLabel.SetText("") - } + ui.updateFromStatusOnUI(status) }) } -func formatBytesRate(bytesPerSec float64) string { - if bytesPerSec < 0 { - bytesPerSec = 0 +func (ui *desktopUI) updateFromStatusOnUI(status torrent.Status) { + progress := clampProgress(status.Progress) + phaseText := formatPhase(status.Phase) + + ui.progress.SetValue(progress) + ui.phaseLabel.SetText("Phase: " + phaseText) + ui.phaseMetricLabel.SetText(phaseText) + ui.statusSummaryLabel.SetText(formatProgressSummary(status)) + + if status.Name == "" { + ui.nameLabel.SetText("-") + } else { + ui.nameLabel.SetText(status.Name) } - units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s"} - value := bytesPerSec - unit := 0 - for value >= 1024 && unit < len(units)-1 { - value /= 1024 - unit++ + + if status.Length > 0 { + ui.sizeLabel.SetText(formatBytes(int64(status.Length))) + } else { + ui.sizeLabel.SetText("-") } - if unit == 0 { - return fmt.Sprintf("%.0f %s", value, units[unit]) + + if status.PieceLength > 0 { + ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength))) + } else { + ui.pieceLabel.SetText("-") } - return fmt.Sprintf("%.2f %s", value, units[unit]) + + if status.PieceCount > 0 { + pieceProgress := float64(status.CompletedPieces) / float64(status.PieceCount) + ui.pieceCountLabel.SetText(fmt.Sprintf("%d / %d (%s)", status.CompletedPieces, status.PieceCount, formatPercent(pieceProgress))) + } else { + ui.pieceCountLabel.SetText("-") + } + + ui.downloadedLabel.SetText(formatTransferred(status.DownloadedBytes, status.TotalBytes)) + ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed)) + if status.OutputPath == "" { + ui.outputLabel.SetText("-") + } else { + ui.outputLabel.SetText(status.OutputPath) + } + ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount)) + + ui.mu.Lock() + ui.trackers = append(ui.trackers[:0], status.Trackers...) + ui.peers = append(ui.peers[:0], status.Peers...) + ui.files = append(ui.files[:0], status.Files...) + manualErr := ui.inputError + ui.mu.Unlock() + + ui.trackersTable.Refresh() + ui.peersTable.Refresh() + ui.filesTable.Refresh() + + switch { + case status.LastError != "": + ui.errorLabel.SetText("Error: " + status.LastError) + case manualErr != "": + ui.errorLabel.SetText("Error: " + manualErr) + default: + ui.errorLabel.SetText("") + } + + ui.applyControlState(status.Phase) +} + +func (ui *desktopUI) applyControlState(phase string) { + ui.mu.RLock() + loading := ui.loading + ui.mu.RUnlock() + + busy := loading || isBusyPhase(phase) + if busy { + ui.startButton.Disable() + ui.stopButton.Enable() + ui.pathEntry.Disable() + ui.downloadDirEntry.Disable() + ui.browseButton.Disable() + ui.browseOutputButton.Disable() + return + } + + ui.startButton.Enable() + ui.stopButton.Disable() + ui.pathEntry.Enable() + ui.downloadDirEntry.Enable() + ui.browseButton.Enable() + ui.browseOutputButton.Enable() } func (ui *desktopUI) setMessage(msg string) { fyne.Do(func() { - ui.messageLabel.SetText(msg) + ui.setMessageOnUI(msg) }) } +func (ui *desktopUI) setMessageOnUI(msg string) { + ui.messageLabel.SetText(strings.TrimSpace(msg)) +} + func (ui *desktopUI) setError(msg string) { fyne.Do(func() { - if msg == "" { - ui.errorLabel.SetText("") - return - } - ui.errorLabel.SetText("Error: " + msg) + ui.setErrorOnUI(msg) }) } +func (ui *desktopUI) setErrorOnUI(msg string) { + msg = strings.TrimSpace(msg) + + ui.mu.Lock() + ui.inputError = msg + ui.mu.Unlock() + + if msg == "" { + ui.errorLabel.SetText("") + return + } + ui.errorLabel.SetText("Error: " + msg) +} + func (ui *desktopUI) newTrackersTable() *widget.Table { table := widget.NewTable( func() (int, int) { @@ -344,7 +478,7 @@ func (ui *desktopUI) newTrackersTable() *widget.Table { case 0: label.SetText(tr.URL) case 1: - label.SetText(tr.State) + label.SetText(formatPhase(tr.State)) case 2: label.SetText(strconv.Itoa(tr.PeerCount)) case 3: @@ -352,9 +486,9 @@ func (ui *desktopUI) newTrackersTable() *widget.Table { } }, ) - table.SetColumnWidth(0, 420) - table.SetColumnWidth(1, 90) - table.SetColumnWidth(2, 70) + table.SetColumnWidth(0, 440) + table.SetColumnWidth(1, 110) + table.SetColumnWidth(2, 80) table.SetColumnWidth(3, 420) return table } @@ -382,7 +516,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table { case 3: label.SetText("Pieces") case 4: - label.SetText("Source tracker") + label.SetText("Source Tracker") case 5: label.SetText("Error") } @@ -398,7 +532,7 @@ func (ui *desktopUI) newPeersTable() *widget.Table { case 1: label.SetText(strconv.Itoa(int(peer.Port))) case 2: - label.SetText(peer.State) + label.SetText(formatPhase(peer.State)) case 3: label.SetText(strconv.Itoa(peer.DownloadedPieces)) case 4: @@ -408,12 +542,12 @@ func (ui *desktopUI) newPeersTable() *widget.Table { } }, ) - table.SetColumnWidth(0, 170) + table.SetColumnWidth(0, 190) table.SetColumnWidth(1, 80) - table.SetColumnWidth(2, 100) - table.SetColumnWidth(3, 70) - table.SetColumnWidth(4, 310) - table.SetColumnWidth(5, 440) + table.SetColumnWidth(2, 120) + table.SetColumnWidth(3, 90) + table.SetColumnWidth(4, 320) + table.SetColumnWidth(5, 400) return table } @@ -446,11 +580,100 @@ func (ui *desktopUI) newFilesTable() *widget.Table { case 0: label.SetText(file.Path) case 1: - label.SetText(fmt.Sprintf("%d", file.Length)) + label.SetText(formatBytes(int64(file.Length))) } }, ) - table.SetColumnWidth(0, 780) - table.SetColumnWidth(1, 120) + table.SetColumnWidth(0, 820) + table.SetColumnWidth(1, 140) return table } + +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{"B", "KB", "MB", "GB", "TB"} + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + + suffix := "" + if perSecond { + suffix = "/s" + } + if unit == 0 { + return fmt.Sprintf("%.0f %s%s", value, units[unit], suffix) + } + return fmt.Sprintf("%.2f %s%s", value, units[unit], suffix) +} + +func formatTransferred(done, total int64) string { + if total <= 0 { + return fmt.Sprintf("%s / -", formatBytes(done)) + } + + pct := float64(done) / float64(total) + return fmt.Sprintf("%s / %s (%s)", formatBytes(done), formatBytes(total), formatPercent(pct)) +} + +func formatPercent(progress float64) string { + progress = clampProgress(progress) + return fmt.Sprintf("%.1f%%", progress*100) +} + +func formatProgressSummary(status torrent.Status) string { + return fmt.Sprintf( + "%s | %s | %s", + formatPercent(status.Progress), + formatTransferred(status.DownloadedBytes, status.TotalBytes), + formatBytesRate(status.DownloadSpeed), + ) +} + +func formatPhase(phase string) string { + phase = strings.TrimSpace(phase) + if phase == "" { + return "Idle" + } + + parts := strings.Split(phase, "_") + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + + return strings.Join(parts, " ") +} + +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 + } +}