Skip to content

Commit 40d4c64

Browse files
committed
feat: TUI refresh with new header, removed footer, and overlays
1 parent 5fa3b06 commit 40d4c64

26 files changed

Lines changed: 1062 additions & 258 deletions

.claude/settings.local.json

Lines changed: 0 additions & 8 deletions
This file was deleted.

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
env:
5252
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
5353
with:
54-
file: coverage.out
54+
files: coverage.out
5555
fail_ci_if_error: true
5656

5757
validate-generated:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ coverage.txt
1111
*.sarif
1212
.idea
1313

14+
.claude/settings.local.json

application.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package tuikit
22

33
type Application struct {
4-
Name string
4+
Name string
5+
Version string
56

67
stateKey string
78
stateVal string
8-
notice string
99
loadingMsg string
1010
}
1111

@@ -44,9 +44,9 @@ func WithState(key, val string) ApplicationOption {
4444
}
4545
}
4646

47-
func WithNotice(notice string) ApplicationOption {
47+
func WithVersion(version string) ApplicationOption {
4848
return func(a *Application) {
49-
a.notice = notice
49+
a.Version = version
5050
}
5151
}
5252

container.go

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
tea "charm.land/bubbletea/v2"
1313
"charm.land/lipgloss/v2"
1414

15+
"github.com/flowexec/tuikit/overlay"
1516
"github.com/flowexec/tuikit/themes"
1617
"github.com/flowexec/tuikit/types"
1718
"github.com/flowexec/tuikit/views"
@@ -20,11 +21,18 @@ import (
2021
type View interface {
2122
tea.Model
2223

23-
ShowFooter() bool
24-
HelpMsg() string
24+
HelpBindings() []themes.HelpKey
2525
Type() string
2626
}
2727

28+
// InputCapturer is an optional interface views can implement to signal
29+
// that they are actively capturing keyboard input (e.g. a filter field).
30+
// When CapturingInput returns true, the Container forwards all keys
31+
// to the view instead of handling them globally.
32+
type InputCapturer interface {
33+
CapturingInput() bool
34+
}
35+
2836
type Container struct {
2937
ctx context.Context
3038
cancel context.CancelFunc
@@ -34,7 +42,8 @@ type Container struct {
3442
sendFunc func(msg tea.Msg) // Temporary hack for testing
3543

3644
previousView, currentView, nextView View
37-
showHelp bool
45+
help *overlay.HelpPopup
46+
toasts *overlay.ToastManager
3847
finalizing *chan struct{}
3948

4049
viewMu sync.RWMutex
@@ -80,6 +89,8 @@ func NewContainer(
8089
if c.render.Theme == nil {
8190
c.render.Theme = themes.EverforestTheme()
8291
}
92+
c.help = overlay.NewHelpPopup(c.render.Theme)
93+
c.toasts = overlay.NewToastManager(c.render.Theme)
8394

8495
return c, nil
8596
}
@@ -158,7 +169,7 @@ func (c *Container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
158169
Width: msg.Width,
159170
Height: msg.Height,
160171
ContentWidth: msg.Width,
161-
ContentHeight: msg.Height - (themes.HeaderHeight + themes.FooterHeight),
172+
ContentHeight: msg.Height - themes.HeaderHeight,
162173
Theme: c.render.Theme,
163174
}
164175
c.stateMu.Unlock()
@@ -183,12 +194,29 @@ func (c *Container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183194
c.HandleError(err)
184195
}
185196
case tea.KeyPressMsg:
197+
if c.help.Visible() {
198+
fwdMsg = nil
199+
switch msg.String() {
200+
case "esc", "h", "?":
201+
c.help.Toggle()
202+
}
203+
break
204+
}
186205
if c.CurrentView().Type() == views.FormViewType {
187206
fwdMsg = nil
188207
_, cmd := c.CurrentView().Update(msg)
189208
cmds = append(cmds, cmd)
190209
break
191210
}
211+
// When the view is capturing input (e.g. filter field), only
212+
// handle hard exit keys; forward everything else to the view.
213+
if ic, ok := c.CurrentView().(InputCapturer); ok && ic.CapturingInput() {
214+
if msg.String() == "ctrl+c" {
215+
c.CurrentView().Update(tea.Quit())
216+
return c, tea.Quit
217+
}
218+
break
219+
}
192220
switch msg.String() {
193221
case "ctrl+c", "q":
194222
c.CurrentView().Update(tea.Quit())
@@ -203,13 +231,13 @@ func (c *Container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203231
}
204232
return c, nil
205233
}
206-
case "h":
207-
if !c.CurrentView().ShowFooter() {
208-
break
209-
}
234+
case "h", "?":
210235
fwdMsg = nil
211-
c.showHelp = !c.showHelp
236+
c.help.SetViewKeys(c.CurrentView().HelpBindings())
237+
c.help.Toggle()
212238
}
239+
case types.ToastDismissMsg:
240+
c.toasts.Dismiss(msg.ID)
213241
case types.TickMsg:
214242
if c.Ready() && c.CurrentView().Type() == views.LoadingViewType && c.nextView != nil {
215243
c.currentView = c.nextView
@@ -227,45 +255,47 @@ func (c *Container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
227255
}
228256

229257
func (c *Container) View() tea.View {
230-
var footer string
231-
var footerPrefix string
232-
233258
if !c.Ready() && c.CurrentView().Type() != views.LoadingViewType {
234259
return tea.NewView("")
235260
}
236-
switch {
237-
case c.CurrentView().Type() == views.FrameViewType:
261+
if c.CurrentView().Type() == views.FrameViewType {
238262
return c.CurrentView().View()
239-
case c.CurrentView().Type() == views.LoadingViewType, c.CurrentView().Type() == views.FormViewType:
240-
footer = ""
241-
case !c.CurrentView().ShowFooter():
242-
footer = c.render.Theme.RenderFooter(c.app.notice, c.render.Width)
243-
case c.CurrentView().ShowFooter() && c.showHelp:
244-
footerPrefix = "[ q: quit ] [ h: hide help ] [ ↑/↓: navigate ]"
245-
if c.PreviousView() != nil {
246-
footerPrefix += " [ esc: back ]"
247-
}
248-
footer = c.render.Theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, c.CurrentView().HelpMsg()), c.render.Width)
249-
case c.CurrentView().ShowFooter() && !c.showHelp && c.CurrentView().HelpMsg() != "":
250-
footerPrefix = "[ q: quit ] [ h: show help ]"
251-
if c.app.notice != "" {
252-
footer = c.render.Theme.RenderFooter(
253-
fmt.Sprintf("%s ● %s ● %s", footerPrefix, c.CurrentView().HelpMsg(), c.app.notice), c.render.Width,
254-
)
255-
} else {
256-
footer = c.render.Theme.RenderFooter(footerPrefix, c.render.Width)
257-
}
258-
case c.CurrentView().ShowFooter() && !c.showHelp:
259-
footerPrefix = "[ q: quit ] [ ↑/↓: navigate ]"
260-
if c.app.notice != "" {
261-
footer = c.render.Theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, c.app.notice), c.render.Width)
262-
} else {
263-
footer = c.render.Theme.RenderFooter(footerPrefix, c.render.Width)
264-
}
265263
}
266264

267-
header := c.render.Theme.RenderHeader(c.app.Name, c.app.stateKey, c.app.stateVal, c.render.Width)
268-
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Top, header, c.CurrentView().View().Content, footer))
265+
header := c.render.Theme.RenderHeader(c.app.Name, c.app.Version, c.app.stateKey, c.app.stateVal, c.render.Width)
266+
base := lipgloss.JoinVertical(lipgloss.Top, header, c.CurrentView().View().Content)
267+
268+
// Fast path: no overlays active.
269+
if !c.help.Visible() && c.toasts.Empty() {
270+
v := tea.NewView(base)
271+
v.WindowTitle = c.app.Name
272+
return v
273+
}
274+
275+
// Overlay path: compose layers.
276+
baseLayer := lipgloss.NewLayer(base)
277+
278+
if c.help.Visible() {
279+
helpStr := c.help.Render(c.render.Width, c.render.Height)
280+
helpW, helpH := lipgloss.Width(helpStr), lipgloss.Height(helpStr)
281+
helpX := (c.render.Width - helpW) / 2
282+
helpY := (c.render.Height - helpH) / 2
283+
helpLayer := lipgloss.NewLayer(helpStr).X(helpX).Y(helpY).Z(10)
284+
baseLayer.AddLayers(helpLayer)
285+
}
286+
287+
if !c.toasts.Empty() {
288+
toastStr := c.toasts.Render(c.render.Width, c.render.Height)
289+
toastW := lipgloss.Width(toastStr)
290+
toastH := lipgloss.Height(toastStr)
291+
toastX := c.render.Width - toastW - 1
292+
toastY := c.render.Height - toastH - 1
293+
toastLayer := lipgloss.NewLayer(toastStr).X(toastX).Y(toastY).Z(5)
294+
baseLayer.AddLayers(toastLayer)
295+
}
296+
297+
comp := lipgloss.NewCompositor(baseLayer)
298+
v := tea.NewView(comp.Render())
269299
v.WindowTitle = c.app.Name
270300
return v
271301
}
@@ -406,7 +436,10 @@ func (c *Container) RenderState() *types.RenderState {
406436
}
407437

408438
func (c *Container) SetNotice(notice string, lvl themes.OutputLevel) {
409-
c.app.notice = c.render.Theme.RenderLevel(notice, lvl)
439+
cmd := c.toasts.Push(notice, lvl)
440+
if cmd != nil {
441+
c.Send(cmd, 0)
442+
}
410443
}
411444

412445
func (c *Container) SetState(key, val string) {
@@ -439,7 +472,7 @@ func WithInitialTermSize(width, height int) ContainerOptions {
439472
c.render.Width = width
440473
c.render.Height = height
441474
c.render.ContentWidth = width
442-
c.render.ContentHeight = height - (themes.HeaderHeight + themes.FooterHeight)
475+
c.render.ContentHeight = height - themes.HeaderHeight
443476
}
444477
}
445478

0 commit comments

Comments
 (0)