1package app23import (4 "encoding/json"5 "errors"6 "fmt"7 "log/slog"8 "net/http"9 "sort"10 "strconv"11 "strings"12 "time"1314 "github.com/FelineStateMachine/atlas/format/vnext"15 "github.com/FelineStateMachine/atlas/internal/app/cells"16 "github.com/FelineStateMachine/atlas/internal/app/hostenv"17 "github.com/FelineStateMachine/atlas/internal/logging"18)1920// The session record is the server's half of the state-placement rule21// (issue #5 §4.1): discrete application state lives on the server and in URLs,22// continuous interaction state lives in the seam. Everything a reader would be23// annoyed to lose across a restart is here; everything that changes sixty24// times a second is not, except the camera, which reports upward once per25// settle so "reopen where you left off" survives a restart.26//27// One record per volume, plus a small record naming the volume last open. The28// schema is documented in docs/app.md and versioned by SessionSchema: a record29// written by a newer schema is ignored rather than half-read, which costs a30// reader their layout once and never corrupts it.3132// SessionSchema is the version of the record shape below.33//34// Version 2 is the templates wave. The arrangement grew the fields the legend35// tree actually has -- which shape rows are unfolded, which ground is36// highlighted, where the corner locator and the panel are put -- and lost the37// solo list, because isolating turned out to be a move on the hide set rather38// than a state of its own (see applySolo, and legend.go's soloChip, which39// derives the chip from the hide set so it is right however that set was40// reached). A version-1 record is passed over rather than half-read, which41// costs a reader their layout once.42const SessionSchema = 24344// Session is one volume's remembered state.45type Session struct {46 Schema int `json:"schema"`47 Volume string `json:"volume"`4849 // Stamp is the serving build the record was last written against. A50 // record whose stamp is behind the serving build is still read -- slugs51 // outlive builds -- but the difference is what tells the events stream52 // that the page in front of the reader is looking at an older volume.53 Stamp string `json:"stamp,omitempty"`5455 World string `json:"world,omitempty"`56 Lens string `json:"lens,omitempty"`5758 // Hidden collections, Collapsed sections, Expanded shape rows and59 // Highlighted features are named by their ids in the world payload; all60 // four are kept sorted so a record is stable to diff and two paths to61 // the same state produce the same bytes.62 //63 // Arranged is what tells a fresh record from an arranged one. The hide64 // set has a non-empty default that a world's own curation supplies --65 // the payload's hidden collections -- and an empty set is a reader's66 // deliberate "show everything", not an absence. Without this flag the67 // two are the same bytes.68 Hidden []string `json:"hidden,omitempty"`69 Collapsed []string `json:"collapsed,omitempty"`70 Expanded []string `json:"expanded,omitempty"`71 Highlighted []string `json:"highlighted,omitempty"`72 Arranged bool `json:"arranged,omitempty"`7374 // Labels is the per-collection label-policy override: collection id to75 // policy name. An absent entry means the policy the conventions imply.76 Labels map[string]string `json:"labels,omitempty"`7778 Search string `json:"search,omitempty"`79 Dock Dock `json:"dock"`80 Detail Detail `json:"detail"`81 Grid Grid `json:"grid"`82 Sidebar Sidebar `json:"sidebar"`83 Overview Overview `json:"overview"`84 Selected string `json:"selected,omitempty"`8586 // Focused is the ground the reader last went to from a list, and it is a87 // different fact from Selected: closing the card puts the selection down88 // and leaves the index still marking where the reader has been. The89 // reference kept exactly this (`focusedZoneID`), cleared it whenever the90 // ground itself was rebuilt -- a world opened, a split world's layer91 // swapped -- and never on a card being closed.92 Focused string `json:"focused,omitempty"`9394 // Cameras is the last settled camera per world, reported by the seam.95 // It is the one piece of continuous state the server keeps, and it is96 // kept because a reader expects a volume to open where they left it.97 Cameras map[string]Camera `json:"cameras,omitempty"`9899 UpdatedAt string `json:"updatedAt,omitempty"`100}101102// Dock is the readout beside the map: whether it is open, which section of it103// is showing, and whether the reader has put it away themselves.104//105// The panel starts out of the way, because the map is the reason the window is106// open, and comes out by itself the first time it has something to say. Once107// -- and only until the reader has answered the question by folding it by108// hand, which is what Dismissed records. After that it stays where they put it109// and the map is theirs.110type Dock struct {111 Open bool `json:"open"`112 Dismissed bool `json:"dismissed,omitempty"`113 Section string `json:"section,omitempty"`114}115116// Detail is the card for one feature.117type Detail struct {118 Open bool `json:"open"`119}120121// Grid is the cell system in play: which system, which cell is held, and how122// far the subgrid is opened under it.123type Grid struct {124 System string `json:"system,omitempty"`125 Cell string `json:"cell,omitempty"`126 Subgrid int `json:"subgrid,omitempty"`127}128129// Sidebar is the legend column. It is spelled as the thing a reader does to130// it rather than the state it is usually in, so the zero value is the ordinary131// page: a sidebar nobody has collapsed.132type Sidebar struct {133 Collapsed bool `json:"collapsed,omitempty"`134}135136// Overview is the corner locator. Docked means put away against the edge --137// the same shape of choice as the panel, and remembered with the volume rather138// than with one of its worlds, because where the corner of the screen is139// wanted is a preference about the atlas.140type Overview struct {141 Docked bool `json:"docked,omitempty"`142}143144// Camera is one world's settled view, in the seam's own terms. The server145// stores it and hands it back; it never reasons about it.146type Camera struct {147 X float64 `json:"x"`148 Y float64 `json:"y"`149 Zoom float64 `json:"zoom"`150 Rotation float64 `json:"rotation,omitempty"`151 At string `json:"at,omitempty"`152}153154// pointer is the small record naming the volume the reader was last in, so /155// can send them back to it.156type pointer struct {157 Schema int `json:"schema"`158 Volume string `json:"volume,omitempty"`159 UpdatedAt string `json:"updatedAt,omitempty"`160}161162const pointerRecord = "app.json"163164// sessionRecord is the record name one volume's session is kept under. The165// slug has already passed bundle.ValidSlug by being in a manifest, and166// hostenv.ValidName holds it again at the store.167func sessionRecord(slug string) string { return "volume." + slug + ".json" }168169// session reads one volume's record, answering a fresh one where there is170// nothing to read. A record from a schema this build does not know is passed171// over the same way: a reader loses their layout once, rather than meeting a172// page assembled out of half-understood state.173func (a *App) session(slug string) Session {174 fresh := Session{Schema: SessionSchema, Volume: slug}175 data, err := a.env.Sessions().Load(sessionRecord(slug))176 if err != nil {177 return fresh178 }179 var held Session180 if err := json.Unmarshal(data, &held); err != nil || held.Schema != SessionSchema {181 return fresh182 }183 held.Volume = slug184 return held185}186187// saveSession writes one volume's record.188func (a *App) saveSession(s *Session) error {189 s.Schema = SessionSchema190 s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)191 sort.Strings(s.Hidden)192 sort.Strings(s.Collapsed)193 sort.Strings(s.Expanded)194 sort.Strings(s.Highlighted)195 data, err := json.Marshal(s)196 if err != nil {197 return fmt.Errorf("encode session: %w", err)198 }199 return a.env.Sessions().Save(sessionRecord(s.Volume), data)200}201202// lastVolume is the volume the reader was last in, or empty.203func (a *App) lastVolume() string {204 data, err := a.env.Sessions().Load(pointerRecord)205 if err != nil {206 if !errors.Is(err, hostenv.ErrNoSession) {207 return ""208 }209 return ""210 }211 var held pointer212 if err := json.Unmarshal(data, &held); err != nil || held.Schema != SessionSchema {213 return ""214 }215 return held.Volume216}217218// rememberVolume records which volume is in front of the reader. It is219// written when a volume's page is served, because a real URL typed or220// bookmarked is as much a choice as one clicked.221func (a *App) rememberVolume(slug string) error {222 data, err := json.Marshal(pointer{223 Schema: SessionSchema,224 Volume: slug,225 UpdatedAt: time.Now().UTC().Format(time.RFC3339),226 })227 if err != nil {228 return fmt.Errorf("encode pointer: %w", err)229 }230 return a.env.Sessions().Save(pointerRecord, data)231}232233// A concern is one interaction the session answers for. One route per concern234// (issue #5 §4.2), each declaring the regions its answer covers, so a response235// carries exactly the partials the interaction touched and no page-wide236// refresh stands in for thinking about it.237//238// The region set beside each concern is the whole table of what an interaction239// may move; partialTargets (partials.go) is the whole table of where a region240// goes. Between them there is no third place where a swap is decided.241type concern struct {242 apply func(c *concernContext, form formValues) error243 regions []string244245 // whole marks the one concern whose answer is the page rather than a246 // region set. It is the reset, and it earns the exception by deleting the247 // record every other answer is computed from: there is no arrangement248 // left to render partials out of, and nothing on the page is still249 // standing for a partial to land in. A region list here would be a list250 // of every region there is, which is a page-wide refresh wearing the251 // envelope's clothes.252 whole bool253}254255// concernContext is what a concern gets to work with: the record it is256// changing and the world that record is about. Several of the moves are257// algebra over the world's own collections -- isolating, folding a whole258// section, unfolding every shape row -- and the alternative to handing the259// model over is a client that computes the answer and posts a set, which is260// the display logic leaking back into the page.261type concernContext struct {262 session *Session263 world *worldModel264}265266// concerns is the table of session routes. One line per concern; adding an267// interaction is adding a line and a function.268//269// Reading the regions column downward is the fastest way to see what the270// application thinks is coupled to what: every filtering move touches the271// legend (its own rows and the isolate chip), the dock (the count and the272// list), and the viewport's state node (what the seam draws). Nothing touches273// the topbar but the things that change what is on offer.274var concerns = map[string]concern{275 "world": {apply: applyWorld, regions: []string{"topbar", "legend", "dock", "overview", "viewport"}},276 // A lens is usually a different picture of one ground and moves nothing277 // but the raster. On a split sheet it is a different *layer*, and the278 // ground under the reader changes with it: which shapes the index lists,279 // which features the panel can name, what the footer counts. The legend280 // and the dock are therefore in the set -- a lens swap that left them281 // showing the layer it came from is a page half in one world.282 "lens": {apply: applyLens, regions: []string{"topbar", "legend", "dock", "overview", "viewport"}},283 "collections": {apply: applyCollections, regions: []string{"legend", "dock", "viewport"}},284 "sections": {apply: applySections, regions: []string{"legend"}},285 "expand": {apply: applyExpand, regions: []string{"legend"}},286 "labels": {apply: applyLabels, regions: []string{"legend", "viewport"}},287 "solo": {apply: applySolo, regions: []string{"legend", "dock", "viewport"}},288 "search": {apply: applySearch, regions: []string{"legend", "dock", "viewport"}},289 "highlight": {apply: applyHighlight, regions: []string{"legend", "dock", "viewport"}},290 "dock": {apply: applyDock, regions: []string{"dock"}},291 "select": {apply: applySelect, regions: []string{"legend", "dock", "detail", "viewport"}},292 "grid": {apply: applyGrid, regions: []string{"grid-navigator", "dock", "viewport"}},293 "overview": {apply: applyOverview, regions: []string{"overview"}},294 // The camera report names no region. It is a debounced upward whisper295 // rather than an interaction, and swapping any of the chrome in response296 // to a settling camera would fight the reader's own hand. It still297 // answers with the island the dispatcher appends -- an inert script node298 // with no focus to lose, no scroll to reset and nothing on screen that299 // moves -- because otherwise the camera it just wrote would be readable300 // only after the next unrelated request, and the baselines record it on301 // their very first step.302 "view": {apply: applyView},303 "sidebar": {apply: applySidebar, regions: []string{"shell"}},304 // The blunt reset, and the only line in this table with no function305 // beside it: what it does is delete the record the functions above work306 // on, which is resetSession's rather than a move on a Session nobody is307 // going to write. It answers with the volume's own address again (see308 // resetSession), and a reader gets back the arrangement the world asks309 // for on a page they did not have to find their way to.310 "reset": {whole: true},311}312313// formValues is the parsed body of a session POST, read through accessors so314// a concern never has to think about what a missing field looks like.315type formValues struct{ values map[string][]string }316317func (f formValues) get(name string) string {318 if held := f.values[name]; len(held) > 0 {319 return strings.TrimSpace(held[0])320 }321 return ""322}323324func (f formValues) has(name string) bool { _, sent := f.values[name]; return sent }325326// on reads a checkbox-shaped field: present and not a negative word is on.327func (f formValues) on(name string) bool {328 switch strings.ToLower(f.get(name)) {329 case "", "0", "off", "false", "no", "closed", "hidden":330 return false331 default:332 return true333 }334}335336func (f formValues) number(name string) (float64, bool) {337 value, err := strconv.ParseFloat(f.get(name), 64)338 return value, err == nil339}340341// handleSession is every /session/* route. It reads the volume the342// interaction is about, applies the concern, writes the record, and answers343// with the partials the concern declares.344func (a *App) handleSession(w http.ResponseWriter, r *http.Request) {345 name := r.PathValue("concern")346 held, known := concerns[name]347 if !known {348 http.NotFound(w, r)349 return350 }351 if err := r.ParseForm(); err != nil {352 http.Error(w, "the session request could not be read", http.StatusBadRequest)353 return354 }355 form := formValues{values: r.PostForm}356 // One record, one writer at a time. Everything from here to the write is357 // a read-modify-write of the same bytes.358 a.writing.Lock()359 defer a.writing.Unlock()360361 slug := form.get("volume")362 if err := vnext.ValidSlug(slug); err != nil {363 http.Error(w, "the session request names no volume", http.StatusBadRequest)364 return365 }366 library := a.library()367 volume, serving := library.bySlug[slug]368 if !serving {369 // The volume left the library between the page being rendered and370 // this interaction. Say so plainly; the events stream is what tells371 // the page to catch up.372 http.Error(w, "that volume is not installed", http.StatusNotFound)373 return374 }375376 if held.whole {377 a.resetSession(w, volume, slug)378 return379 }380381 session := a.session(slug)382 session.Stamp = vnext.ShortStamp(volume.Info().Stamp)383 a.arrange(volume, &session)384 ctx := &concernContext{session: &session, world: a.world(volume, session.World)}385 if err := held.apply(ctx, form); err != nil {386 http.Error(w, err.Error(), http.StatusBadRequest)387 return388 }389 if err := a.saveSession(&session); err != nil {390 http.Error(w, "the session could not be written", http.StatusInternalServerError)391 return392 }393394 // Every answer carries the island, because every answer has just written395 // the record the island publishes. It is inert, it is last, and it is396 // what makes the server's half of the joint diagnostics a reading of the397 // record rather than a reading of the last page render.398 a.writePartials(w, append(held.regions, "island"), a.view(library, volume, session))399}400401// resetSession is the reset ⌘R presses: this volume's record is deleted, and402// the reader is sent back into the volume they are already in.403//404// It is deliberately blunt. Everything a record holds goes at once -- what is405// hidden, folded, unfolded, highlighted and labelled, the search, the panel,406// the card, the grid, the index, the corner locator, every world's camera --407// with one exception written back afterwards: the world and lens the reader408// was standing in, because a refresh is not a departure --409// because the reason to reach for it is a record that has gone wrong in a way410// nobody wants to diagnose one field at a time. Nothing synthesizes defaults411// here: the next load reads no record, and arrange builds the arrangement the412// world itself asks for, which is the one definition of fresh there is.413//414// WHAT IS NOT TOUCHED is the last-volume pointer. The reader is not leaving;415// they are standing in the same volume with nothing remembered about it, and416// deleting app.json as well would answer the next / with a library card.417//418// The answer is a redirect rather than a partial set, and the round trip is419// the point twice over: after a reset every region is stale, and a reader who420// pressed the refresh key is owed the thing refreshing does. It is spelled as421// the header htmx acts on itself -- a 303 would be followed by fetch and422// swapped into a page that asked for no swap, which is a request that appears423// to do nothing at all.424func (a *App) resetSession(w http.ResponseWriter, volume hostenv.Volume, slug string) {425 // Where to land is read before the record goes, because the record is426 // what knows which world the reader is standing in. A record that names a427 // world this build no longer serves -- or no record at all, which is a428 // reset pressed twice -- falls back to the volume's first world, and a429 // volume with no worlds to fall back to falls all the way back to /,430 // which sends the reader to the volume they were last in.431 info := volume.Info()432 held := a.session(slug)433 world := held.World434 if _, serving := worldEntry(info, world); !serving {435 world = ""436 if len(info.Worlds) > 0 {437 world = info.Worlds[0].Slug438 }439 }440 where := "/"441 if world != "" {442 where = "/v/" + slug + "/" + world443 }444 if err := a.env.Sessions().Delete(sessionRecord(slug)); err != nil {445 slog.Error("clearing a session record", logging.Op("session"),446 slog.String("volume", slug), slog.Any("error", err))447 http.Error(w, "the session could not be cleared", http.StatusInternalServerError)448 return449 }450 // The one thing a reset keeps is the reader's place: the world and the451 // lens they were looking at. Everything about HOW they were looking at it452 // goes; WHERE they stood does not -- a refresh that also teleported would453 // be answering a question nobody pressed the key to ask.454 if world != "" && held.Lens != "" {455 if err := a.saveSession(&Session{Volume: slug, World: world, Lens: held.Lens}); err != nil {456 slog.Warn("keeping the place through a reset", logging.Op("session"),457 slog.String("volume", slug), slog.Any("error", err))458 }459 }460 slog.Info("session reset", logging.Op("session"),461 slog.String("volume", slug), slog.String("back", where))462 w.Header().Set("HX-Redirect", where)463 w.WriteHeader(http.StatusNoContent)464}465466// arrange fills a fresh record with the arrangement the world itself asks for467// before anybody has arranged it: the collections the payload marks invisible468// are put away, the viewer's own Zones section is folded, and the ungrouped469// shape rows are unfolded so their feature indexes are there the moment the470// section is opened.471//472// It runs exactly once per record, which is what Arranged records. An empty473// hide set afterwards is a reader who asked to see everything, and must not be474// mistaken for a record nobody has touched.475func (a *App) arrange(volume hostenv.Volume, s *Session) {476 a.arrangeWith(volume, s, a.world)477}478479func (a *App) arrangePageZero(volume hostenv.Volume, s *Session) {480 a.arrangeWith(volume, s, a.outlineWorld)481}482483func (a *App) arrangeWith(484 volume hostenv.Volume,485 s *Session,486 load func(hostenv.Volume, string) *worldModel,487) {488 if s.Arranged {489 return490 }491 if s.World == "" {492 info := volume.Info()493 if len(info.Worlds) == 0 {494 return495 }496 s.World = info.Worlds[0].Slug497 }498 model := load(volume, s.World)499 if model == nil {500 return501 }502 s.Hidden = defaultHidden(model)503 s.Collapsed = defaultCollapsed()504 // Every feature index arrives folded. The sections stand open -- the505 // categories are the world's summary -- but a category's members are a506 // list the reader asks for, one press here or one on unfold-all.507 s.Expanded = nil508 // The subdivision is shown by default: a grid the reader opens is a grid509 // with its next level drawn, and the baselines record `subgridVisible`510 // true from the first step of every volume -- before any grid is open at511 // all, because the setting outlives the grid being closed.512 if s.Grid.Subgrid == 0 {513 s.Grid.Subgrid = 1514 }515 s.Arranged = true516}517518func applyWorld(c *concernContext, form formValues) error {519 world := form.get("world")520 if err := vnext.ValidSlug(world); err != nil {521 return fmt.Errorf("world: %w", err)522 }523 if world != c.session.World {524 // A world is a different ground. A selection, a highlight, a search525 // and a held cell all belong to the ground they were made on, and526 // the arrangement is the new world's to supply.527 c.session.Selected = ""528 c.session.Focused = ""529 c.session.Detail.Open = false530 c.session.Highlighted = nil531 c.session.Search = ""532 c.session.Grid.Cell = ""533 c.session.Labels = nil534 c.session.Lens = ""535 c.session.Arranged = false536 // A map opens on the map. The panel is away until something gives it537 // a reason to come out, and a world nobody has arranged yet has not538 // been put away by hand either -- which is what lets it open itself539 // again on the first search of the new ground.540 c.session.Dock = Dock{Section: c.session.Dock.Section}541 }542 c.session.World = world543 return nil544}545546func applyLens(c *concernContext, form formValues) error {547 name := form.get("lens")548 // A lens is usually a different picture of the same ground and the reader549 // keeps their place in it. A lens drawing another *layer* of a split world550 // is different ground, and the ground the reader last went to is not on it.551 if lensShard(c.world, name) != lensShard(c.world, c.session.Lens) {552 c.session.Focused = ""553 }554 c.session.Lens = name555 return nil556}557558// lensShard is the layer a named lens draws, or the first lens's when the name559// is empty and nothing has been chosen yet.560func lensShard(model *worldModel, name string) int {561 if model == nil || len(model.Lenses) == 0 {562 return 0563 }564 for at := range model.Lenses {565 if model.Lenses[at].Name == name {566 return model.Lenses[at].Shard567 }568 }569 return model.Lenses[0].Shard570}571572// applyCollections carries every move on the hide set: one row toggled, one573// section's switch thrown, everything shown or everything put away, or the574// whole set replaced at once.575func applyCollections(c *concernContext, form formValues) error {576 s := c.session577 if replacement, sent := form.values["hidden"]; sent {578 s.Hidden = trimmed(replacement)579 return nil580 }581 switch form.get("all") {582 case "show":583 s.Hidden = nil584 return nil585 case "hide":586 s.Hidden = everyCollection(c.world)587 return nil588 }589 if section := form.get("section"); section != "" {590 s.Hidden = toggleSection(c.world, setOf(s.Hidden), section)591 return nil592 }593 id := form.get("collection")594 if id == "" {595 return errors.New("collections: no collection named")596 }597 s.Hidden = toggle(s.Hidden, id, !form.on("visible"))598 return nil599}600601// applySections folds legend sections. There is one level of nesting per move602// -- sections hold rows, rows may hold a feature index -- so folding by a depth603// and folding entirely are the same move, and only the one exists.604func applySections(c *concernContext, form formValues) error {605 s := c.session606 switch form.get("all") {607 case "fold":608 s.Collapsed = everySection(c.world)609 return nil610 case "unfold":611 s.Collapsed = nil612 return nil613 }614 id := form.get("section")615 if id == "" {616 return errors.New("sections: no section named")617 }618 s.Collapsed = toggle(s.Collapsed, id, !form.on("open"))619 return nil620}621622// applyExpand unfolds a shape row into its feature index.623func applyExpand(c *concernContext, form formValues) error {624 s := c.session625 switch form.get("all") {626 case "fold":627 s.Expanded = nil628 return nil629 case "unfold":630 s.Expanded = unfoldableCollections(c.world)631 return nil632 }633 id := form.get("collection")634 if id == "" {635 return errors.New("expand: no collection named")636 }637 s.Expanded = toggle(s.Expanded, id, form.on("open"))638 return nil639}640641func applyLabels(c *concernContext, form formValues) error {642 s := c.session643 id := form.get("collection")644 if id == "" {645 return errors.New("labels: no collection named")646 }647 if s.Labels == nil {648 s.Labels = map[string]string{}649 }650 // A flip is spelled as the move rather than as the destination: the651 // policy turns over, and if the other word is what the producer curated652 // anyway the override has nothing left to say and is dropped rather653 // than stored. That is what keeps a ladder turned over and back from654 // leaving overrides behind it.655 policy := form.get("policy")656 if form.get("flip") != "" || policy == "" {657 if c.world == nil {658 return errors.New("labels: this world could not be read")659 }660 collection, held := c.world.ByID[id]661 if !held {662 return errors.New("labels: no such collection")663 }664 policy = flipLabel(collection, *s)665 }666 if policy == "" {667 delete(s.Labels, id)668 } else {669 s.Labels[id] = policy670 }671 if len(s.Labels) == 0 {672 s.Labels = nil673 }674 return nil675}676677// applySolo isolates a row or a section. It is a move on the hide set rather678// than a state of its own, which is why the record grew no field for it: the679// chip the toolbar shows is derived from what is hidden, so it is right680// however that set was reached -- including by switching rows off one at a681// time -- and showing everything is the single, obvious way back.682//683// Isolation stays inside its own domain: point collections isolate against684// point collections, ground against ground, so highlighting a region and then685// asking for only one resource leaves the region standing with the resource686// inside it.687func applySolo(c *concernContext, form formValues) error {688 s := c.session689 section, collection := form.get("section"), form.get("collection")690 if section == "" && collection == "" {691 // The chip itself is the way out: pressing it shows everything.692 s.Hidden = nil693 return nil694 }695 s.Hidden = isolate(c.world, setOf(s.Hidden), section, collection)696 return nil697}698699func applySearch(c *concernContext, form formValues) error {700 s := c.session701 s.Search = form.get("q")702 // A search with something to say is worth bringing the panel out for,703 // once, until the reader has said otherwise.704 if s.Search != "" {705 revealDock(s)706 }707 return nil708}709710// applyHighlight is the AND-across, OR-within filter: highlighting two711// features of one collection widens the question, and one from each of two712// collections narrows it to the ground they share.713//714// THE EXCLUSIVE FORM (`only`) is the third way to ask, and it is a move on the715// highlight set rather than a state of its own -- the same shape as isolating716// a collection, and for the same reason. Highlights accumulate: every press717// adds ground, so a reader who wanted *this* district and nothing else had to718// clear the set and start again, or reach for the collection's isolate button,719// which answers a different question (it puts every other collection away).720// `only` names one feature and makes the set exactly that feature. Pressing it721// again on the feature that is already alone is the way back out, which is the722// isolate button's own toggle and means the control never traps a reader in a723// filter they cannot leave from the row they set it on.724func applyHighlight(c *concernContext, form formValues) error {725 s := c.session726 if form.get("all") == "clear" {727 s.Highlighted = nil728 return nil729 }730 id := form.get("feature")731 if id == "" {732 return errors.New("highlight: no feature named")733 }734 on := !setOf(s.Highlighted)[id]735 switch {736 case form.on("only"):737 on = !soleHighlight(*s, id)738 s.Highlighted = nil739 if on {740 s.Highlighted = []string{id}741 }742 default:743 if form.has("on") {744 on = form.on("on")745 }746 s.Highlighted = toggle(s.Highlighted, id, on)747 }748 // Asking to look at a piece of ground and keeping its collection put away749 // cannot both be meant, so a highlight brings the collection back.750 if on && c.world != nil {751 if shape, held := c.world.ShapeByID[id]; held {752 s.Hidden = toggle(s.Hidden, shape.Collection.ID, false)753 }754 }755 revealDock(s)756 return nil757}758759func applyDock(c *concernContext, form formValues) error {760 s := c.session761 s.Dock.Open = form.on("open")762 // The reader working the control themselves is what settles it. Folding763 // it by hand is the answer that stops it opening again; unfolding by764 // hand withdraws that answer, so a panel put away and brought back is765 // once more a panel that keeps up with what is selected.766 if form.on("byHand") {767 s.Dock.Dismissed = !s.Dock.Open768 }769 if section := form.get("section"); section != "" {770 s.Dock.Section = section771 }772 return nil773}774775// revealDock brings the panel out the first time there is something in it to776// read, and never again once the reader has put it away themselves.777func revealDock(s *Session) {778 if s.Dock.Open || s.Dock.Dismissed {779 return780 }781 s.Dock.Open = true782}783784// applySelect is the hybrid interaction: the seam resolved a canvas pick and785// submits the identity it found through an ordinary request. A row in the786// legend's feature index and a row in the dock submit the same thing by the787// same route, which is why the legend is in the region set -- the index marks788// the row it opened.789func applySelect(c *concernContext, form formValues) error {790 s := c.session791 s.Selected = form.get("feature")792 s.Detail.Open = s.Selected != ""793 if s.Detail.Open {794 revealDock(s)795 }796 // A row reached for from a list says so, and going to a piece of ground797 // marks it in the index for as long as the reader is standing on it. A798 // pick off the canvas says nothing: the reader was already there.799 if form.on("focus") && c.world != nil {800 if _, ground := c.world.ShapeByID[s.Selected]; ground {801 s.Focused = s.Selected802 }803 }804 return nil805}806807// defaultCellSystem is the system a grid opens on. Which systems exist and808// what they divide is the analysis lane's (issue #5 §5.4); which one is chosen809// is the session's, and this is the session's half saying "the first one".810// Geohash is the first one for the same reason it is first in the navigator:811// it divides anything, so a grid can always open.812const defaultCellSystem = cells.SystemGeohash813814// worldAttrs is the world's own conventions, or none. A request that named no815// world the application could stand up still gets an answer out of the systems816// that ask the ground nothing.817func worldAttrs(world *worldModel) map[string]string {818 if world == nil {819 return nil820 }821 return world.Attrs822}823824func applyGrid(c *concernContext, form formValues) error {825 s := c.session826 if system, sent := form.values["system"]; sent {827 switch value := strings.TrimSpace(first(system)); value {828 case "":829 s.Grid = Grid{}830 return nil831 case "toggle":832 // The G key: a grid on a map that has none, and no grid on a map833 // that has one. Closing it takes the held cell with it, because a834 // cell nobody can see is not a place anybody is standing.835 if s.Grid.System != "" {836 s.Grid = Grid{Subgrid: s.Grid.Subgrid}837 return nil838 }839 // Opening it divides the cell again. A reader who put the subgrid840 // away and then closed the grid altogether is starting over rather841 // than resuming, which is the reference's own reading of the move842 // (`toggleGrid` -> `setSubgridVisible(true)` on enable). The843 // setting still outlives a *closed* grid -- the line above carries844 // it -- so a reader who only ever hides the subdivision keeps it845 // hidden for as long as the grid stays open.846 s.Grid = Grid{System: defaultCellSystem, Subgrid: 1}847 case "cycle":848 cycleGrid(c)849 return nil850 default:851 // A system named outright is the one value on this concern that852 // is not a move but a destination, and a destination has to853 // exist. Which systems divide a world is the world's answer, not854 // the session's: geohash divides anything, S2 wants a sphere with855 // an invertible flattening and refuses everything else856 // (internal/app/cells, ApplicableSystems -- the same two857 // questions the navigator asks before it offers a system at all).858 //859 // A value outside that set is refused rather than kept, and the860 // state stays exactly where it was. Keeping it would put the two861 // halves of the page in different worlds: the seam would narrow862 // the drawn set by a system the server cannot divide by, and on a863 // ground that cannot answer it the systems throw rather than864 // shrug. One request is not allowed to do that.865 attrs := worldAttrs(c.world)866 if !cells.Applicable(attrs, value) {867 slog.Warn("a grid system this world does not divide", logging.Op("session"),868 slog.String("system", value),869 slog.String("offered", strings.Join(cells.ApplicableSystems(attrs), ",")))870 return nil871 }872 setGridSystem(c, value)873 }874 }875 if form.on("ascend") {876 // Escape telescopes out: one level of the address at a time, and out877 // of the grid altogether once there is no address left. Two presses,878 // two distinct answers, and app_test.go walks both.879 //880 // One level is not one character. What the parent of an address is, is881 // the address's own system's business (grid.go, parentCell), and the882 // only reason to say so twice is that the shape of a geohash makes the883 // wrong answer look right.884 if s.Grid.System == "" {885 return nil886 }887 if s.Grid.Cell == "" {888 s.Grid = Grid{Subgrid: s.Grid.Subgrid}889 return nil890 }891 s.Grid.Cell = parentCell(s.Grid.System, s.Grid.Cell, worldAttrs(c.world))892 return nil893 }894 if cell, sent := form.values["cell"]; sent {895 selectGridCell(c, first(cell))896 }897 if form.get("subgrid") == "flip" {898 if s.Grid.Subgrid > 0 {899 s.Grid.Subgrid = 0900 } else {901 s.Grid.Subgrid = 1902 }903 return nil904 }905 if depth, ok := form.number("subgrid"); ok {906 s.Grid.Subgrid = int(depth)907 }908 return nil909}910911// selectGridCell is the navigator's field arriving: whatever the reader has912// typed, made into an address by the system holding the grid.913//914// Nothing here refuses a keystroke and nothing here reports one. A draft that915// is not yet a place -- half an S2 token -- leaves the record exactly as it916// was, which is the field holding the text while the map stays put; the next917// keystroke either completes the address or does not. And a cell arriving at a918// grid nobody has opened opens it, on the system that divides anything,919// *without* restoring the subdivision: the reader went somewhere rather than920// starting over, which is the one thing that separates this from the G key921// (`selectGridCell` in the reference, which sets `gridEnabled` by hand for922// exactly this reason).923func selectGridCell(c *concernContext, raw string) {924 s := c.session925 system := s.Grid.System926 if system == "" {927 system = defaultCellSystem928 }929 // The world's attributes ride along because the depth of an address is930 // the ground's: six characters of real geohash on an earth-anchored931 // plane, three on anything synthetic (grid.go, normalizeCell).932 attrs := worldAttrs(c.world)933 cell, place := parseCell(system, normalizeCell(system, raw, attrs), attrs)934 if !place {935 return936 }937 s.Grid.Cell, s.Grid.System = cell, system938}939940// setGridSystem changes which system divides the map, and carries the held941// place across.942//943// The two hierarchies share no boundaries, so the address cannot survive; the944// ground under the reader can, and does (internal/app/cells, Equivalent). The945// view is left alone on purpose -- the carried cell covers roughly the ground946// already on screen, and a camera move here would be the map jumping under a947// reader who asked for nothing of the kind.948func setGridSystem(c *concernContext, system string) {949 s := c.session950 if system == s.Grid.System {951 return952 }953 from, held := s.Grid.System, s.Grid.Cell954 s.Grid.System = system955 if from == "" || held == "" {956 return957 }958 s.Grid.Cell = cells.Equivalent(gridGround(c), worldAttrs(c.world), from, system, held)959}960961// cycleGrid is the navigator's one button and the ⌘G it answers to: the next962// system this world divides by, carrying the held place.963//964// It is a move rather than a destination, which is why it can never be965// refused: the step is taken through the world's own list of systems, so a966// world offering one has nowhere to step and a world offering two steps967// between them. A grid nobody has opened has no system to cycle, and the968// button that would have said so is not on the page.969func cycleGrid(c *concernContext) {970 s := c.session971 if s.Grid.System == "" {972 return973 }974 next := nextSystem(cells.ApplicableSystems(worldAttrs(c.world)), s.Grid.System)975 if next == "" {976 return977 }978 setGridSystem(c, next)979}980981func applyOverview(c *concernContext, form formValues) error {982 c.session.Overview.Docked = form.on("docked")983 return nil984}985986// applyView takes the camera report. It is the only upward flow from the seam987// besides a pick, it is debounced to once per settle, and it answers with no988// content.989func applyView(c *concernContext, form formValues) error {990 s := c.session991 world := form.get("world")992 if world == "" {993 world = s.World994 }995 if err := vnext.ValidSlug(world); err != nil {996 return fmt.Errorf("view: %w", err)997 }998 x, haveX := form.number("x")999 y, haveY := form.number("y")1000 zoom, haveZoom := form.number("zoom")1001 if !haveX || !haveY || !haveZoom {1002 return errors.New("view: a camera report carries x, y and zoom")1003 }1004 rotation, _ := form.number("rotation")1005 if s.Cameras == nil {1006 s.Cameras = map[string]Camera{}1007 }1008 s.Cameras[world] = Camera{1009 X: x, Y: y, Zoom: zoom, Rotation: rotation,1010 At: time.Now().UTC().Format(time.RFC3339),1011 }1012 return nil1013}10141015func applySidebar(c *concernContext, form formValues) error {1016 c.session.Sidebar.Collapsed = !form.on("open")1017 return nil1018}10191020// soleHighlight answers whether one feature is the whole of what is1021// highlighted. It is the exclusive control's own state, and it is derived1022// rather than stored for the same reason the isolate chip is: the set can be1023// arrived at by any route -- one press of `only`, or highlights added and1024// taken away one at a time -- and a reader looking at a lone highlighted zone1025// is looking at an exclusive one however they got there. Both the move1026// (applyHighlight) and the mark the control wears (legend.go) ask this, so1027// they cannot disagree about what pressing it would do.1028func soleHighlight(s Session, id string) bool {1029 return len(s.Highlighted) == 1 && s.Highlighted[0] == id1030}10311032// toggle adds or removes one member of a set kept as a sorted slice.1033func toggle(set []string, member string, present bool) []string {1034 out := make([]string, 0, len(set)+1)1035 for _, held := range set {1036 if held != member {1037 out = append(out, held)1038 }1039 }1040 if present {1041 out = append(out, member)1042 }1043 sort.Strings(out)1044 return out1045}10461047func trimmed(values []string) []string {1048 out := make([]string, 0, len(values))1049 for _, value := range values {1050 if value = strings.TrimSpace(value); value != "" {1051 out = append(out, value)1052 }1053 }1054 sort.Strings(out)1055 return out1056}10571058func first(values []string) string {1059 if len(values) == 0 {1060 return ""1061 }1062 return values[0]1063}