clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

The theme is one answer, so it is one menu

Dami committed 3a85d20
A row of buttons says these are all live at once, which is the one thing a
setting with a single answer is not. The blocks already knew this — nine screens
plus AUTO wrapped into three ragged lines and read as a wall, so it became a
pull-down. The theme is the same question with a shorter list: one of a closed
set, or AUTO. So it is the same control, and the two now come out of one
function rather than two copies of the same markup.

Which menu is open is a named thing rather than a flag each, so two menus open at
once stops being a state this sheet can get into. Opening one closes the other
for free.

A menu also closes when you click off it, which is what everyone already expects
and what the sheet had no way of hearing before: the sheet listens for the click,
the menu stops its own from reaching it. Escape still works and so does choosing.

And an open menu dims what is behind it, which lifts it off the sheet and is the
only hint anyone gets that clicking away is a thing you can do. A fifty-percent
stipple rather than an alpha fade — but stippled in *paper*, not ink, which is
the whole trick: over the sheet's own ground it is paper on paper and invisible,
and over a letter or a rule it knocks out every other pixel, so what is behind
reads as dimmed rather than as covered. Ink was backwards on a dark theme, where
the ink is the light colour and stippling with it brightens the very screen it is
meant to be putting to sleep.
commit
3a85d20b1968350630fd4e434cccf8ae8162d124
parent
14c2a9d15ffb83ce5574c3fd4814a90e63a9de37
author
Dami <dami.etoile@gmail.com>
2 files changed, +182 -79
Massets/styles.css24+++-
Msrc/klotski.gleam237++++++++++++++++++++----------
M assets/styles.css +23 -1
@@ -187,6 +187,28 @@ button.ghost { border-color: transparent; }
187187 undoes the whole conceit. This is the QuickDraw menu instead: a hard box, a188188 hairline frame, and inversion to show the highlight. */189189 .menu { position: relative; }190+/* An open pull-down dims the rest of the sheet, the way a Macintosh dimmed191+ anything it had taken over: a fifty-percent stipple, not an alpha fade. It192+ does two jobs — it lifts the menu off the sheet it is standing on, and it says193+ that everything behind it is now somewhere to click rather than somewhere to194+ read, which is the only hint anyone gets that clicking away closes the thing.195+196+ Stippled in *paper* and not in ink, which is the whole trick and is why it197+ works in both polarities. Over the sheet's own ground it is paper on paper and198+ invisible; over a letter or a rule it knocks out every other pixel, so the199+ thing behind reads as dimmed rather than as covered. Ink would have been200+ backwards on a dark theme, where the ink is the light colour and stippling201+ with it brightens the screen it is meant to be putting to sleep. */202+.scrim {203+ position: absolute; inset: 0;204+ z-index: 4;205+ /* Whole units, so the checker lands on whole device pixels like every other206+ rule in this app. Hard stops rather than a gradient, and no alpha: there is207+ no such thing as a half-inked pixel here. */208+ background: conic-gradient(var(--paper) 0 25%, #0000 0 50%, var(--paper) 0 75%, #0000 0);209+ background-size: calc(2px * var(--s)) calc(2px * var(--s));210+}211+.menu.open { z-index: 5; }190212 .menutitle { min-width: calc(84px * var(--s)); text-align: left; }191213 .menutitle::after { content: ' \25BE'; float: right; } /* the little triangle */192214 .menulist {
@@ -208,7 +230,7 @@ button.ghost { border-color: transparent; }
208230 .menulist button:hover, .menulist button.armed { background: var(--ink); color: var(--paper); }209231 /* A rule under AUTO, because it is a different kind of answer from the rest:210232 everything below it is a screen, AUTO is "let the board decide". */211-.menulist button[data-shading="auto"] {233+.menulist button[data-auto] {212234 border-bottom: calc(1px * var(--s)) solid var(--ink);213235 }214236 .credit {
M src/klotski.gleam +159 -78
@@ -164,7 +164,10 @@ pub type Model {
164164 finishing: Option(Float),165165 elapsed: Float,166166 settings_open: Bool,167- shading_open: Bool,167+ /// Which pull-down is open, if any. One field rather than a flag per menu:168+ /// two menus open at once is not a state this sheet has, and two booleans is169+ /// a way of writing it down that permits it.170+ menu_open: Menu,168171 install_available: Bool,169172 last_frame: String,170173 /// The moves a harness probe is waiting on an answer for, in the order they
@@ -174,6 +177,14 @@ pub type Model {
174177 )175178 }176179 180+/// Which pull-down is open. A named thing rather than a flag each, so that two181+/// menus open at once is a state this sheet cannot get into.182+pub type Menu {183+ NoMenu184+ ThemeMenu185+ BlocksMenu186+}187+177188 pub type Message {178189 Ticked(Float)179190 ViewportChanged
@@ -198,7 +209,15 @@ pub type Message {
198209 NextPressed199210 SettingsOpened200211 SettingsClosed201- ShadingMenuToggled212+ MenuToggled(Menu)213+ /// A click that landed on the sheet and not on an open pull-down, which is214+ /// how a menu is dismissed without being answered. Anywhere is outside: the215+ /// sheet covers the screen, and a menu you have to aim at to close is a menu216+ /// that has taken the screen hostage.217+ MenuDismissed218+ /// A click inside an open pull-down. It carries no change of its own — being219+ /// caught before it reaches the sheet is the whole of what it does.220+ MenuHeld202221 PaletteChosen(String)203222 ShadingChosen(Result(Pattern, Nil))204223 HintsChosen(store.Hints)
@@ -263,7 +282,7 @@ fn init(raw: String) -> #(Model, Effect(Message)) {
263282 finishing: None,264283 elapsed: 0.0,265284 settings_open: False,266- shading_open: False,285+ menu_open: NoMenu,267286 install_available: False,268287 last_frame: "",269288 probing: [],
@@ -343,20 +362,31 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
343362 StatsPressed -> go(model, Record)344363 NextPressed -> open_rung(model, model.family, rung_of(model, model.family))345364 SettingsOpened -> #(346- Model(..model, settings_open: True, shading_open: False),365+ Model(..model, settings_open: True, menu_open: NoMenu),347366 effect.none(),348367 )349368 SettingsClosed -> #(350- Model(..model, settings_open: False, shading_open: False),369+ Model(..model, settings_open: False, menu_open: NoMenu),351370 effect.none(),352371 )353- ShadingMenuToggled -> #(354- Model(..model, shading_open: !model.shading_open),372+ MenuDismissed -> #(Model(..model, menu_open: NoMenu), effect.none())373+ MenuHeld -> #(model, effect.none())374+ // Opening one closes the other, which is what a single field buys.375+ MenuToggled(menu) -> #(376+ Model(..model, menu_open: case model.menu_open == menu {377+ True -> NoMenu378+ False -> menu379+ }),355380 effect.none(),356381 )357382 358383 PaletteChosen(palette) -> {359- let model = Model(..model, save: store.Save(..model.save, palette:))384+ let model =385+ Model(386+ ..model,387+ save: store.Save(..model.save, palette:),388+ menu_open: NoMenu,389+ )360390 #(361391 model,362392 effect.batch([persist(model), render.clear_sprites(), repaint(model)]),
@@ -367,7 +397,7 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
367397 Model(368398 ..model,369399 save: store.Save(..model.save, shading: pattern),370- shading_open: False,400+ menu_open: NoMenu,371401 )372402 // The screen is baked into the sprites.373403 #(
@@ -585,7 +615,7 @@ fn sine(v: Float) -> Float
585615 // --------------------------------------------------------------------- flow586616 587617 fn go(model: Model, screen: Screen) -> #(Model, Effect(Message)) {588- let model = Model(..model, screen:, settings_open: False, shading_open: False)618+ let model = Model(..model, screen:, settings_open: False, menu_open: NoMenu)589619 #(model, effect.batch([page(model), refresh_previews(model), paint(model)]))590620 }591621
@@ -1395,8 +1425,8 @@ fn atlas_tap(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {
13951425 fn key_pressed(model: Model, key: String) -> #(Model, Effect(Message)) {13961426 case key, model.screen {13971427 "Escape", _ ->1398- case model.shading_open, model.settings_open {1399- True, _ -> #(Model(..model, shading_open: False), effect.none())1428+ case model.menu_open != NoMenu, model.settings_open {1429+ True, _ -> #(Model(..model, menu_open: NoMenu), effect.none())14001430 False, True -> #(Model(..model, settings_open: False), effect.none())14011431 False, False -> #(model, effect.none())14021432 }
@@ -2077,78 +2107,57 @@ fn sheet(model: Model) -> Element(Message) {
20772107 attribute.attribute("role", "dialog"),20782108 attribute.attribute("aria-modal", "true"),20792109 attribute.attribute("aria-label", "Settings"),2110+ event.on_click(MenuDismissed),20802111 ],20812112 [2113+ // The dim behind an open pull-down. Rendered before everything so it is2114+ // behind everything, and inside the sheet so a click on it is a click on2115+ // the sheet, which is already what closes a menu.2116+ case model.menu_open == NoMenu {2117+ True -> element.none()2118+ False -> html.div([attribute.class("scrim")], [])2119+ },20822120 // The three that change how the picture looks come first and together:20832121 // what it is printed in, what the blocks are printed with, and how finely.20842122 // Reading them apart, with the hints and the sound in between, made three20852123 // settings about one thing look like three unrelated ones.2086- group("THEME", [2087- // AUTO first, and the default for anyone who has not chosen: the phone2088- // was already told whether it wants a dark screen, and asking again is2089- // asking someone to state a preference they have stated.2090- setting("AUTO", model.save.palette == "auto", PaletteChosen("auto")),2091- ..list.map(theme.all(), fn(t) {2092- setting(t.name, model.save.palette == t.id, PaletteChosen(t.id))2093- })2094- ]),2095- // A pull-down rather than a row of buttons: eight shapes plus AUTO wrapped2096- // into three ragged lines and read as a wall. Built out of real elements,2097- // not a <select> — a native menu is the one control the platform insists2098- // on drawing in its own house style, and it would be the only rounded,2099- // shaded, system-blue thing on screen. The ruling is not here at all; it2100- // is not a preference.2101- html.div([attribute.class("group")], [2102- html.div([attribute.class("grouplbl")], [html.text("BLOCKS")]),2103- html.div(2104- [2105- attribute.class(case model.shading_open {2106- True -> "menu open"2107- False -> "menu"2108- }),2109- ],2110- [2111- html.button(2112- [2113- attribute.class("menutitle"),2114- attribute.attribute("aria-haspopup", "listbox"),2115- event.on_click(ShadingMenuToggled),2116- ],2117- [2118- html.text(case model.save.shading {2119- Ok(pattern) -> shading.pattern_label(pattern)2120- Error(_) -> "AUTO"2121- }),2122- ],2123- ),2124- html.div(2125- [attribute.class("menulist"), attribute.attribute("role", "listbox")],2126- [2127- // A rule under AUTO, because it is a different kind of answer2128- // from the rest: everything below it is a screen, AUTO is "let2129- // the board decide".2130- html.button(2131- [2132- attribute.attribute("data-shading", "auto"),2133- attribute.class(armed(model.save.shading == Error(Nil))),2134- event.on_click(ShadingChosen(Error(Nil))),2135- ],2136- [html.text("AUTO")],2137- ),2138- ..list.map(shading.patterns(), fn(pattern) {2139- html.button(2140- [2141- attribute.class(armed(model.save.shading == Ok(pattern))),2142- event.on_click(ShadingChosen(Ok(pattern))),2143- ],2144- [html.text(shading.pattern_label(pattern))],2145- )2146- })2147- ],2148- ),2149- ],2150- ),2151- ]),2124+ //2125+ // Both of the first two are pull-downs. The blocks always were — nine2126+ // answers wrapped into three ragged lines and read as a wall — and the2127+ // theme now is too, because it is the same question: one of a closed list,2128+ // or AUTO. A row of buttons said "these are all live at once", which is2129+ // the one thing a setting with a single answer is not. The ruling is not2130+ // here at all; it is not a preference.2131+ pull_down(2132+ "THEME",2133+ case model.save.palette {2134+ "auto" -> "AUTO"2135+ id -> theme.by_id(id).name2136+ },2137+ model.menu_open == ThemeMenu,2138+ MenuToggled(ThemeMenu),2139+ #(model.save.palette == "auto", PaletteChosen("auto")),2140+ list.map(theme.all(), fn(t) {2141+ #(t.name, model.save.palette == t.id, PaletteChosen(t.id))2142+ }),2143+ ),2144+ pull_down(2145+ "BLOCKS",2146+ case model.save.shading {2147+ Ok(pattern) -> shading.pattern_label(pattern)2148+ Error(_) -> "AUTO"2149+ },2150+ model.menu_open == BlocksMenu,2151+ MenuToggled(BlocksMenu),2152+ #(model.save.shading == Error(Nil), ShadingChosen(Error(Nil))),2153+ list.map(shading.patterns(), fn(pattern) {2154+ #(2155+ shading.pattern_label(pattern),2156+ model.save.shading == Ok(pattern),2157+ ShadingChosen(Ok(pattern)),2158+ )2159+ }),2160+ ),21522161 group("INTENSITY", [21532162 setting(21542163 store.density_label(store.Low),
@@ -2178,6 +2187,78 @@ fn sheet(model: Model) -> Element(Message) {
21782187 )21792188 }21802189 2190+/// A pull-down, and both of the sheet's menus are one of these.2191+///2192+/// Built out of real elements rather than a `<select>`: a native menu is the one2193+/// control the platform insists on drawing in its own house style, and it would2194+/// be the only rounded, shaded, system-blue thing on screen. So this is the2195+/// QuickDraw menu instead — a hard box, a hairline frame, inversion for the2196+/// highlight, and a solid drop shadow.2197+///2198+/// AUTO is always first and always ruled off from what follows, because it is a2199+/// different kind of answer: everything below it names a thing, and AUTO says2200+/// let something else decide.2201+///2202+/// The click that opens it, and every click inside it, is stopped here. The2203+/// sheet behind is listening for everything else, so a click anywhere off the2204+/// menu closes it.2205+fn pull_down(2206+ label: String,2207+ title: String,2208+ open: Bool,2209+ toggle: Message,2210+ automatic: #(Bool, Message),2211+ options: List(#(String, Bool, Message)),2212+) -> Element(Message) {2213+ let #(auto_on, auto_message) = automatic2214+ html.div([attribute.class("group")], [2215+ html.div([attribute.class("grouplbl")], [html.text(label)]),2216+ html.div(2217+ [2218+ attribute.class(case open {2219+ True -> "menu open"2220+ False -> "menu"2221+ }),2222+ event.on_click(MenuHeld) |> event.stop_propagation,2223+ ],2224+ [2225+ html.button(2226+ [2227+ attribute.class("menutitle"),2228+ attribute.attribute("aria-haspopup", "listbox"),2229+ attribute.attribute("aria-expanded", case open {2230+ True -> "true"2231+ False -> "false"2232+ }),2233+ event.on_click(toggle),2234+ ],2235+ [html.text(title)],2236+ ),2237+ html.div(2238+ [attribute.class("menulist"), attribute.attribute("role", "listbox")],2239+ [2240+ html.button(2241+ [2242+ attribute.attribute("data-auto", ""),2243+ attribute.class(armed(auto_on)),2244+ event.on_click(auto_message),2245+ ],2246+ [html.text("AUTO")],2247+ ),2248+ ..list.map(options, fn(option) {2249+ let #(name, on, message) = option2250+ html.button(2251+ [attribute.class(armed(on)), event.on_click(message)],2252+ [html.text(name)],2253+ )2254+ })2255+ ],2256+ ),2257+ ],2258+ ),2259+ ])2260+}2261+21812262 fn group(label: String, controls: List(Element(Message))) -> Element(Message) {21822263 html.div([attribute.class("group")], [21832264 html.div([attribute.class("grouplbl")], [html.text(label)]),