diff --git a/internal/file/file.go b/internal/file/file.go index f831e1b..9ced7c7 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -121,22 +121,18 @@ func DecompressAndUnarchiveBytes(reader io.Reader) ([]string, int64, error) { return createdFiles, decompressedSize, nil } -// Traverses files and directories (recursively) for total size in bytes -func FilesTotalSize(files []*os.File) (int64, error) { +// Traverses a file or directory recursively for total size in bytes. +func FileSize(filePath string) (int64, error) { var size int64 - for _, file := range files { - err := filepath.Walk(file.Name(), func(_ string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - size += info.Size() - } - return err - }) + err := filepath.Walk(filePath, func(_ string, info os.FileInfo, err error) error { if err != nil { - return 0, err + return err } + size += info.Size() + return err + }) + if err != nil { + return 0, err } return size, nil } diff --git a/ui/constants.go b/ui/constants.go index ecdd543..d9858e2 100644 --- a/ui/constants.go +++ b/ui/constants.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "strings" "time" @@ -11,12 +10,14 @@ import ( ) const ( - PADDING = 2 + MARGIN = 2 + PADDING = 1 MAX_WIDTH = 80 PRIMARY_COLOR = "#B8BABA" SECONDARY_COLOR = "#626262" + DARK_COLOR = "#232323" ELEMENT_COLOR = "#EE9F40" - SECONDARY_ELEMENT_COLOR = "#EE9F70" + SECONDARY_ELEMENT_COLOR = "#e87d3e" ERROR_COLOR = "#CC0000" WARNING_COLOR = "#EE9F5C" CHECK_COLOR = "#34B233" @@ -27,21 +28,31 @@ const ( type KeyMap struct { Quit key.Binding CopyPassword key.Binding + FileListUp key.Binding + FileListDown key.Binding } func (k KeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.Quit, k.CopyPassword, + k.FileListUp, + k.FileListDown, } } func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Quit, k.CopyPassword}, + {k.Quit, k.CopyPassword, k.FileListUp, k.FileListDown}, } } +func NewProgressBar() progress.Model { + p := progress.New(progress.WithGradient(SECONDARY_ELEMENT_COLOR, ELEMENT_COLOR)) + p.PercentFormat = " %.2f%%" + return p +} + var Keys = KeyMap{ Quit: key.NewBinding( key.WithKeys("q", "esc", "ctrl+c"), @@ -49,30 +60,31 @@ var Keys = KeyMap{ ), CopyPassword: key.NewBinding( key.WithKeys("c"), - key.WithHelp("(c)", "copy password to clipboard"), + key.WithHelp("(c)", CopyKeyHelpText), + key.WithDisabled(), + ), + FileListUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("(↑/k)", "file summary up"), + key.WithDisabled(), + ), + FileListDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("(↓/j)", "file summary down"), key.WithDisabled(), ), } -var QuitKeys = []string{"ctrl+c", "q", "esc"} -var PadText = strings.Repeat(" ", PADDING) -var QuitCommandsHelpText = HelpStyle(fmt.Sprintf("(any of [%s] to abort)", strings.Join(QuitKeys, ", "))) - -func NewProgressBar() progress.Model { - p := progress.New(progress.WithGradient(SECONDARY_ELEMENT_COLOR, ELEMENT_COLOR)) - p.PercentFormat = " %.2f%%" - return p -} - -var baseStyle = lipgloss.NewStyle() +var PadText = strings.Repeat(" ", MARGIN) +var BaseStyle = lipgloss.NewStyle() -var InfoStyle = baseStyle.Copy().Foreground(lipgloss.Color(PRIMARY_COLOR)).Render -var HelpStyle = baseStyle.Copy().Foreground(lipgloss.Color(SECONDARY_COLOR)).Render -var ItalicText = baseStyle.Copy().Italic(true).Render -var BoldText = baseStyle.Copy().Bold(true).Render -var ErrorText = baseStyle.Copy().Foreground(lipgloss.Color(ERROR_COLOR)).Render -var WarningText = baseStyle.Copy().Foreground(lipgloss.Color(WARNING_COLOR)).Render -var CheckText = baseStyle.Copy().Foreground(lipgloss.Color(CHECK_COLOR)).Render +var InfoStyle = BaseStyle.Copy().Foreground(lipgloss.Color(PRIMARY_COLOR)).Render +var HelpStyle = BaseStyle.Copy().Foreground(lipgloss.Color(SECONDARY_COLOR)).Render +var ItalicText = BaseStyle.Copy().Italic(true).Render +var BoldText = BaseStyle.Copy().Bold(true).Render +var ErrorText = BaseStyle.Copy().Foreground(lipgloss.Color(ERROR_COLOR)).Render +var WarningText = BaseStyle.Copy().Foreground(lipgloss.Color(WARNING_COLOR)).Render +var CheckText = BaseStyle.Copy().Foreground(lipgloss.Color(CHECK_COLOR)).Render -var CopyKeyHelpText = baseStyle.Render("copy password to clipboard") -var CopyKeyActiveHelpText = CheckText("✓") + HelpStyle(" copied password to clipboard") +var CopyKeyHelpText = BaseStyle.Render("password → clipboard") +var CopyKeyActiveHelpText = CheckText("✓") + HelpStyle(" password → clipboard") diff --git a/ui/filetable/filetable.go b/ui/filetable/filetable.go new file mode 100644 index 0000000..d23f701 --- /dev/null +++ b/ui/filetable/filetable.go @@ -0,0 +1,166 @@ +package filetable + +import ( + "math" + + "github.com/SpatiumPortae/portal/internal/file" + "github.com/SpatiumPortae/portal/ui" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +const ( + defaultMaxTableHeight = 4 + nameColumnWidthFactor float64 = 0.8 + sizeColumnWidthFactor float64 = 1 - nameColumnWidthFactor +) + +var fileTableStyle = ui.BaseStyle.Copy(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ui.SECONDARY_COLOR)). + MarginLeft(ui.MARGIN) + +type Option func(m *Model) + +type fileRow struct { + path string + formattedSize string +} + +type Model struct { + Width int + MaxHeight int + rows []fileRow + table table.Model + tableStyles table.Styles +} + +func New(opts ...Option) Model { + m := Model{ + MaxHeight: defaultMaxTableHeight, + table: table.New( + table.WithFocused(true), + table.WithHeight(defaultMaxTableHeight), + ), + } + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(ui.SECONDARY_COLOR)). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color(ui.DARK_COLOR)). + Background(lipgloss.Color(ui.SECONDARY_ELEMENT_COLOR)). + Bold(false) + m.tableStyles = s + m.table.SetStyles(m.tableStyles) + + m.updateColumns() + for _, opt := range opts { + opt(&m) + } + + return m +} + +func (m *Model) SetFiles(filePaths []string) { + for _, filePath := range filePaths { + size, err := file.FileSize(filePath) + var formattedSize string + if err != nil { + formattedSize = "N/A" + } else { + formattedSize = ui.ByteCountSI(size) + } + m.rows = append(m.rows, fileRow{path: filePath, formattedSize: formattedSize}) + } + m.table.SetHeight(int(math.Min(float64(m.MaxHeight), float64(len(filePaths))))) + m.updateColumns() + m.updateRows() +} + +func WithFiles(filePaths []string) Option { + return func(m *Model) { + m.SetFiles(filePaths) + } +} + +func (m *Model) SetMaxHeight(height int) { + m.MaxHeight = height +} + +func WithMaxHeight(height int) Option { + return func(m *Model) { + m.SetMaxHeight(height) + m.updateRows() + } +} + +func (m *Model) getMaxWidth() int { + return int(math.Min(ui.MAX_WIDTH-2*ui.MARGIN, float64(m.Width))) +} + +func (m *Model) updateColumns() { + w := m.getMaxWidth() + m.table.SetColumns([]table.Column{ + {Title: "File", Width: int(float64(w) * nameColumnWidthFactor)}, + {Title: "Size", Width: int(float64(w) * sizeColumnWidthFactor)}, + }) +} + +func (m *Model) updateRows() { + var tableRows []table.Row + maxFilePathWidth := int(float64(m.getMaxWidth()) * nameColumnWidthFactor) + for _, row := range m.rows { + path := row.path + // truncate overflowing file paths from the left + if len(path) > maxFilePathWidth { + overflowingLength := len(path) - maxFilePathWidth + path = runewidth.TruncateLeft(path, overflowingLength+1, "…") + } + tableRows = append(tableRows, table.Row{path, row.formattedSize}) + } + m.table.SetRows(tableRows) +} + +func (Model) Init() tea.Cmd { + return nil +} + +func (m Model) Finalize() tea.Model { + m.table.Blur() + + s := m.tableStyles + s.Selected = s.Selected.UnsetBackground().UnsetForeground() + m.table.SetStyles(s) + + return m +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.Width = msg.Width - 2*ui.MARGIN - 4 + if m.Width > ui.MAX_WIDTH { + m.Width = ui.MAX_WIDTH + } + m.updateColumns() + m.updateRows() + return m, nil + + } + + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m Model) View() string { + return fileTableStyle.Render(m.table.View()) + "\n\n" +} diff --git a/ui/receiver/receiver.go b/ui/receiver/receiver.go index 6f12d7f..013c1fc 100644 --- a/ui/receiver/receiver.go +++ b/ui/receiver/receiver.go @@ -2,6 +2,7 @@ package receiver import ( "fmt" + "math" "os" "time" @@ -11,14 +12,13 @@ import ( "github.com/SpatiumPortae/portal/internal/semver" "github.com/SpatiumPortae/portal/protocol/transfer" "github.com/SpatiumPortae/portal/ui" + "github.com/SpatiumPortae/portal/ui/filetable" "github.com/SpatiumPortae/portal/ui/transferprogress" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/indent" - "github.com/muesli/reflow/wordwrap" ) // ------------------------------------------------------ Ui State ----------------------------------------------------- @@ -79,6 +79,7 @@ type model struct { width int spinner spinner.Model transferProgress transferprogress.Model + fileTable filetable.Model help help.Model keys ui.KeyMap } @@ -88,6 +89,7 @@ func New(addr string, password string, opts ...Option) *tea.Program { m := model{ transferProgress: transferprogress.New(), msgs: make(chan interface{}, 10), + fileTable: filetable.New(), password: password, rendezvousAddr: addr, help: help.New(), @@ -174,12 +176,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { time.Since(m.transferProgress.TransferStartTime).Round(time.Millisecond).String(), ui.ByteCountSI(m.transferProgress.TransferSpeedEstimateBps), ) + + m.fileTable.SetMaxHeight(math.MaxInt) + m.fileTable = m.fileTable.Finalize().(filetable.Model) return m, ui.TaskCmd(message, tea.Batch(m.spinner.Tick, decompressCmd(msg.temp))) case decompressionDoneMsg: m.state = showFinished m.receivedFiles = msg.filenames m.decompressedPayloadSize = msg.decompressedPayloadSize + + m.fileTable.SetFiles(m.receivedFiles) return m, ui.QuitCmd() case ui.ErrorMsg: @@ -192,13 +199,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit } - return m, nil + + fileTableModel, fileTableCmd := m.fileTable.Update(msg) + m.fileTable = fileTableModel.(filetable.Model) + + return m, fileTableCmd case tea.WindowSizeMsg: m.width = msg.Width - transferProgressModel, cmd := m.transferProgress.Update(msg) + transferProgressModel, transferProgressCmd := m.transferProgress.Update(msg) m.transferProgress = transferProgressModel.(transferprogress.Model) - return m, cmd + fileTableModel, fileTableCmd := m.fileTable.Update(msg) + m.fileTable = fileTableModel.(filetable.Model) + return m, tea.Batch(transferProgressCmd, fileTableCmd) default: var cmd tea.Cmd @@ -240,18 +253,15 @@ func (m model) View() string { ui.PadText + m.help.View(m.keys) + "\n\n" case showFinished: - indentedWrappedFiles := indent.String(fmt.Sprintf("Received: %s", wordwrap.String(ui.ItalicText(ui.TopLevelFilesText(m.receivedFiles)), ui.MAX_WIDTH)), ui.PADDING) - - var oneOrMoreFiles string + oneOrMoreFiles := "object" if len(m.receivedFiles) > 1 { - oneOrMoreFiles = "objects" - } else { - oneOrMoreFiles = "object" + oneOrMoreFiles += "s" } - finishedText := fmt.Sprintf("Received %d %s (%s compressed)\n\n%s", len(m.receivedFiles), oneOrMoreFiles, ui.ByteCountSI(m.payloadSize), indentedWrappedFiles) + finishedText := fmt.Sprintf("Received %d %s (%s compressed)", len(m.receivedFiles), oneOrMoreFiles, ui.ByteCountSI(m.payloadSize)) return ui.PadText + ui.LogSeparator(m.width) + ui.PadText + ui.InfoStyle(finishedText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + ui.PadText + m.transferProgress.View() + "\n\n" + + m.fileTable.View() case showError: return ui.ErrorText(m.errorMessage) @@ -342,6 +352,6 @@ func (m *model) resetSpinner() { m.spinner.Spinner = ui.CompressingSpinner } if m.state == showReceivingProgress { - m.spinner.Spinner = ui.TransferSpinner + m.spinner.Spinner = ui.ReceivingSpinner } } diff --git a/ui/sender/sender.go b/ui/sender/sender.go index df15047..05d0e2c 100644 --- a/ui/sender/sender.go +++ b/ui/sender/sender.go @@ -13,6 +13,7 @@ import ( "github.com/SpatiumPortae/portal/internal/sender" "github.com/SpatiumPortae/portal/protocol/transfer" "github.com/SpatiumPortae/portal/ui" + "github.com/SpatiumPortae/portal/ui/filetable" "github.com/SpatiumPortae/portal/ui/transferprogress" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/help" @@ -21,8 +22,6 @@ import ( "github.com/charmbracelet/bubbles/timer" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/muesli/reflow/indent" - "github.com/muesli/reflow/wordwrap" "github.com/pkg/errors" "golang.org/x/exp/slices" ) @@ -88,6 +87,7 @@ type model struct { width int spinner spinner.Model transferProgress transferprogress.Model + fileTable filetable.Model help help.Model keys ui.KeyMap copyMessageTimer timer.Model @@ -97,6 +97,7 @@ type model struct { func New(filenames []string, addr string, opts ...Option) *tea.Program { m := model{ transferProgress: transferprogress.New(), + fileTable: filetable.New(filetable.WithFiles(filenames)), fileNames: filenames, rendezvousAddr: addr, msgs: make(chan interface{}, 10), @@ -104,6 +105,8 @@ func New(filenames []string, addr string, opts ...Option) *tea.Program { keys: ui.Keys, copyMessageTimer: timer.NewWithInterval(ui.TEMP_UI_MESSAGE_DURATION, 100*time.Millisecond), } + m.keys.FileListUp.SetEnabled(true) + m.keys.FileListDown.SetEnabled(true) for _, opt := range opts { opt(&m) } @@ -235,6 +238,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { time.Since(m.transferProgress.TransferStartTime).Round(time.Millisecond).String(), ui.ByteCountSI(m.transferProgress.TransferSpeedEstimateBps), ) + + m.fileTable = m.fileTable.Finalize().(filetable.Model) return m, ui.TaskCmd(message, ui.QuitCmd()) case ui.ErrorMsg: @@ -256,13 +261,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } } - return m, nil + + fileTableModel, fileTableCmd := m.fileTable.Update(msg) + m.fileTable = fileTableModel.(filetable.Model) + + return m, fileTableCmd case tea.WindowSizeMsg: m.width = msg.Width - transferProgressModel, cmd := m.transferProgress.Update(msg) + transferProgressModel, transferProgressCmd := m.transferProgress.Update(msg) m.transferProgress = transferProgressModel.(transferprogress.Model) - return m, cmd + fileTableModel, fileTableCmd := m.fileTable.Update(msg) + m.fileTable = fileTableModel.(filetable.Model) + return m, tea.Batch(transferProgressCmd, fileTableCmd) default: var cmd tea.Cmd @@ -285,8 +296,6 @@ func (m model) View() string { } slices.Sort(m.fileNames) - filesToSend := ui.ItalicText(strings.Join(m.fileNames, ", ")) - builder := strings.Builder{} builder.WriteString(fmt.Sprintf("%s %d object", readiness, len(m.fileNames))) if len(m.fileNames) > 1 { @@ -305,30 +314,30 @@ func (m model) View() string { case transfer.Unknown: } - indentedWrappedFiles := indent.String(wordwrap.String(fmt.Sprintf("Sending: %s", filesToSend), ui.MAX_WIDTH), ui.PADDING) - builder.WriteString("\n\n") - builder.WriteString(indentedWrappedFiles) - fileInfoText := builder.String() + statusText := builder.String() switch m.state { case showPassword: return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(fileInfoText) + "\n\n" + + ui.PadText + ui.InfoStyle(statusText) + "\n\n" + ui.PadText + ui.InfoStyle("On the receiving end, run:") + "\n" + ui.PadText + ui.InfoStyle(fmt.Sprintf("portal receive %s", m.password)) + "\n\n" + + m.fileTable.View() + ui.PadText + m.help.View(m.keys) + "\n\n" case showSendingProgress: return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(fileInfoText) + "\n\n" + + ui.PadText + ui.InfoStyle(statusText) + "\n\n" + ui.PadText + m.transferProgress.View() + "\n\n" + + m.fileTable.View() + ui.PadText + m.help.View(m.keys) + "\n\n" case showFinished: finishedText := fmt.Sprintf("Sent %d object(s) (%s compressed)", len(m.fileNames), ui.ByteCountSI(m.payloadSize)) return ui.PadText + ui.LogSeparator(m.width) + ui.PadText + ui.InfoStyle(finishedText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + ui.PadText + m.transferProgress.View() + "\n\n" + + m.fileTable.View() case showError: return ui.ErrorText(m.errorMessage) @@ -381,11 +390,17 @@ func readFilesCmd(paths []string) tea.Cmd { if err != nil { return ui.ErrorMsg(err) } - size, err := file.FilesTotalSize(files) - if err != nil { - return ui.ErrorMsg(err) + + var totalSize int64 + for _, f := range files { + size, err := file.FileSize(f.Name()) + if err != nil { + return ui.ErrorMsg(err) + } + totalSize += size } - return fileReadMsg{files: files, size: size} + + return fileReadMsg{files: files, size: totalSize} } } diff --git a/ui/transferprogress/transferprogress.go b/ui/transferprogress/transferprogress.go index b3b6370..75994e9 100644 --- a/ui/transferprogress/transferprogress.go +++ b/ui/transferprogress/transferprogress.go @@ -3,6 +3,7 @@ package transferprogress import ( "fmt" "math" + "strings" "time" "github.com/SpatiumPortae/portal/ui" @@ -46,12 +47,22 @@ func (Model) Init() tea.Cmd { } func (m Model) View() string { - bytesProgress := fmt.Sprintf("(%s/%s, %s/s)", - ui.ByteCountSI(m.bytesTransferred), ui.ByteCountSI(m.PayloadSize), ui.ByteCountSI(m.TransferSpeedEstimateBps)) - eta := fmt.Sprintf("%v remaining", m.estimatedRemainingDuration.Round(time.Second).String()) + bytesProgress := strings.Builder{} + bytesProgress.WriteRune('(') + bytesProgress.WriteString(fmt.Sprintf("%s/%s", ui.ByteCountSI(m.bytesTransferred), ui.ByteCountSI(m.PayloadSize))) + if m.TransferSpeedEstimateBps > 0 { + bytesProgress.WriteString(fmt.Sprintf(", %s/s", ui.ByteCountSI(m.TransferSpeedEstimateBps))) + } + bytesProgress.WriteRune(')') + + secondsRemaining := m.estimatedRemainingDuration.Round(time.Second) + var eta string + if secondsRemaining > 0 { + eta = fmt.Sprintf("%v remaining", secondsRemaining.String()) + } progressBar := m.progressBar.ViewAs(m.progress) - return bytesProgress + "\t\t" + eta + "\n\n" + + return bytesProgress.String() + "\t\t" + eta + "\n\n" + ui.PadText + progressBar } @@ -59,7 +70,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.Width = msg.Width - 2*ui.PADDING - 4 + m.Width = msg.Width - 2*ui.MARGIN - 4 if m.Width > ui.MAX_WIDTH { m.Width = ui.MAX_WIDTH } diff --git a/ui/ui.go b/ui/ui.go index 1199b84..54fc124 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -49,21 +49,21 @@ var CompressingSpinner = spinner.Spinner{ } var TransferSpinner = spinner.Spinner{ - Frames: []string{"» ", "»» ", "»»»", " "}, + Frames: []string{"⇢┄┄", "┄⇢┄", "┄┄⇢", "┄┄┄"}, FPS: time.Millisecond * 400, } var ReceivingSpinner = spinner.Spinner{ - Frames: []string{" ", " «", " ««", "«««"}, + Frames: []string{"┄┄┄", "┄┄⇠", "┄⇠┄", "⇠┄┄"}, FPS: time.Second / 2, } // --------------------------------------------------- Shared Helpers -------------------------------------------------- func LogSeparator(width int) string { - paddedWidth := math.Max(0, float64(width)-2*PADDING) + paddedWidth := math.Max(0, float64(width)-2*MARGIN) return fmt.Sprintf("%s\n\n", - baseStyle.Copy(). + BaseStyle.Copy(). Foreground(lipgloss.Color(SECONDARY_COLOR)). Render(strings.Repeat("─", int(math.Min(MAX_WIDTH, paddedWidth))))) }