diff --git a/config.go b/config.go index 41e851a..b0b04d4 100644 --- a/config.go +++ b/config.go @@ -17,6 +17,7 @@ type Config struct { Help bool `toml:"help"` Styles Styles `toml:"styles"` Signs Signs `toml:"signs"` + Context int `toml:"context"` } type Styles struct { @@ -45,6 +46,7 @@ type Signs struct { Error string `toml:"error"` Warning string `toml:"warning"` System string `toml:"system"` + HorizontalBar string `toml:"line"` Separator string `toml:"separator"` PassiveIndent string `toml:"passiveIndent"` ActiveIndent string `toml:"activeIndent"` @@ -216,6 +218,7 @@ func DefaultConfig() Config { Help: true, Mode: "build", Package: "./cmd/gust", + Context: 0, Styles: Styles{ Mode: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("5"))}, Success: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("2"))}, @@ -224,7 +227,7 @@ func DefaultConfig() Config { Warning: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, File: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("4"))}, Duration: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, - Separator: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("8"))}, + Separator: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("12"))}, Line: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, Stdout: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, Stderr: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))}, @@ -239,6 +242,7 @@ func DefaultConfig() Config { Error: "err", Warning: "wrn", System: "sys", + HorizontalBar: "─", Separator: " · ", PassiveIndent: " | ", ActiveIndent: " > ", diff --git a/flake.nix b/flake.nix index d1a1656..ed98137 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,7 @@ version = "0.1.0"; src = gitignoreSource ./.; subPackages = ["cmd/gust"]; - vendorHash = "sha256-BXjnNZMITu8uC/B5hFv41QRXtOv5tDMBRySU4dcyu6E="; + vendorHash = "sha256-XKydHm/KX6sGU5ndQTNsIqaF0BpHew9NB+Q7gcAKBh4="; env.CGO_ENABLED = 0; }; }; diff --git a/model.go b/model.go index 06dccb9..3fb1762 100644 --- a/model.go +++ b/model.go @@ -7,11 +7,14 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "time" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/fsnotify/fsnotify" ) @@ -121,14 +124,12 @@ func (p *CommandProcess) CheckStatus() *os.ProcessState { // Model represents the application state type Model struct { - stderr string - stdout string + stdio []IoLine messages []CompilerMessage fs Fs w *fsnotify.Watcher config Config - context int status Status systemErrors []error @@ -137,19 +138,44 @@ type Model struct { start *time.Time end *time.Time + viewport viewport.Model + ready bool width, height int } +type Io []IoLine +type IoLine struct { + Kind IoKind + Line string +} +type IoKind int + +const ( + Stdout IoKind = iota + Stderr +) + +func (m Model) stderr() string { + var b strings.Builder + + for _, l := range m.stdio { + if l.Kind == Stderr { + b.WriteString(l.Line) + } + } + + return b.String() +} + func NewModel(watcher *fsnotify.Watcher, config Config, options ...func(*Config)) Model { for _, o := range options { o(&config) } return Model{ - w: watcher, - status: NewStatus(), - context: 1, - config: config, + w: watcher, + status: NewStatus(), + config: config, } } @@ -158,10 +184,24 @@ func (m Model) Init() tea.Cmd { } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - return m, nil + headerHeight := lipgloss.Height(m.viewStatus()) + footerHeight := lipgloss.Height(m.viewHelp()) + verticalMarginHeight := headerHeight + footerHeight + + m.width, m.height = msg.Width, msg.Height-verticalMarginHeight + if !m.ready { + m.viewport = viewport.New(m.width, m.height) + m.viewport.YPosition = headerHeight + m.ready = true + } else { + m.viewport.Width, m.viewport.Height = m.width, m.height + } + + return m, updateViewport case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": @@ -190,7 +230,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.config.Help = !m.config.Help return m, nil default: - return m, nil + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } case StatusKind, spinner.TickMsg: var cmd tea.Cmd @@ -200,16 +241,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m, cmd := m.reload() return m, cmd case stdoutMsg: - m.stdout = m.stdout + msg.line + m.stdio = append(m.stdio, IoLine{ + Kind: Stdout, + Line: msg.line, + }) return m, m.checkStdout() case stderrMsg: - m.stderr = m.stderr + msg.line + m.stdio = append(m.stdio, IoLine{ + Kind: Stderr, + Line: msg.line, + }) if m.config.Mode == "build" { - messages := Parse(m.stderr) + messages := Parse(m.stderr()) fs := Fs{} if !m.config.Summarized { fs = BuildFs(messages) - fs.PopulateContext(messages, m.context) + fs.PopulateContext(messages, m.config.Context) } m.messages = messages m.fs = fs @@ -218,7 +265,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case processState: end := time.Now() if msg.state == nil { - return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport) } else if msg.state.Success() { m.end = &end return m, Success.Cmd() @@ -226,8 +273,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.end = &end return m, Error.Cmd() } else { - return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport) } + case updateViewportMsg: + m.viewport.SetContent(m.viewBody()) + if m.config.Mode == "build" { + m.viewport.GotoTop() + } else { + m.viewport.GotoBottom() + } + return m, nil default: return m, nil } @@ -239,6 +294,12 @@ func reload() tea.Msg { return reloadMsg{} } +type updateViewportMsg struct{} + +func updateViewport() tea.Msg { + return updateViewportMsg{} +} + type modifiedMsg struct{} type watcherrMsg struct{} @@ -279,8 +340,7 @@ func (m Model) reload() (Model, tea.Cmd) { } // Reset state - m.stderr = "" - m.stdout = "" + m.stdio = []IoLine{} m.messages = nil m.fs = nil @@ -303,7 +363,7 @@ func (m Model) reload() (Model, tea.Cmd) { return m, Error.Cmd() } - return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr()) + return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr(), updateViewport) } type stdoutMsg struct { diff --git a/view.go b/view.go index a5df5d8..05cc695 100644 --- a/view.go +++ b/view.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" @@ -12,15 +13,28 @@ import ( func (m Model) View() string { var b strings.Builder - style := m.config.Styles - sign := m.config.Signs b.WriteString(m.viewStatus()) + m.viewport.SetContent(m.viewBody()) + b.WriteString(m.viewport.View()) + + b.WriteString("\n") + + b.WriteString(m.viewHelp()) + + return b.String() +} + +func (m Model) viewBody() string { + style := m.config.Styles + sign := m.config.Signs + + var b strings.Builder if m.config.Mode == "build" { - m.viewBuild(&b) + b.WriteString(m.viewBuild()) } else { - m.viewRun(&b) + b.WriteString(m.viewRun()) } for _, e := range m.systemErrors { @@ -31,15 +45,11 @@ func (m Model) View() string { } b.WriteString("\n") - - if m.config.Help { - b.WriteString(m.viewHelp()) - } - return b.String() } -func (m Model) viewBuild(b *strings.Builder) { +func (m Model) viewBuild() string { + var b strings.Builder maxLineLen := 1 maxColLen := 1 style := m.config.Styles @@ -128,28 +138,28 @@ func (m Model) viewBuild(b *strings.Builder) { b.WriteString("\n") } + + return b.String() } -func (m Model) viewRun(b *strings.Builder) { +func (m Model) viewRun() string { + var b strings.Builder style := m.config.Styles - width := m.width - if m.stdout != "" && m.stderr != "" { - width = width / 2 - b.WriteString(style.Stdout.Width(width).Render("stdout")) - b.WriteString(style.Stdout.Width(width).Render("stderr")) - } else if m.stdout != "" { - b.WriteString(style.Stdout.Render("stdout")) - } else if m.stderr != "" { - b.WriteString(style.Stdout.Render("stderr")) + for _, l := range m.stdio { + switch l.Kind { + case Stdout: + b.WriteString(style.Stdout.Render("stdout")) + case Stderr: + b.WriteString(style.Stderr.Render("stderr")) + default: + b.WriteString(strings.Repeat(" ", 6)) + } + b.WriteString(" ") + b.WriteString(l.Line) } - leftSide := fmt.Sprintf("\n%s", m.stdout) - rightSide := fmt.Sprintf("\n%s", m.stderr) - - columns := lipgloss.JoinHorizontal(lipgloss.Top, leftSide, rightSide) - - b.WriteString(columns) + return b.String() } func (m Model) viewStatus() string { @@ -210,12 +220,19 @@ func (m Model) viewStatus() string { b.WriteString(style.Duration.Render(time.Now().Sub(*m.start).String())) } + line := strings.Repeat(sign.HorizontalBar, max(0, (m.viewport.Width-lipgloss.Width(b.String()))/utf8.RuneCountInString(sign.HorizontalBar))) + b.WriteString(style.Separator.Render(line)) + b.WriteString("\n") return b.String() } func (m Model) viewHelp() string { + if !m.config.Help { + return "" + } + var b strings.Builder style := m.config.Styles sign := m.config.Signs @@ -247,5 +264,10 @@ func (m Model) viewHelp() string { b.WriteString("h/? ") b.WriteString(style.Separator.Render("hide help")) - return lipgloss.NewStyle().PaddingTop(2).Render(b.String()) + percentageBox := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) + line := strings.Repeat(sign.HorizontalBar, max(0, (m.viewport.Width-lipgloss.Width(b.String())-lipgloss.Width(percentageBox))/utf8.RuneCountInString(sign.HorizontalBar))) + b.WriteString(style.Separator.Render(line)) + b.WriteString(style.Duration.Render(percentageBox)) + + return lipgloss.NewStyle().Render(b.String()) }