Describing
describe: a sketch that says what it shows, for a screen reader
- Run
swift run --package-path Examples Example-Basic-Describing- Source
- Examples/Basic/Describing/Sketch.swift
import Ollin
/// A day passing over a bay, and the sketch saying what it shows while it
/// shows it. Turn on VoiceOver (⌘F5) and the window reads back the sentence
/// under `describe`, then each named part in turn.
///
/// The point of the example is where the words come from: the same numbers that
/// place the sun write the sentence about the sun, so the description cannot
/// drift away from the picture. Nothing here is written twice.
///
/// Two things worth watching. The sun is dropped from the description when it
/// sets, because a part that has left the picture must leave the words with it.
/// And the glow and the shimmer on the water are never described: they are
/// texture, not meaning, and a list of every shape tells nobody what the piece
/// looks like.
///
/// The `showWords` parameter draws the description on the canvas, so you can read
/// what somebody using a screen reader is given.
@main
final class Describing: Sketch {
/// Draw the description as well as saying it, so it can be checked by eye.
@Param(icon: "text.bubble") var showWords = true
let day = 24.0 // one day per 24 seconds
override var loopDuration: Double? { day }
override func setup() {
textFont(.systemMedium)
}
override func draw() {
// The day opens a little after sunrise, so the sun is in the picture on
// the first frame and takes the first place in the reading order.
let phase = loopProgress(over: day, phase: 0.06)
let daylight = max(0, sin(phase * .tau)) // 0 at night, 1 at noon
// The sun moves along a half circle over the bay, and drops below it at night.
let horizon = height * 0.68
let sunRadius = width * 0.055
let sunCenter = Vector2(width * (0.1 + 0.8 * phase),
horizon - sin(phase * .tau) * height * 0.5)
let sun = Rectangle(center: sunCenter, width: sunRadius * 2, height: sunRadius * 2)
let sunIsUp = sunCenter.y + sunRadius < horizon
// A boat crosses the water once a day, the other way round.
let boatX = width * (0.95 - 0.9 * phase)
let boat = Rectangle(center: Vector2(boatX, horizon + height * 0.14),
width: width * 0.1, height: height * 0.06)
drawScene(daylight: daylight, horizon: horizon, sun: sun, boat: boat)
// What the sketch says about itself, written from the same numbers.
describe("A bay at \(timeOfDay(phase)). \(sky(sunIsUp: sunIsUp, at: sunCenter)), "
+ "and a small boat crosses the water \(boatHeading(phase)).")
// A part that is no longer in the picture is dropped from the
// description: empty words remove it.
describe("the sun", as: sunIsUp
? "\(sunColor(daylight)) disc \(place(of: sunCenter)), \(Int(sunRadius * 2)) wide"
: "", in: sun)
describe("the water", as: "a flat band across the lower third, "
+ "\(daylight > 0.15 ? "blue-gray and lit" : "almost black")",
in: Rectangle(x: 0, y: horizon, width: width, height: height - horizon))
describe("the boat", as: "a small dark hull with one sail, "
+ "\(Int(boatX / width * 100))% of the way across", in: boat)
if showWords { drawWords() }
}
// MARK: - The picture
private func drawScene(daylight: Double, horizon: Double, sun: Rectangle, boat: Rectangle) {
background(Color.mix(Color(hex: 0x121A2B), Color(hex: 0x9EC6E8), daylight))
noStroke()
// The sun, with a soft glow that fades as it drops.
let disc = Color.mix(Color(hex: 0xE8DCC8), Color(hex: 0xFFE9A8), daylight)
for ring in stride(from: 5.0, through: 1.0, by: -1.0) {
fill(disc.withAlpha(0.055 * daylight))
drawCircle(center: sun.center, radius: sun.width / 2 * ring)
}
fill(disc)
drawCircle(center: sun.center, radius: sun.width / 2)
// The water, and a few shimmer bands lying on it.
let water = Color.mix(Color(hex: 0x0B1220), Color(hex: 0x3E7CA6), daylight)
fill(water)
drawRect(0, horizon, width, height - horizon)
for i in 0 ..< 9 {
let y = horizon + (height - horizon) * (Double(i) + 0.5) / 9
let sway = sin(time * 0.6 + Double(i)) * width * 0.06
fill(Color.white.withAlpha(0.05 * daylight))
drawRect(center: Vector2(width * 0.5 + sway, y),
width: width * (0.5 - Double(i) * 0.04), height: 3 * scale)
}
// The boat: a hull and one sail.
fill(Color(hex: 0x16202E))
drawTriangle(Vector2(boat.center.x, boat.corner.y),
Vector2(boat.center.x + boat.width * 0.32, boat.center.y),
Vector2(boat.center.x, boat.center.y))
drawRect(center: Vector2(boat.center.x, boat.center.y + boat.height * 0.22),
width: boat.width * 0.75, height: boat.height * 0.2)
}
/// Draw the description, so what is said and what is drawn can be compared.
private func drawWords() {
withState {
noStroke()
let lines = accessibleDescription.lines
let lineHeight = 26 * scale
fill(Color(red: 0, green: 0, blue: 0, alpha: 0.55))
drawRect(0, 0, width, lineHeight * Double(lines.count) + 24 * scale)
fill(.white)
textSize(14 * scale)
textAlign(.left, .middle)
for (i, line) in lines.enumerated() {
drawText(line, 20 * scale, 22 * scale + Double(i) * lineHeight)
}
}
}
// MARK: - The words
/// The sun rises at phase 0 and sets at phase 0.5, so the names have to
/// change on the same numbers the sun does.
private func timeOfDay(_ phase: Double) -> String {
switch phase {
case ..<0.05: "dawn"
case ..<0.20: "mid morning"
case ..<0.32: "noon"
case ..<0.45: "late afternoon"
case ..<0.50: "dusk"
default: "night"
}
}
private func sky(sunIsUp: Bool, at p: Vector2) -> String {
sunIsUp ? "The sun stands \(place(of: p))" : "The sky is empty and dark"
}
private func sunColor(_ daylight: Double) -> String {
daylight > 0.5 ? "a pale yellow" : "a deep orange"
}
/// Where something is, in the words a person would use.
private func place(of p: Vector2) -> String {
let up = p.y < height * 0.3 ? "high" : p.y < height * 0.6 ? "halfway up" : "low"
let across = p.x < width * 0.4 ? "left" : p.x > width * 0.6 ? "right" : "middle"
return "\(up) on the \(across)"
}
private func boatHeading(_ phase: Double) -> String {
phase < 0.5 ? "from the right" : "toward the left"
}
}