bloom

Scrolling and sizing in Update

Scroll content that is taller than its space, and use Solve to find a node's size in Update for paging and for components that need their size up front.

Scroll content

bloom.Scroll(child, offset) gives a child as much height as it wants, and shows it starting offset rows down, clipped to the space the scroll has. The offset is model state, so you decide how keys and the mouse wheel move it:

type model struct {
	ui     *bloom.Root
	offset int // rows scrolled
	// ...
}

func (m *model) layout() bloom.Child {
	return bloom.Box(panel, bloom.Scroll(bloom.Text(m.article).Style(prose), m.offset))
}

Scroll measures and draws its whole child, which is fine for an article or a help screen. For a long log or a table with thousands of rows, have the component only render the rows in view instead, such as with the Bubbles viewport.

Find a node's size in Update

To clamp the offset, or to page by a screen at a time, Update needs to know how tall the scrolled area is. That's decided by the layout, which runs in View.

bloom.Solve lays out a tree without drawing it, and returns where each node with an ID would go. Layout is a pure function of the tree and the size, so Solve in Update gives the same rectangles that View will draw for the same state.

Give the nodes you need IDs. Here, the box gives the size of one page, and the text inside the scroll gives the full height of the article:

type pane string

const (
	paneReader  pane = "reader"
	paneArticle pane = "article"
)

func (m *model) layout() bloom.Child {
	return bloom.Column(
		header,
		bloom.Fill(bloom.Box(panel, bloom.Scroll(
			bloom.Text(m.article).Style(prose).ID(paneArticle),
			m.offset,
		))).ID(paneReader),
	)
}

func (m *model) View() tea.View {
	v := m.ui.View(m.width, m.height, m.layout())
	v.AltScreen = true
	return v
}

Then solve the same layout in Update:

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
	case tea.KeyPressMsg:
		frame := bloom.Solve(m.layout(), m.width, m.height)
		page := 1
		if r, ok := frame.Rect(paneReader); ok {
			page = max(r.Dy()-panel.GetVerticalFrameSize(), 1)
		}
		switch msg.String() {
		case "down":
			m.offset++
		case "up":
			m.offset--
		case "pgdown":
			m.offset += page
		case "pgup":
			m.offset -= page
		}
		if r, ok := frame.Rect(paneArticle); ok {
			m.offset = min(max(m.offset, 0), max(r.Dy()-page, 0))
		}
	}
	return m, m.ui.Update(msg)
}

Rect returns an image.Rectangle in screen cells, and whether the node is in the frame at all. A node in a collapsed slot, or a layer that's hidden, isn't.

Scroll with the mouse wheel

Put an on.Scroll handler on the reader, so the wheel only scrolls what it's over. dy is 1 for each step down and -1 for each step up:

reader := bloom.Fill(bloom.Box(panel, bloom.Scroll(content, m.offset))).
	ID(paneReader).
	On(on.Scroll(func(_, dy int) tea.Cmd { m.offset += dy * 3; return nil }))

The handler runs just before Update gets the wheel event, so clamp the offset in Update as you do for keys.

Remember to turn on mouse reporting in View with v.MouseMode = tea.MouseModeCellMotion.

Solve or Frame?

There are two ways to get geometry, and they answer different questions:

  • bloom.Solve(layout, w, h) lays out the tree you pass, for the state your model has now. Use it in Update after changing state, when the answer has to reflect that change.
  • m.ui.Frame() returns the geometry of the frame last drawn on screen. It costs nothing, and it's what focus moves through, but it doesn't know about changes your model made since. With layout animation, it has the rectangles currently on screen, mid-animation, while Solve returns where things are headed.

Last updated on

On this page