|
function render(layout: Layout) { |
|
const logo = new UIImage('/logo.png', 100, 100) |
|
const segmented = new UISegmentedControl(['Intro', '1', '2']) |
|
const text = new UIText('Swift manual views layouting without auto layout, no magic, pure code, full control ...') |
|
const separator = new UISeparator(1, 'blue') |
|
|
|
return layout.pad_all(10) |
|
.cut_top(sublayout => |
|
sublayout.cut_left(logo, { align: 'top' }).pad_left(10) |
|
.cut_top(segmented).pad_top(10) |
|
.put(text) |
|
) |
|
.cut_top(separator) |
|
|
|
/* |
|
Alternative API without closures & without chaining: |
|
|
|
layout.pad_all(10) |
|
|
|
const sublayout = layout.cut_top() |
|
sublayout.cut_left(logo, { align: 'top' }) |
|
sublayout.pad_left(10) |
|
sublayout.cut_top(segmented) |
|
sublayout.pad_top(10) |
|
sublayout.put(text) |
|
|
|
layout.cut_top(separator) |
|
*/ |
|
} |
|
|
|
const FONT_WIDTH = 10 |
|
const FONT_HEIGHT = 16 |
|
|
|
// the contract for render(): we tell you the available width & height (either or both |
|
// of which may be 'fit-content'), and you tell us what size you'll be. Eg images will |
|
// scale to fit inside the available dimensions, or text will determine its height based |
|
// on the width. |
|
// Known issue: assumes render() returns sizes less than given available sizes, and will |
|
// crap out if it violates that |
|
interface UIElement { |
|
render(available_width: number | 'fit-content', available_height: number | 'fit-content'): Readonly<{ width: number, height: number }> |
|
} |
|
|
|
// images scale to fit inside the available dimensions, preserving their intrinsic |
|
// aspect ratio; if both available width & height are 'fit-content', then the intrinsic |
|
// size is used. Alternative: maybe should only scale to fit if available dimensions |
|
// are smaller than the intrinsic size? So scaling factor will always be ≤1? |
|
class UIImage implements UIElement { |
|
constructor(readonly url: string, readonly width: number, readonly height: number) {} |
|
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') { |
|
// fit as big as it can while preserving aspect ratio |
|
let width, height |
|
if (avail_width === 'fit-content' && avail_height === 'fit-content') { |
|
({ width, height } = this) |
|
} else if (avail_width === 'fit-content' |
|
|| this.height/this.width > (avail_height as number)/avail_width) { |
|
height = avail_height as number |
|
width = this.width*height/this.height |
|
} else { |
|
width = avail_width |
|
height = this.height*width/this.width |
|
} |
|
return { width, height } |
|
} |
|
} |
|
|
|
// fixed height, width expands to fit container |
|
// Known issue: what if available width is less than min width? |
|
class UISegmentedControl implements UIElement { |
|
constructor(readonly items: readonly string[]) {} |
|
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') { |
|
if (avail_width === 'fit-content') { |
|
return { width: this.items.join(' ').length*FONT_WIDTH, height: FONT_HEIGHT } |
|
} else { |
|
return { width: avail_width, height: FONT_HEIGHT } |
|
} |
|
} |
|
} |
|
|
|
// based on available width, determines the height needed. |
|
// If width unconstrained, the whole text is put on one line. |
|
// Should it have a default width instead? |
|
// Known issue: doesn't do real text wrapping, instead estimates based on "typical |
|
// area" of a character |
|
class UIText implements UIElement { |
|
constructor(readonly text: string) {} |
|
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') { |
|
// in leiu of real text wrapping, pretend that text is a 2D liquid with some amount of "2D volume"/area |
|
const area = this.text.length*FONT_WIDTH*FONT_HEIGHT |
|
let width, height |
|
if (avail_width === 'fit-content' && avail_height === 'fit-content') { |
|
height = FONT_HEIGHT |
|
width = this.text.length*FONT_WIDTH |
|
} else if (avail_width === 'fit-content') { |
|
height = avail_height as number |
|
width = area/height|0 |
|
} else { |
|
width = avail_width |
|
height = area/width|0 |
|
} |
|
return { width, height } |
|
} |
|
} |
|
|
|
// horizontal separator line |
|
// Ben suggested this should be subsumed by a way to draw an arbitrary rectangle |
|
class UISeparator implements UIElement { |
|
constructor(readonly height: number, readonly color: string) {} |
|
render(avail_width: number | 'fit-content', avail_height: number | 'fit-content') { |
|
return { width: avail_width === 'fit-content' ? 20 : avail_width, height: this.height } |
|
} |
|
} |
|
|
|
class Layout { |
|
// If the initial available width or height is a fixed number, then the layout |
|
// will always have that width or height and the available width and height will |
|
// shrink as the layout is cut up to put things in. |
|
// However if the initial available width or height is "fit-content" (meaning it's |
|
// just large enough to fit its content), then the layout will always be fit-content, |
|
// and instead the/ layout's width and height will grow as things are put in by |
|
// cutting it up. |
|
// Note that this is independent for width and height, so that's not 2 kinds |
|
// of layouts, that's 4 kinds (fixed size, fixed width, fixed height, unconstrained). |
|
// The top-level page is normally fixed width and unconstrained height. |
|
constructor( |
|
// dimensions of remaining available space in this layout: |
|
// As described above, if a fixed size was initially provided, then the |
|
// corresponding available dimension will shrink as the layout is cut up |
|
// to put things in; but if it was initially 'fit-content', then the |
|
// available dimension will always be 'fit-content'. |
|
public available_width: number | 'fit-content', |
|
public available_height: number | 'fit-content', |
|
|
|
// top-left coordinates of remaining available space in this layout: |
|
// As the layout is cut up to put things in it, these will move down and |
|
// to the right. |
|
private available_x = 0, |
|
private available_y = 0, |
|
|
|
// fake "rendering" of UI elements by "drawing" their rectangles, i.e. appending |
|
// to this list the coordinates and sizes at which to draw each UI element |
|
readonly rects: Array<{x: number, y: number, width: number, height: number, content: UIElement }> = [] |
|
) { |
|
this.width = available_width === 'fit-content' ? 0 : available_width |
|
this.height = available_height === 'fit-content' ? 0 : available_height |
|
this.x = this.available_x |
|
this.y = this.available_y |
|
} |
|
|
|
// the outer dimensions of this layout: |
|
// As described above, if the initial available width or height was a fixed |
|
// number, then this width/height will always be that value; but if the layout |
|
// is fit-content along a dimension, then the width or height will grow as |
|
// content is put into the layout by cutting it up. |
|
width: number |
|
height: number |
|
|
|
// the initial top-left coordinates of the layout pane; never changes |
|
private readonly x: number |
|
private readonly y: number |
|
|
|
// cut out padding along a side |
|
pad_top(padding: number) { |
|
this.available_y += padding |
|
return this.pad_bottom(padding) |
|
} |
|
pad_bottom(padding: number) { |
|
if (this.available_height === 'fit-content') { |
|
this.height += padding |
|
} else { |
|
this.available_height -= padding |
|
} |
|
return this |
|
} |
|
pad_left(padding: number) { |
|
this.available_x += padding |
|
return this.pad_right(padding) |
|
} |
|
pad_right(padding: number) { |
|
if (this.available_width === 'fit-content') { |
|
this.width += padding |
|
} else { |
|
this.available_width -= padding |
|
} |
|
return this |
|
} |
|
pad_all(v_padding: number, h_padding?: number) { |
|
h_padding ??= v_padding |
|
this.available_x += h_padding |
|
this.available_y += v_padding |
|
return this.pad_bottom(2*v_padding).pad_right(2*h_padding) |
|
} |
|
|
|
// cut out a row at the top, put the given content in there |
|
cut_top(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'left' | 'center' | 'right' } = { align: 'center' }) { |
|
// render the content, calculating the x/y/width/height and "drawing" it by |
|
// adding it to the rects array |
|
let rect |
|
if (content instanceof Function) { |
|
rect = content(new Layout(this.available_width, 'fit-content', this.available_x, this.available_y, this.rects)) |
|
} else { |
|
rect = content.render(this.available_width, 'fit-content') |
|
this.rects.push({ x: this.h_align(align, rect.width), y: this.available_y, ...rect, content }) |
|
} |
|
|
|
// when cutting out a row at the top, the x-coord of the remaining available |
|
// space doesn't change, but the y-coord grows |
|
this.available_y += rect.height |
|
|
|
// if the layout is fixed-width, that doesn't change; |
|
// if the width is fit-content, then the width grows if this newly drawn |
|
// rect would stick out the side of the layout |
|
if (this.available_width === 'fit-content') { |
|
this.width = Math.max(this.width, this.available_x - this.x + rect.width) |
|
} |
|
// if the layout is fixed-height, that doesn't change, but the remaining |
|
// available width shrinks; |
|
// if the height is fit-content, then the height grows if this newly drawn |
|
// rect sticks out the bottom of the layout |
|
if (this.available_height !== 'fit-content') { |
|
this.available_height -= rect.height |
|
} else { |
|
this.height = Math.max(this.height, this.available_y - this.y) |
|
} |
|
return this |
|
} |
|
|
|
// cut out a row at the bottom, put the given content in there |
|
cut_bottom(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'left' | 'center' | 'right' } = { align: 'center' }) { |
|
// can't put content at the bottom of a variable-height layout because we'd |
|
// have to recalculate the y-coords as the height of the layout grows |
|
if (this.available_height === 'fit-content') throw 'Can only cut-bottom of layouts with fixed height' |
|
|
|
// render the content, calculating the x/y/width/height and "drawing" it by |
|
// adding it to the rects array |
|
let rect |
|
if (content instanceof Function) { |
|
rect = content(new Layout(this.available_width, 'fit-content', this.available_x, this.available_y, this.rects)) |
|
} else { |
|
rect = content.render(this.available_width, 'fit-content') |
|
const y = this.available_y + this.available_height - rect.height |
|
this.rects.push({ x: this.h_align(align, rect.width), y, ...rect, content }) |
|
} |
|
|
|
// cutting out a row at the bottom doesn't change the x or y-coord of the |
|
// remaining available space, but does shrink the height |
|
this.available_height -= rect.height |
|
|
|
// if the layout is fixed-width, that doesn't change; |
|
// if the width is fit-content, then the width grows if this newly drawn |
|
// rect would stick out the side of the layout |
|
if (this.available_width === 'fit-content') { |
|
this.width = Math.max(this.width, this.available_x - this.x + rect.width) |
|
} |
|
return this |
|
} |
|
|
|
// cut out a column on the left, put the given content in there |
|
cut_left(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'top' | 'center' | 'bottom' } = { align: 'center' }) { |
|
// render the content, calculating the x/y/width/height and "drawing" it by |
|
// adding it to the rects array |
|
let rect |
|
if (content instanceof Function) { |
|
rect = content(new Layout('fit-content', this.available_height, this.available_x, this.available_y, this.rects)) |
|
} else { |
|
rect = content.render('fit-content', this.available_height) |
|
this.rects.push({ x: this.available_x, y: this.v_align(align, rect.height), ...rect, content }) |
|
} |
|
|
|
// when cutting out a column on the left, the y-coord of the remaining |
|
// available space doesn't change, the x-coord grows |
|
this.available_x += rect.width |
|
|
|
// if the layout is fixed-width, that doesn't change, but the remaining |
|
// available width shrinks; |
|
// if the width is fit-content, then the width grows if this newly drawn |
|
// rect sticks out the side of the layout |
|
if (this.available_width !== 'fit-content') { |
|
this.available_width -= rect.width |
|
} else { |
|
this.width = Math.max(this.width, this.available_x - this.x) |
|
} |
|
// if the layout is fixed-height, that doesn't change; |
|
// if the height is fit-content, then the height grows if this newly drawn |
|
// rect would stick out the bottom of the layout |
|
if (this.available_height === 'fit-content') { |
|
this.height = Math.max(this.height, this.available_y + rect.height - this.y) |
|
} |
|
return this |
|
} |
|
// cut out a column on the right, put the given content in there |
|
cut_right(content: UIElement | ((l: Layout) => Layout), { align }: { align: 'top' | 'center' | 'bottom' } = { align: 'center' }) { |
|
// can't put content on the right edge of a variable-width layout because we'd |
|
// have to recalculate the x-coords as the width of the layout grows |
|
if (this.available_width === 'fit-content') throw 'Can only cut right of layouts with fixed width' |
|
|
|
// render the content, calculating the x/y/width/height and "drawing" it by |
|
// adding it to the rects array |
|
let rect |
|
if (content instanceof Function) { |
|
rect = content(new Layout('fit-content', this.available_height, this.available_x, this.available_y, this.rects)) |
|
} else { |
|
rect = content.render('fit-content', this.available_height) |
|
const x = this.available_x + this.available_width - rect.width |
|
this.rects.push({ x, y: this.v_align(align, rect.height), ...rect, content }) |
|
} |
|
|
|
// cutting out a column on the right doesn't change the x or y-coord of the |
|
// remaining available space, but does shrink the width |
|
this.available_width -= rect.width |
|
|
|
// if the layout is fixed-height, that doesn't change; |
|
// if the height is fit-content, then the height grows if this newly drawn |
|
// rect would stick out the bottom of the layout |
|
if (this.available_height === 'fit-content') { |
|
this.height = Math.max(this.height, this.available_y + rect.height - this.y) |
|
} |
|
return this |
|
} |
|
|
|
// put the given item in the remaining space in the layout |
|
put(content: UIElement, { align }: { align: 'top' | 'left' | 'center' | 'right' | 'bottom' | `${'top' | 'bottom'}-${'left' | 'right'}` } = { align: 'center' }) { |
|
// parse the alignment specifier into h-align and v-align specifiers |
|
let v_align: 'top' | 'center' | 'bottom', h_align: 'left' | 'center' | 'right' |
|
if (align === 'top' || align === 'bottom') [v_align, h_align] = [align, 'center'] |
|
else if (align === 'left' || align === 'right') [v_align, h_align] = ['center', align] |
|
else if (align === 'center') [v_align, h_align] = ['center', 'center'] |
|
else [v_align, h_align] = align.split('-') as ['top' | 'bottom', 'left' | 'right'] |
|
|
|
// render the content, calculating the x/y/width/height and "drawing" it by |
|
// adding it to the rects array |
|
const rect = content.render(this.available_width, this.available_height) |
|
this.rects.push({ |
|
x: this.h_align(h_align, rect.width), |
|
y: this.v_align(v_align, rect.height), |
|
...rect, |
|
content, |
|
}) |
|
|
|
// if the layout is fixed-width, that doesn't change, but the remaining |
|
// available width is used up; |
|
// if the width is fit-content, then the width grows if this newly drawn |
|
// rect sticks out the side of the layout |
|
if (this.available_width !== 'fit-content') { |
|
this.available_width = 0 |
|
} else { |
|
this.width = Math.max(this.width, this.available_x - this.x + rect.width) |
|
} |
|
|
|
// if the layout is fixed-height, that doesn't change, but the remaining |
|
// available height is used up; |
|
// if the height is fit-content, then the height grows if this newly drawn |
|
// rect sticks out the bottom of the layout |
|
if (this.available_height !== 'fit-content') { |
|
this.available_height = 0 |
|
} else { |
|
this.height = Math.max(this.height, this.available_y - this.y + rect.height) |
|
} |
|
return this |
|
} |
|
|
|
// calculates the x-coord of a rect with given width & alignment in remaining available space |
|
h_align(align: 'left' | 'center' | 'right', width: number) { |
|
return align === 'left' || this.available_width === 'fit-content' ? this.available_x |
|
: align === 'right' ? this.available_x + this.available_width - width |
|
: /* center */ this.available_x + (this.available_width - width)/2 | 0 |
|
} |
|
// calculates the y-coord of a rect with given height & alignment in remaining available space |
|
v_align(align: 'top' | 'center' | 'bottom', height: number) { |
|
return align === 'top' || this.available_height === 'fit-content' ? this.available_y |
|
: align === 'bottom' ? this.available_y + this.available_height - height |
|
: /* center */ this.available_y + (this.available_height - height)/2 | 0 |
|
} |
|
} |
|
|
|
// test: |
|
const l = render(new Layout(800, 'fit-content')) |
|
for (const rect of l.rects) { |
|
const el = document.createElement('div') |
|
el.style.position = 'absolute' |
|
el.style.left = rect.x + 'px' |
|
el.style.top = rect.y + 'px' |
|
el.style.width = rect.width + 'px' |
|
el.style.height = rect.height + 'px' |
|
el.style.background = 'skyblue' |
|
el.textContent = rect.content.constructor.name |
|
document.body.appendChild(el) |
|
} |
|
|
|
// TypeScript won't assume this for ES5 output for some reason? |
|
declare interface Function { |
|
name: string |
|
} |