jcloude/libs/fcrestore/tui/progressbar.go
2025-12-23 20:48:07 +08:00

216 lines
4.1 KiB
Go

package tui
import (
"fmt"
"os"
"strings"
"time"
"git.jingrow.com/charmbracelet/bubbles/progress"
tea "git.jingrow.com/charmbracelet/bubbletea"
"git.jingrow.com/charmbracelet/lipgloss"
)
var (
progressHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
)
const (
progressPadding = 2
progressMaxWidth = 80
)
type progressMsg struct {
ratio float64
uploadedGB string
totalGB string
}
type progressErrMsg struct{ err error }
type progressModel struct {
prompt string
progress progress.Model
err error
currentInfo string
quitting bool
onQuit func()
}
func finalPause() tea.Cmd {
return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg {
return nil
})
}
func (m progressModel) Init() tea.Cmd {
return tea.Batch(
tea.EnterAltScreen,
tea.HideCursor,
m.progress.Init(),
)
}
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlQ {
m.quitting = true
if m.onQuit != nil {
m.onQuit()
}
return m, tea.Batch(
tea.ExitAltScreen,
tea.ShowCursor,
tea.Quit,
)
}
return m, nil
case tea.WindowSizeMsg:
m.progress.Width = msg.Width - progressPadding*2 - 4
if m.progress.Width > progressMaxWidth {
m.progress.Width = progressMaxWidth
}
return m, nil
case progressErrMsg:
m.err = msg.err
return m, nil
case progressMsg:
var cmds []tea.Cmd
if msg.ratio >= 1.0 {
m.quitting = true
cmds = append(cmds, tea.Sequence(
finalPause(),
tea.ExitAltScreen,
tea.ShowCursor,
tea.Quit,
))
}
m.currentInfo = fmt.Sprintf("%s / %s", msg.uploadedGB, msg.totalGB)
cmds = append(cmds, m.progress.SetPercent(msg.ratio))
return m, tea.Batch(cmds...)
case progress.FrameMsg:
progressModel, cmd := m.progress.Update(msg)
m.progress = progressModel.(progress.Model)
return m, cmd
default:
return m, nil
}
}
func (m progressModel) View() string {
if m.quitting {
return ""
}
if m.err != nil {
return "\n " + titleStyle.Render(m.prompt) + "\n\n Error: " + m.err.Error() + "\n\n " +
progressHelpStyle("jcloude <ctrl+q> to quit") + "\n"
}
pad := strings.Repeat(" ", progressPadding)
progressView := m.progress.View()
return "\n " + titleStyle.Render(m.prompt) + "\n\n" +
pad + progressView + "\n" +
pad + m.currentInfo + "\n\n" +
pad + progressHelpStyle("jcloude <ctrl+q> to quit") + "\n"
}
type ProgressUI struct {
updateChan chan progressMsg
errChan chan error
DoneChan chan struct{}
program *tea.Program
}
func ShowProgress(prompt string, onQuit func()) *ProgressUI {
prog := progress.New(
progress.WithGradient("#FF87D7", "#FF87D7"),
)
m := progressModel{
prompt: prompt,
progress: prog,
onQuit: onQuit,
}
p := tea.NewProgram(m)
updateChan := make(chan progressMsg)
errChan := make(chan error)
doneChan := make(chan struct{})
go func() {
if _, err := p.Run(); err != nil {
// Ensure terminal state is restored on error
fmt.Fprint(os.Stderr, "\033[?25h\033[?1049l")
}
close(doneChan)
}()
go func() {
for {
select {
case msg := <-updateChan:
p.Send(msg)
case err := <-errChan:
p.Send(progressErrMsg{err})
case <-doneChan:
return
}
}
}()
return &ProgressUI{
updateChan: updateChan,
errChan: errChan,
DoneChan: doneChan,
program: p,
}
}
func (p *ProgressUI) Update(ratio float64, uploadedGB, totalGB string) {
select {
case <-p.DoneChan:
return
default:
p.updateChan <- progressMsg{
ratio: ratio,
uploadedGB: uploadedGB,
totalGB: totalGB,
}
}
}
func (p *ProgressUI) Error(err error) {
select {
case <-p.DoneChan:
return
default:
p.errChan <- err
}
}
func (p *ProgressUI) Done() {
// Send Ctrl+Q to quit
p.program.Send(tea.KeyMsg{Type: tea.KeyCtrlQ})
// Add timeout to prevent hanging
select {
case <-p.DoneChan:
case <-time.After(2 * time.Second):
// Force cleanup if graceful quit times out
fmt.Fprint(os.Stdout, "\033[?25h\033[?1049l")
}
// Extra terminal reset to be safe
fmt.Fprint(os.Stdout, "\033[?25h\033[?1049l")
}