@@ -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 (
2021type 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+
2836type 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
229257func (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
408438func (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
412445func (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