import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/http/response.{type Response} import gleam/io import gleam/json import gleam/list import gleam/option.{None, Some} import gleam/pair import gleam/regexp import gleam/result import gleam/set.{type Set} import lustre.{type App} import lustre/component import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html import lustre/event import plinth/browser/document import plinth/browser/event as plinth_event import rsvp import context import encodable.{type Encodable} import field.{type Field} import field_type.{type FieldType} pub const name: String = "viewer-controller" pub fn component() -> App(Nil, Model, Msg) { lustre.component(init, update, view, [ component.on_attribute_change("root-path", fn(value) { ParentChangedRootPath(value) |> Ok }), component.on_attribute_change("pkeys", fn(value) { value |> json.parse( decode.list(decode.dict(decode.string, encodable.decoder())), ) |> result.map(fn(lst) { list.zip(list.range(0, list.length(lst)), lst) |> dict.from_list() |> ParentChangedPkeys() }) |> result.replace_error(Nil) }), component.on_attribute_change("fields", fn(value) { value |> json.parse(decode.list(field.decoder())) |> result.map(fn(lst) { list.zip(list.range(0, list.length(lst)), lst) |> dict.from_list() |> ParentChangedFields() }) |> result.replace_error(Nil) }), component.on_attribute_change("root-path", fn(value) { ParentChangedRootPath(value) |> Ok }), ]) } // -------- Model -------- // pub type Model { Model( root_path: String, root_path_consumers: List(Dynamic), selections: List(#(Int, Int)), editing: Bool, pkeys: Dict(Int, Dict(String, Encodable)), fields: Dict(Int, Field), // Whether OS, ctrl, or alt keys are being held. If so, simultaneously // pressing a typing key should not trigger an overwrite. modifiers_held: Set(String), ) } fn init(_) -> #(Model, Effect(Msg)) { #( Model( root_path: "", root_path_consumers: [], selections: [], editing: False, pkeys: dict.new(), fields: dict.new(), modifiers_held: set.new(), ), effect.before_paint(fn(dispatch, _) -> Nil { document.add_event_listener("keydown", fn(ev) -> Nil { let key = plinth_event.key(ev) case key { "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" -> dispatch(UserPressedDirectionalKey(key)) "Enter" -> dispatch(UserPressedEnterKey) "Meta" | "Alt" | "Control" -> dispatch(UserPressedModifierKey(key)) _ -> case is_typing_key(key) { True -> dispatch(UserPressedTypingKey(key)) False -> Nil } } }) document.add_event_listener("keyup", fn(ev) -> Nil { let key = plinth_event.key(ev) case key { "Meta" | "Alt" | "Control" -> dispatch(UserReleasedModifierKey(key)) _ -> Nil } }) }), ) } fn is_typing_key(key: String) -> Bool { let assert Ok(re) = regexp.from_string("^[a-zA-Z0-9!@#$%^&*._/?<>{}[\\]'\"~`-]$") regexp.check(key, with: re) } // -------- Update -------- // pub type Msg { ParentChangedRootPath(String) ParentChangedPkeys(Dict(Int, Dict(String, Encodable))) ParentChangedFields(Dict(Int, Field)) ChildEmittedEditStartEvent ChildEmittedEditUpdateEvent(original: Encodable, edited: Encodable) ChildEmittedEditEndEvent(original: Encodable, edited: Encodable) ChildRequestedRootPath(callback: Dynamic, subscribe: Bool) ServerUpdateValuePost(Result(Response(String), rsvp.Error)) UserClickedCell(Int, Int) UserDoubleClickedCell(Int, Int) UserPressedDirectionalKey(String) UserPressedEnterKey UserPressedTypingKey(String) UserPressedModifierKey(String) UserReleasedModifierKey(String) } fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { ParentChangedRootPath(root_path) -> #( Model(..model, root_path:), context.update_consumers( model.root_path_consumers, dynamic.string(root_path), ), ) ParentChangedPkeys(pkeys) -> #(Model(..model, pkeys:), effect.none()) ParentChangedFields(fields) -> #(Model(..model, fields:), effect.none()) ChildEmittedEditStartEvent -> #( Model(..model, editing: True), effect.none(), ) ChildEmittedEditUpdateEvent(_original, edited) -> { case model.selections { [coords] -> #(model, set_cell_value(coords, edited)) _ -> todo } } ChildEmittedEditEndEvent(_original, edited) -> case model.selections { [#(selected_row, _)] -> case selected_row == dict.size(model.pkeys) { // Cell under edit is in "insert" row True -> #(Model(..model, editing: False), effect.none()) False -> #(model, commit_change(model:, value: edited)) } _ -> todo } ChildRequestedRootPath(callback, subscribe) -> #( case subscribe { True -> Model(..model, root_path_consumers: [ callback, ..model.root_path_consumers ]) False -> model }, context.update_consumers([callback], dynamic.string(model.root_path)), ) ServerUpdateValuePost(response) -> { case response { Ok(_) -> #(Model(..model, editing: False), blur_hoverbar()) Error(rsvp.HttpError(response)) -> { io.println_error("HTTP error while updating value: " <> response.body) #(model, effect.none()) } Error(err) -> { echo err #(model, effect.none()) } } } UserClickedCell(row, column) -> assign_selection(model:, to: #(row, column)) // TODO UserDoubleClickedCell(_row, _column) -> #(model, case model.editing { True -> effect.none() False -> focus_hoverbar() }) UserPressedDirectionalKey(key) -> { case model.editing, model.selections { False, [#(selected_row, selected_col)] -> { let first_row_selected = selected_row == 0 // No need to subtract 1 from pkeys size, because there's another // row for "insert". let last_row_selected = selected_row == dict.size(model.pkeys) let first_col_selected = selected_col == 0 let last_col_selected = selected_col == dict.size(model.fields) - 1 case key, first_row_selected, last_row_selected, first_col_selected, last_col_selected { "ArrowLeft", _, _, False, _ -> assign_selection(model:, to: #(selected_row, selected_col - 1)) "ArrowRight", _, _, _, False -> assign_selection(model:, to: #(selected_row, selected_col + 1)) "ArrowUp", False, _, _, _ -> assign_selection(model:, to: #(selected_row - 1, selected_col)) "ArrowDown", _, False, _, _ -> assign_selection(model:, to: #(selected_row + 1, selected_col)) _, _, _, _, _ -> #(model, effect.none()) } } False, [] -> case dict.size(model.pkeys) > 0 && dict.size(model.fields) > 0 { True -> assign_selection(model:, to: #(0, 0)) False -> #(model, effect.none()) } _, _ -> #(model, effect.none()) } } UserPressedEnterKey -> #(model, case model.editing { True -> effect.none() False -> focus_hoverbar() }) UserPressedTypingKey(key) -> case model.editing, model.selections, set.is_empty(model.modifiers_held) { False, [_], True -> #(model, overwrite_in_hoverbar(value: key)) _, _, _ -> #(model, effect.none()) } UserPressedModifierKey(key) -> #( Model(..model, modifiers_held: model.modifiers_held |> set.insert(key)), effect.none(), ) UserReleasedModifierKey(key) -> #( Model(..model, modifiers_held: model.modifiers_held |> set.delete(key)), effect.none(), ) } } fn overwrite_in_hoverbar(value value: String) -> Effect(Msg) { use _, _ <- effect.before_paint() do_overwrite_in_hoverbar(value) } @external(javascript, "./viewer_controller_component.ffi.mjs", "overwriteInHoverbar") fn do_overwrite_in_hoverbar(value _value: String) -> Nil { Nil } fn commit_change(model model: Model, value value: Encodable) -> Effect(Msg) { case model.selections { [#(row, col)] -> rsvp.post( "./update-value", json.object([ #( "column", model.fields |> dict.get(col) |> result.map(fn(field) { field.name }) |> result.unwrap("") |> json.string(), ), #( "pkeys", model.pkeys |> dict.get(row) |> result.unwrap(dict.new()) |> json.dict(fn(x) { x }, encodable.to_json), ), #("value", encodable.to_json(value)), ]), rsvp.expect_ok_response(ServerUpdateValuePost), ) _ -> todo } } /// Updater for when selection is being assigned to a single cell. fn assign_selection( model model: Model, to coords: #(Int, Int), ) -> #(Model, Effect(Msg)) { // Multiple sets of conditions fall back to the "selection actually changed" // scenario, so this is a little awkward to write without a return keyword. let early_return = case model.selections { [prev_coords] -> case prev_coords == coords { True -> Some(#(model, effect.none())) False -> None } _ -> None } case early_return { Some(value) -> value None -> #( Model(..model, selections: [coords]), effect.batch([ sync_cell_value_to_hoverbar( coords, model.fields |> dict.get(pair.second(coords)) |> result.map(fn(f) { f.field_type }) |> result.unwrap(field_type.Unknown), ), change_selected_attrs([coords]), ]), ) } } // -------- Effects -------- // fn focus_hoverbar() -> Effect(Msg) { use _dispatch, _root <- effect.before_paint() do_focus_hoverbar() } @external(javascript, "./viewer_controller_component.ffi.mjs", "focusHoverbar") fn do_focus_hoverbar() -> Nil { Nil } fn blur_hoverbar() -> Effect(Msg) { use _dispatch, _root <- effect.before_paint() do_blur_hoverbar() } @external(javascript, "./viewer_controller_component.ffi.mjs", "blurHoverbar") fn do_blur_hoverbar() -> Nil { Nil } /// Effect that changes [selected=] attributes on assigned children. Should only /// be called when the new selection is different than the previous one (that /// is, not when selection is "moving" from a cell to the same cell). fn change_selected_attrs(to_coords coords: List(#(Int, Int))) -> Effect(msg) { use _, _ <- effect.before_paint() do_clear_selected_attrs() coords |> list.map(fn(coords) { let #(row, column) = coords do_set_cell_attr(row:, column:, key: "selected", value: "true") }) Nil } fn set_cell_value(coords coords: #(Int, Int), value value: Encodable) { use _, _ <- effect.before_paint() let #(row, col) = coords do_set_cell_attr( row, col, "value", value |> encodable.to_json() |> json.to_string(), ) } @external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs") fn do_clear_selected_attrs() -> Nil { Nil } @external(javascript, "./viewer_controller_component.ffi.mjs", "setCellAttr") fn do_set_cell_attr( row _row: Int, column _column: Int, key _key: String, value _value: String, ) -> Nil { Nil } fn sync_cell_value_to_hoverbar( coords coords: #(Int, Int), field_type f_type: FieldType, ) -> Effect(Msg) { use _, _root <- effect.before_paint() let #(row, column) = coords do_sync_cell_value_to_hoverbar( row:, column:, field_type: f_type |> field_type.to_json() |> json.to_string(), ) } @external(javascript, "./viewer_controller_component.ffi.mjs", "syncCellValueToHoverbar") fn do_sync_cell_value_to_hoverbar( row _row: Int, column _column: Int, field_type _field_type: String, ) -> Nil { Nil } // -------- View -------- // fn view(_: Model) -> Element(Msg) { html.div( [ context.on_context_request( dynamic.string("root_path"), ChildRequestedRootPath, ), event.on("cell-click", { use msg <- decode.field("detail", { use row <- decode.field("row", decode.int) use column <- decode.field("column", decode.int) decode.success(UserClickedCell(row, column)) }) decode.success(msg) }), event.on("cell-dblclick", { use msg <- decode.field("detail", { use row <- decode.field("row", decode.int) use column <- decode.field("column", decode.int) decode.success(UserDoubleClickedCell(row, column)) }) decode.success(msg) }), event.on("edit-start", decode.success(ChildEmittedEditStartEvent)), event.on("edit-update", { use original <- decode.subfield( ["detail", "original"], encodable.decoder(), ) use edited <- decode.subfield(["detail", "edited"], encodable.decoder()) decode.success(ChildEmittedEditUpdateEvent(original:, edited:)) }), event.on("edit-end", { use original <- decode.subfield( ["detail", "original"], encodable.decoder(), ) use edited <- decode.subfield(["detail", "edited"], encodable.decoder()) decode.success(ChildEmittedEditEndEvent(original:, edited:)) }), ], [component.default_slot([], [])], ) }