phonograph/webc/src/viewer_controller_component.gleam

461 lines
14 KiB
Gleam
Raw Normal View History

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
}
}
2025-08-10 14:32:15 -07:00
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
2025-08-10 14:32:15 -07:00
// 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([], [])],
)
}