Pad/internal/editor/chunked_buffer.go
Greg Pomerantz b24aa26446 Track Android user font scale in all line-height geometry (Phase 11)
Density (pure hi-DPI) was already scale-free: all bookkeeping is in
density-dp and the scale enters only at the px<->dp boundary. But Android
also has a second axis, the user font-size setting (PxPerSp = fontScale *
PxPerDp), and the shaper draws baselines in sp. At a non-default font
scale the rendered line pitch is 16.8*fontScale dp while every logic-side
consumer used the raw 16.8 dp: taps would misplace by up to (fontScale-1)
viewportfuls of lines and scroll clamping would stop short of the bottom.

- ScaleEvent.FontScale + Frame.FontScale closed loop (main reads
  gtx.Metric, logic tracks it in State.fontScale).
- EffectiveLineHeight()/EffectiveLineHeightAt(): the font-scale-applied
  line height, now used by every consumer (window start, sub-line
  remainder, tap mapping, scroll clamp, page size, cursor vertical move,
  menu position, chunk-prefetch fallbacks).
- Renderer: GlyphLayout.LineHeight, caret, selection handles, and
  highlight all use the scaled ascent/line-height from gtx.Metric.
- font_scale_test.go: 2000-pair tap property test at fontScale 1.3 with
  the glyph layout fabricated at the scaled pitch (independent ground
  truth), plus EffectiveLineHeight unit test.
- On-device: tap markers landed on exactly the tapped line at font_scale
  1.3 (fsline060/080/081) and 0.8 (fsline039); rendered pitch measured
  57/35/44 px at 1.3/0.8/1.0 (matches 16.8*fs*2.625); settled-position
  window start k = floor(s/lh_eff) verified against the visible top line.
- Docs: architecture.md 6.2 font-scale axis, README two-scale note +
  profiler 2s flush staleness note, development plan v10 Phase 11.
2026-08-17 10:59:34 -04:00

684 lines
21 KiB
Go

