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/layout" "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 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 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") w.Resize(fyne.NewSize(1200, 760)) 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"), } ui.pathEntry.SetPlaceHolder("/path/to/file.torrent") ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder") 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) metaGrid := container.NewGridWithColumns(4, wrapMetric("Name", ui.nameLabel), wrapMetric("Size (bytes)", ui.sizeLabel), wrapMetric("Piece length", ui.pieceLabel), wrapMetric("Completed pieces", ui.pieceCountLabel), wrapMetric("Downloaded", ui.downloadedLabel), wrapMetric("Speed", ui.speedLabel), wrapMetric("Output", ui.outputLabel), wrapMetric("Discovered peers", ui.peerCountLabel), wrapMetric("Phase", ui.phaseMetricLabel), ) 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)), ) statusColumn := container.NewVBox( ui.phaseLabel, ui.progress, ui.messageLabel, ui.errorLabel, ) content := container.NewBorder( topRows, nil, nil, nil, container.NewVBox( statusColumn, layout.NewSpacer(), metaGrid, layout.NewSpacer(), tabs, ), ) w.SetContent(content) w.SetOnClosed(func() { close(ui.stopRefresh) }) 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.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) if err != nil { ui.setError("download folder is not accessible: " + err.Error()) return } if !info.IsDir() { ui.setError("download path must be a folder") return } ui.setMessage("Loading torrent...") ui.setError("") go func() { err := ui.controller.StartTorrent(path, downloadRoot) status := ui.controller.Status() ui.updateFromStatus(status) if err != nil { ui.setError(err.Error()) } else { ui.setMessage("Loaded " + filepath.Base(path)) } }() } 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.window) fd.Show() } func (ui *desktopUI) refreshLoop() { ticker := time.NewTicker(1200 * time.Millisecond) 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.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("") } }) } func formatBytesRate(bytesPerSec float64) string { if bytesPerSec < 0 { bytesPerSec = 0 } 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 unit == 0 { return fmt.Sprintf("%.0f %s", value, units[unit]) } return fmt.Sprintf("%.2f %s", value, units[unit]) } func (ui *desktopUI) setMessage(msg string) { fyne.Do(func() { ui.messageLabel.SetText(msg) }) } func (ui *desktopUI) setError(msg string) { fyne.Do(func() { 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(tr.State) case 2: label.SetText(strconv.Itoa(tr.PeerCount)) case 3: label.SetText(tr.Error) } }, ) table.SetColumnWidth(0, 420) table.SetColumnWidth(1, 90) table.SetColumnWidth(2, 70) 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(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, 170) table.SetColumnWidth(1, 80) table.SetColumnWidth(2, 100) table.SetColumnWidth(3, 70) table.SetColumnWidth(4, 310) table.SetColumnWidth(5, 440) 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(fmt.Sprintf("%d", file.Length)) } }, ) table.SetColumnWidth(0, 780) table.SetColumnWidth(1, 120) return table }