package ui import ( "fmt" "os" "path/filepath" "strconv" "strings" "sync" "time" "fyne.io/fyne/v2" fyneapp "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/widget" appcore "github.com/veggiedefender/torrent-client/internal/app" "github.com/veggiedefender/torrent-client/internal/torrent" "github.com/veggiedefender/torrent-client/internal/torrentfile" ) type desktopUI struct { controller *appcore.Controller window fyne.Window pathEntry *widget.Entry downloadDirEntry *widget.Entry 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 filesTable *widget.Table stopRefresh chan struct{} } func Start(controller *appcore.Controller) { desktop := newDesktopUI(controller) desktop.window.ShowAndRun() } func newDesktopUI(controller *appcore.Controller) *desktopUI { a := fyneapp.NewWithID("github.com/veggiedefender/ztorrent") w := a.NewWindow("Ztorrent Desktop") w.Resize(fyne.NewSize(1280, 800)) ui := &desktopUI{ 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) } 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() 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", 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("Phase", ui.phaseMetricLabel), ) detailsCard := widget.NewCard("Details", "", metaGrid) ui.trackersTable = ui.newTrackersTable() ui.peersTable = ui.newPeersTable() ui.filesTable = ui.newFilesTable() tabs := container.NewAppTabs( container.NewTabItem("Trackers", container.NewMax(ui.trackersTable)), 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) 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() } go ui.refreshLoop() return ui } func wrapMetric(name string, value fyne.CanvasObject) fyne.CanvasObject { return container.NewVBox(widget.NewLabel(name), value) } func (ui *desktopUI) onBrowse() { filter := storage.NewExtensionFileFilter([]string{".torrent"}) fd := dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) { if err != nil { ui.setError(err.Error()) return } if reader == nil { return } defer reader.Close() ui.pathEntry.SetText(reader.URI().Path()) ui.setError("") }, ui.window) fd.SetFilter(filter) fd.Show() } func (ui *desktopUI) onLoad() { path, downloadRoot, err := ui.validateInputs() if err != nil { ui.setError(err.Error()) return } 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() { ui.controller.StopTorrent() status := ui.controller.Status() 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 { ui.setError(err.Error()) return } if uri == nil { return } ui.downloadDirEntry.SetText(uri.Path()) ui.setError("") }, ui.window) fd.Show() } func (ui *desktopUI) refreshLoop() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ui.stopRefresh: return case <-ticker.C: ui.updateFromStatus(ui.controller.Status()) } } } func (ui *desktopUI) updateFromStatus(status torrent.Status) { fyne.Do(func() { ui.updateFromStatusOnUI(status) }) } 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) } if status.Length > 0 { ui.sizeLabel.SetText(formatBytes(int64(status.Length))) } else { ui.sizeLabel.SetText("-") } if status.PieceLength > 0 { ui.pieceLabel.SetText(formatBytes(int64(status.PieceLength))) } else { ui.pieceLabel.SetText("-") } 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.setMessageOnUI(msg) }) } func (ui *desktopUI) setMessageOnUI(msg string) { ui.messageLabel.SetText(strings.TrimSpace(msg)) } func (ui *desktopUI) setError(msg string) { fyne.Do(func() { 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) { ui.mu.RLock() defer ui.mu.RUnlock() return len(ui.trackers) + 1, 4 }, func() fyne.CanvasObject { return widget.NewLabel("") }, func(id widget.TableCellID, obj fyne.CanvasObject) { label := obj.(*widget.Label) if id.Row == 0 { switch id.Col { case 0: label.SetText("Tracker") case 1: label.SetText("State") case 2: label.SetText("Peers") case 3: label.SetText("Error") } return } ui.mu.RLock() defer ui.mu.RUnlock() tr := ui.trackers[id.Row-1] switch id.Col { case 0: label.SetText(tr.URL) case 1: label.SetText(formatPhase(tr.State)) case 2: label.SetText(strconv.Itoa(tr.PeerCount)) case 3: label.SetText(tr.Error) } }, ) table.SetColumnWidth(0, 440) table.SetColumnWidth(1, 110) table.SetColumnWidth(2, 80) table.SetColumnWidth(3, 420) return table } func (ui *desktopUI) newPeersTable() *widget.Table { table := widget.NewTable( func() (int, int) { ui.mu.RLock() defer ui.mu.RUnlock() return len(ui.peers) + 1, 6 }, func() fyne.CanvasObject { return widget.NewLabel("") }, func(id widget.TableCellID, obj fyne.CanvasObject) { label := obj.(*widget.Label) if id.Row == 0 { switch id.Col { case 0: label.SetText("Address") case 1: label.SetText("Port") case 2: label.SetText("State") case 3: label.SetText("Pieces") case 4: label.SetText("Source Tracker") case 5: label.SetText("Error") } return } ui.mu.RLock() defer ui.mu.RUnlock() peer := ui.peers[id.Row-1] switch id.Col { case 0: label.SetText(peer.Address) case 1: label.SetText(strconv.Itoa(int(peer.Port))) case 2: label.SetText(formatPhase(peer.State)) case 3: label.SetText(strconv.Itoa(peer.DownloadedPieces)) case 4: label.SetText(peer.Source) case 5: label.SetText(peer.Error) } }, ) table.SetColumnWidth(0, 190) table.SetColumnWidth(1, 80) table.SetColumnWidth(2, 120) table.SetColumnWidth(3, 90) table.SetColumnWidth(4, 320) table.SetColumnWidth(5, 400) return table } func (ui *desktopUI) newFilesTable() *widget.Table { table := widget.NewTable( func() (int, int) { ui.mu.RLock() defer ui.mu.RUnlock() return len(ui.files) + 1, 2 }, func() fyne.CanvasObject { return widget.NewLabel("") }, func(id widget.TableCellID, obj fyne.CanvasObject) { label := obj.(*widget.Label) if id.Row == 0 { switch id.Col { case 0: label.SetText("File") case 1: label.SetText("Size") } return } ui.mu.RLock() defer ui.mu.RUnlock() file := ui.files[id.Row-1] switch id.Col { case 0: label.SetText(file.Path) case 1: label.SetText(formatBytes(int64(file.Length))) } }, ) 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 } }