package editor
import (
"bytes"
"math"
"sort"
"strings"
"pad/internal/io/pool"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
const (
DefaultChunkSize = 64 * 1024 // 64 KB
// MaxEditableFileSize is the largest file the editor will open for editing.
// In-range files are loaded fully into memory (see Phase 3): the chunked
// buffer keeps the raw bytes (~file size) resident and the renderer
// virtualizes the glyph layout to the visible window, so memory scales
// roughly linearly with file size and stays bounded (no leak). Files above
// this are rejected with a "too large to edit" state (the browser can still
// list them).
//
// On-device measurement (Android emulator, SwiftShader): a 10 MB file uses
// ~150 MB PSS / ~230 MB RSS at steady state and stays flat under scroll
// (previously the shaper was handed the whole file each frame, ballooning
// to ~1.1 GB and OOM-killing the process). 50 MB extrapolates to a few
// hundred MB, comfortable on a modern phone.
MaxEditableFileSize = 50 * 1024 * 1024 // 50 MB
)
// ChunkedBuffer provides chunked access to a file's content.
//
// Model (Phase 3): for in-range files the ENTIRE file is loaded into memory on
// open and split into ordered chunks (see SetContent). Each chunk keeps its
// own length; the byte offset of a chunk is the sum of the lengths of the
// chunks before it (a prefix sum), NOT a fixed i*chunkSize slot. This is what
// makes edits correct: an insert/delete changes a chunk's length and the
// prefix sums automatically shift every later chunk, so byte->chunk mapping
// stays exact. Because every chunk is resident, there is no lazy loading and
// therefore no stale-disk re-read (the old fixed-slot model could re-read a
// shifted tail chunk from disk and clobber in-memory edits).
//
// Chunks grow/shrink with edits; Insert splits any chunk that grows past
// twice the target size (see Insert), so the per-edit copy cost stays bounded
// by O(chunkSize) even under sustained typing at one spot. Shrunken (even
// empty) chunks are left in place: the chunk count never grows with deletes,
// all readers walk actual lengths, and removing chunks would be pure churn.
type ChunkedBuffer struct {
filename string
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
fileLen int64 // total content length
chunks [][]byte // ordered; chunks[i] is the i-th chunk
dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reads
basePath string // base path for file resolution
// line index is built asynchronously
LineIndex *types.LineIndex
// workerPool is retained for API compatibility; in-range files load fully
// up front, so chunk loading no longer dispatches worker tasks.
workerPool *pool.WorkerPool
// lastPrefetchedChunk tracks the last chunk that was prefetched, so callers
// can avoid redundant work. Kept for compatibility.
lastPrefetchedChunk int
// dirtyChunks tracks which individual chunks have been modified since they
// were last persisted to disk.
dirtyChunks map[int]bool
// loadingChunks is retained for API compatibility; it is always empty for
// in-range files (no lazy loading).
loadingChunks map[int]bool
// fullyLoaded is true once SetContent has populated all chunks; the buffer
// never falls back to disk afterwards.
fullyLoaded bool
}
// NewChunkedBuffer creates a new ChunkedBuffer.
func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePath string) *ChunkedBuffer {
if chunkSize <= 0 {
chunkSize = DefaultChunkSize
}
return &ChunkedBuffer{
filename: filename,
chunkSize: chunkSize,
fileLen: 0,
chunks: nil,
dirtyChunks: make(map[int]bool),
loadingChunks: make(map[int]bool),
FS: fs,
basePath: basePath,
}
}
// SetFileSize sets the total file length (used before SetContent, e.g. from a
// stat). It only applies when the buffer is not dirty.
func (cb *ChunkedBuffer) SetFileSize(length int64) {
if !cb.dirty {
cb.fileLen = length
}
}
// FileLen returns the total content length.
func (cb *ChunkedBuffer) FileLen() int64 {
return cb.fileLen
}
// Filename returns the filename.
func (cb *ChunkedBuffer) Filename() string {
return cb.filename
}
// ChunkSize returns the target chunk size.
func (cb *ChunkedBuffer) ChunkSize() int {
return cb.chunkSize
}
// SetContent replaces the buffer content with data, split into ordered chunks
// of at most chunkSize. This is the on-open load path for in-range files: the
// whole file becomes resident, so there is no lazy loading and no risk of
// re-reading stale disk data after an edit.
func (cb *ChunkedBuffer) SetContent(data []byte) {
cb.chunks = nil
for start := 0; start < len(data); start += cb.chunkSize {
end := start + cb.chunkSize
if end > len(data) {
end = len(data)
}
chunk := make([]byte, end-start)
copy(chunk, data[start:end])
cb.chunks = append(cb.chunks, chunk)
}
if len(data) == 0 {
cb.chunks = [][]byte{}
}
cb.fileLen = int64(len(data))
cb.dirty = false
cb.dirtyChunks = make(map[int]bool)
cb.fullyLoaded = true
}
// chunkForPos returns the index of the chunk that contains byte offset pos and
// the offset of pos within that chunk. If pos is at or past the end, it returns
// the last chunk and an offset at its end (so callers can append). If the
// buffer is empty it returns (-1, 0).
func (cb *ChunkedBuffer) chunkForPos(pos int) (int, int) {
offset := 0
for i, chunk := range cb.chunks {
if pos < offset+len(chunk) {
return i, pos - offset
}
offset += len(chunk)
}
if n := len(cb.chunks); n > 0 {
return n - 1, len(cb.chunks[n-1])
}
return -1, 0
}
// Content returns the bytes in [start, end) from the chunked buffer. Chunks are
// walked by their actual (prefix-sum) offsets, so the mapping stays correct
// after length-changing edits.
func (cb *ChunkedBuffer) Content(start, end int) string {
if cb.fileLen == 0 {
return ""
}
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if start >= end {
return ""
}
var buf bytes.Buffer
offset := 0
for _, chunk := range cb.chunks {
chunkStart := offset
chunkEnd := offset + len(chunk)
offset = chunkEnd
if chunkEnd <= start {
continue
}
if chunkStart >= end {
break
}
segStart := max(start, chunkStart) - chunkStart
segEnd := min(end, chunkEnd) - chunkStart
if segStart < segEnd {
buf.Write(chunk[segStart:segEnd])
}
}
return buf.String()
}
// RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the
// buffer. The IME addresses text in rune indices while the buffer is
// byte-based, so this bridges the two. It walks chunks by actual length and
// stops as soon as the n-th rune is found. If n is at or past the end it
// returns the content length.
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
if n <= 0 {
return 0
}
if cb.fileLen == 0 {
return 0
}
runes := 0
offset := 0
for _, chunk := range cb.chunks {
for i := 0; i < len(chunk); i++ {
b := chunk[i]
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
// lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
if b < 0x80 || b >= 0xC0 {
if runes == n {
return offset + i
}
runes++
}
}
offset += len(chunk)
}
return int(cb.fileLen)
}
// FullContent reconstructs the entire content by concatenating the ordered
// chunks. For in-range files every chunk is resident, so this is exact and
// never returns an error.
func (cb *ChunkedBuffer) FullContent() (string, error) {
var buf bytes.Buffer
for _, chunk := range cb.chunks {
buf.Write(chunk)
}
return buf.String(), nil
}
// loadChunk reads a chunk from disk. Retained for compatibility; in-range
// files load fully via SetContent and do not use this.
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
start := idx * cb.chunkSize
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
if err != nil {
return nil, err
}
for len(cb.chunks) <= idx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[idx] = chunk
return chunk, nil
}
// LoadChunk loads a chunk (no-op for fully-loaded in-range files).
func (cb *ChunkedBuffer) LoadChunk(idx int) {
if cb.fullyLoaded {
return
}
if idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil {
return
}
if _, err := cb.loadChunk(idx); err != nil {
// Best effort; in-range files should never reach here.
}
cb.lastPrefetchedChunk = idx
}
// IsChunkLoaded reports whether the chunk is resident.
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
return idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil
}
// IsChunkLoading reports whether a chunk load is in flight (never, for
// in-range files).
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
return cb.loadingChunks[idx]
}
// LoadChunkAsync dispatches an async chunk load (no-op for in-range files,
// which are fully resident).
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
if cb.fullyLoaded {
return
}
if cb.workerPool != nil && !cb.loadingChunks[idx] && !cb.IsChunkLoaded(idx) {
cb.loadingChunks[idx] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
)
}
}
// SetWorkerPool sets the worker pool (retained for compatibility).
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
cb.workerPool = wp
}
// LastPrefetchedChunk returns the last prefetched chunk index.
func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
return cb.lastPrefetchedChunk
}
// Prefetch is a no-op for in-range files (all chunks resident).
func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
cb.lastPrefetchedChunk = centerChunk
}
// EvictFarChunks is intentionally a no-op for in-range files: all chunks stay
// resident so byte->chunk mapping and FullContent remain exact. (Evicting
// would reintroduce the stale-disk re-read hazard the fixed-slot model had.)
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
// no-op
}
// markDirtyChunk flags the buffer and a chunk as modified.
func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
cb.dirty = true
if idx >= 0 {
cb.dirtyChunks[idx] = true
}
}
// Insert inserts text at byte position pos, splicing only the affected
// chunk. If the result grows past twice the target chunk size (sustained
// typing at one spot, or a large paste), the chunk is split in half so that
// chunks stay O(chunkSize) and every future edit remains a bounded
// O(chunkSize) copy. The split cut is an arbitrary byte offset, like the
// chunk boundaries created by SetContent: chunk boundaries may fall inside
// multi-byte sequences, which is fine because every reader reassembles whole
// windows from chunk bytes (windows are always line-aligned, i.e. on rune
// boundaries).
func (cb *ChunkedBuffer) Insert(pos int, text string) {
if len(text) == 0 {
return
}
fileLen := int(cb.fileLen)
if pos > fileLen {
pos = fileLen // clamp to end
}
if pos < 0 {
pos = 0
}
idx, local := cb.chunkForPos(pos)
if idx < 0 {
// Empty buffer: chunk the new text directly so a large first paste
// does not create one oversized chunk.
cb.chunks = nil
for start := 0; start < len(text); start += cb.chunkSize {
end := start + cb.chunkSize
if end > len(text) {
end = len(text)
}
cb.chunks = append(cb.chunks, []byte(text[start:end]))
}
cb.fileLen = int64(len(text))
cb.markDirtyChunk(0)
return
}
chunk := cb.chunks[idx]
newChunk := make([]byte, 0, len(chunk)+len(text))
newChunk = append(newChunk, chunk[:local]...)
newChunk = append(newChunk, text...)
newChunk = append(newChunk, chunk[local:]...)
if len(newChunk) > 2*cb.chunkSize {
// Split in half and insert the second half after idx.
cut := len(newChunk) / 2
second := make([]byte, len(newChunk)-cut)
copy(second, newChunk[cut:])
newChunk = newChunk[:cut]
cb.chunks = append(cb.chunks, nil)
copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove)
cb.chunks[idx+1] = second
}
cb.chunks[idx] = newChunk
cb.fileLen += int64(len(text))
cb.markDirtyChunk(idx)
}
// Delete deletes n bytes starting at pos, splicing only the affected chunk(s).
func (cb *ChunkedBuffer) Delete(pos, n int) {
if n <= 0 {
return
}
fileLen := int(cb.fileLen)
if pos < 0 {
pos = 0
}
if pos >= fileLen {
return
}
if pos+n > fileLen {
n = fileLen - pos
}
absEnd := pos + n
offset := 0
for i := range cb.chunks {
chunk := cb.chunks[i]
chunkStart := offset
chunkEnd := offset + len(chunk)
offset = chunkEnd
if chunkEnd <= pos || chunkStart >= absEnd {
continue // no overlap with [pos, absEnd)
}
delStart := max(pos, chunkStart) - chunkStart
delEnd := min(absEnd, chunkEnd) - chunkStart
if delStart >= delEnd {
continue
}
cb.chunks[i] = append(chunk[:delStart], chunk[delEnd:]...)
cb.markDirtyChunk(i)
}
cb.fileLen -= int64(n)
if cb.fileLen < 0 {
cb.fileLen = 0
}
cb.dirty = true
}
// VisibleByteRange returns the byte range [start, end) that is visible on the
// current editor viewport, plus the visual line at the top of the viewport.
// This drives the renderer's virtual scrolling: only this range of text is
// laid out into GlyphLayout, keeping memory and layout cost bounded by the
// viewport, not the file. It uses actual (prefix-sum) chunk offsets so the
// range stays correct after length-changing edits.
func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) {
// The visible range is always derived from the real-line LineIndex (or a
// heuristic estimate before the index is ready). The previously-shaped
// GlyphLayout (layout.VisualLineStarts) only covers the visible window, NOT
// the whole document. Using it to bound the range made the end fall back to
// the entire file whenever the viewport's line count exceeded the window's
// visual-line count, which forced the text shaper to lay out the whole
// document and ballooned memory to the file size (the shaper's internal
// line/glyph buffers grow to the largest layout ever shaped and are never
// released). That is the root cause of the ~1GB "Unknown" memory on large
// files.
//
// Word wrap does not require a separate path: each real line produces at
// least one visual line, so shaping viewportHeight/lineHeight real lines
// always yields at least as many visual lines as fit in the viewport. The
// extra wrapped lines are simply clipped by the renderer.
lineH := lineHeight
if lineH <= 0 {
lineH = EffectiveLineHeight()
}
if cb.LineIndex == nil {
start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineH)
return start, end, 0
}
start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight, lineH)
return start, end, 0
}
// visibleByteRangeEstimate approximates the visible byte range using
// heuristic estimates. Used when the line index is not yet available.
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end int) {
if lineHeight <= 0 {
lineHeight = EffectiveLineHeight()
}
startLine, _ := scrollDecompose(scrollOffset, lineHeight)
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
// Clamp line numbers to reasonable bounds
totalLinesEstimate := 0
if cb.fileLen > 0 {
totalLinesEstimate = int(cb.fileLen/50) + 1 // Rough estimate: 50 bytes per line
}
if startLine < 0 {
startLine = 0
}
if endLine > totalLinesEstimate {
endLine = totalLinesEstimate
}
if startLine >= endLine {
endLine = startLine + 1 // Ensure at least one line is visible
}
// Convert line numbers to byte offsets using the line index if available.
if cb.LineIndex != nil {
if startLine < len(cb.LineIndex.Offsets) {
start = int(cb.LineIndex.Offsets[startLine])
} else {
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) - 1)
start = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
}
if endLine < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine])
} else {
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) - 1)
end = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
}
} else {
// Rough byte estimation if no LineIndex
start = startLine * 50 // rough estimate: 50 bytes per line
end = endLine * 50
}
// Clamp to file bounds
if cb.fileLen > 0 {
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if end <= start {
end = start + cb.chunkSize // Ensure at least one chunk's worth if range is invalid
}
} else {
start = 0
end = 0 // Empty file
}
return start, end
}
// visibleByteRangePrecise uses the LineIndex to find the exact byte range.
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end int) {
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineHeight)
}
if lineHeight <= 0 {
lineHeight = EffectiveLineHeight()
}
// startLine must use the same floor decomposition as the renderer's
// sub-line shift and tapLocalY (scrollDecompose); a raw int(s/lh) in the
// Dp float32 domain can round the quotient up across an integer boundary
// and disagree with the remainder by one line.
startLine, _ := scrollDecompose(scrollOffset, lineHeight)
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
if startLine < 0 {
startLine = 0
}
if startLine >= len(cb.LineIndex.Offsets) {
startLine = len(cb.LineIndex.Offsets) - 1
}
if endLine < 0 {
endLine = 0
}
if endLine >= len(cb.LineIndex.Offsets) {
endLine = len(cb.LineIndex.Offsets) - 1
}
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
endLine = startLine + 1
}
start = int(cb.LineIndex.Offsets[startLine])
if endLine+1 < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine+1])
} else {
end = int(cb.fileLen)
}
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if end <= start {
if start < int(cb.fileLen) {
end = min(start+cb.chunkSize, int(cb.fileLen))
} else {
end = start
}
}
return start, end
}
// UpdateLineIndexAfterInsert records the insertion of `text` at absolute
// position `pos`, maintaining LineIndex incrementally:
// - old line starts below pos are unchanged;
// - an old line start exactly at pos stays at pos (the byte before it is
// unchanged by the insertion);
// - old line starts above pos shift right by len(text);
// - each '\n' inside `text` creates a new line start immediately after it.
//
// Equivalent to rebuilding the index from the edited content, but in
// O(lines affected) instead of O(file).
func (cb *ChunkedBuffer) UpdateLineIndexAfterInsert(pos int, text string) {
li := cb.LineIndex
if li == nil {
return
}
old := li.Offsets
lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= pos })
atPos := lower < len(old) && int(old[lower]) == pos
rest := lower
if atPos {
rest++
}
shift := int32(len(text))
newOff := make([]int32, 0, len(old)+strings.Count(text, "\n")+1)
newOff = append(newOff, old[:lower]...)
if atPos {
newOff = append(newOff, int32(pos))
}
for i, b := range text {
if b == '\n' {
newOff = append(newOff, int32(pos+i+1))
}
}
for _, o := range old[rest:] {
newOff = append(newOff, o+shift)
}
li.Offsets = newOff
li.Size += int64(len(text))
}
// UpdateLineIndexAfterDelete records the deletion of the absolute byte range
// [start, end), maintaining LineIndex incrementally:
// - old line starts below start are unchanged;
// - old line starts inside [start, end) are removed;
// - old line starts at or above end shift left by end-start, except the one
// at exactly end, which would land on `start` and is valid only if a line
// starts there in the new content;
// - `start` is (re)inserted as a line start iff start==0 or it was a line
// start in the pre-edit index (equivalently, the byte before it is '\n';
// bytes below start are untouched by the deletion).
func (cb *ChunkedBuffer) UpdateLineIndexAfterDelete(start, end int) {
li := cb.LineIndex
if li == nil {
return
}
old := li.Offsets
shift := int32(end - start)
lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= start })
atStart := lower < len(old) && int(old[lower]) == start
upper := sort.Search(len(old), func(i int) bool { return int(old[i]) >= end })
newOff := make([]int32, 0, len(old))
newOff = append(newOff, old[:lower]...)
if start == 0 || atStart {
newOff = append(newOff, int32(start))
}
for _, o := range old[upper:] {
no := o - shift
if int(no) == start {
// The old line start at exactly `end` shifted onto `start`. A line
// starts there in the new content iff one already existed there
// (added above); in neither case do we keep this shifted entry.
continue
}
newOff = append(newOff, no)
}
li.Offsets = newOff
li.Size -= int64(end - start)
if li.Size < 0 {
li.Size = 0
}
}
// max returns the larger of two ints.
func max(a, b int) int {
if a > b {
return a
}
return b
}
// min returns the smaller of two ints.
func min(a, b int) int {
if a < b {
return a
}
return b
}