diff --git a/src/CLI/Print.re b/src/CLI/Print.re index 8802b66f76..11a12e2202 100644 --- a/src/CLI/Print.re +++ b/src/CLI/Print.re @@ -6,6 +6,7 @@ let exp_to_segment_settings: ExpToSegment.Settings.t = { label_format: QuoteWhenNecessary, inline: false, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: true, diff --git a/src/haz3lcore/pretty/ExpToSegment.re b/src/haz3lcore/pretty/ExpToSegment.re index d28d36e017..f5e066a06a 100644 --- a/src/haz3lcore/pretty/ExpToSegment.re +++ b/src/haz3lcore/pretty/ExpToSegment.re @@ -40,6 +40,7 @@ module Settings = { | `Text | `NoFold ], + project_tables: bool, hide_fixpoints: bool, show_filters: bool, show_unknown_as_hole: bool, @@ -51,6 +52,7 @@ module Settings = { label_format: QuoteWhenNecessary, inline, fold_case_clauses: !settings.evaluation.show_case_clauses, + project_tables: settings.evaluation.project_tables, fold_fn_bodies: fold_fn_bodies |> Option.value( @@ -68,6 +70,7 @@ module Settings = { label_format: QuoteWhenNecessary, inline, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: true, @@ -470,8 +473,8 @@ let rec parenthesize = | Ap(Reverse, e1, e2) => Ap( Reverse, - parenthesize(e1) |> paren_assoc_at(Precedence.eqs), - parenthesize(e2) |> paren_at(Precedence.eqs), + parenthesize(e1) |> paren_at(Precedence.eqs), // Associativity is backwards because e2 goes before e1 + parenthesize(e2) |> paren_assoc_at(Precedence.eqs), ) |> rewrap | TypAp(e, tp) => @@ -1056,6 +1059,15 @@ let fold_fun_if = (condition, f_name: string, pieces, exp) => | `NoFold => pieces }; +let project_table_if = (should_project, pieces) => + if (should_project) { + switch (MakeTerm.for_projection([pieces])) { + | None => failwith("ExpToSegment.project_table_if") + | Some(any) => [ProjectorInit.init_or_noop(Table, pieces, any)] + }; + } else { + [pieces]; + }; /* We assume that parentheses have already been added as necessary, and that the expression has no Closures or DynamicErrorHoles */ @@ -1157,7 +1169,10 @@ let rec exp_to_pretty = (~settings: Settings.t, exp: Exp.t): pretty => { ), ], ); - wrap(exp, p_just([form(x, xs)])); + wrap( + exp, + p_just(form(x, xs) |> project_table_if(settings.project_tables)), + ); // TODO: Add optional newlines | Var(v) => wrap(exp, text_to_pretty(exp |> Exp.rep_id, Sort.Exp, v)) | BinOp(op, l, r) => @@ -1440,6 +1455,7 @@ let rec exp_to_pretty = (~settings: Settings.t, exp: Exp.t): pretty => { wrap( exp, e2 + @ (settings.inline ? [] : [Secondary(mk_newline(Id.mk()))]) @ [ Tile({ id, diff --git a/src/haz3lcore/projectors/ProjectorInfo.re b/src/haz3lcore/projectors/ProjectorInfo.re index b941eb2dd2..11596962df 100644 --- a/src/haz3lcore/projectors/ProjectorInfo.re +++ b/src/haz3lcore/projectors/ProjectorInfo.re @@ -12,6 +12,7 @@ let utility: ProjectorBase.utility = { ~settings={ ...ExpToSegment.Settings.of_core(~inline=true, CoreSettings.off), show_unknown_as_hole: false, + fold_fn_bodies: `NoFold, }, ); let lift_syntax = diff --git a/src/haz3lcore/projectors/ProjectorInit.re b/src/haz3lcore/projectors/ProjectorInit.re index cf18e48429..e09647b7b8 100644 --- a/src/haz3lcore/projectors/ProjectorInit.re +++ b/src/haz3lcore/projectors/ProjectorInit.re @@ -15,6 +15,7 @@ let to_module = (kind: ProjectorCore.Kind.t): (module Cooked) => | TextArea => (module Cook(TextAreaProj.M)) | Livelit => (module Cook(LivelitProj.M)) | Card => (module Cook(CardProj.M)) + | Table => (module Cook(TableProj.M)) | Csv => (module Cook(CSVProjector.M)) }; diff --git a/src/haz3lcore/projectors/implementations/TableProj.re b/src/haz3lcore/projectors/implementations/TableProj.re new file mode 100644 index 0000000000..52e04ed0a1 --- /dev/null +++ b/src/haz3lcore/projectors/implementations/TableProj.re @@ -0,0 +1,181 @@ +open Util; +open Virtual_dom.Vdom; +open ProjectorBase; +open Language; + +let max_column_length = 12; + +let rec extract_labeled_tuple_entries = + (exp: Exp.t): option(list((LabeledTuple.label, DHExp.t))) => { + switch (exp.term) { + | Parens(e) => extract_labeled_tuple_entries(e) + | Tuple(es) => + OptUtil.traverse( + (e: Exp.t) => { + switch (e.term) { + | TupLabel({term: Label(l), _}, inner) => Some((l, inner)) + | _ => None + } + }, + es, + ) + | _ => None + }; +}; + +let table_of = + (any: Any.t): option((list(LabeledTuple.label), list(list(Exp.t)))) => + switch (any) { + | Exp({term: ListLit(es), _}) => + switch ( + OptUtil.traverse( + e => extract_labeled_tuple_entries(e) |> Option.map(List.split), + es, + ) + ) { + | Some(data: list((list(string), list(TermBase.exp_t)))) => + let (headers: list(list(string)), rows: list(list(TermBase.exp_t))) = + List.split(data); + + // If all the headers aren't the same return None + switch (headers) { + | [] => None + | [h, ..._] when List.for_all(x => x == h, headers) => + let headers = h; + Some((headers, rows)); + + | _ => None + }; + | None => None + } + | _ => None + }; + +let get = (info: info): (list(LabeledTuple.label), list(list(Exp.t))) => + switch (info.syntax |> info.utility.seg_to_term) { + | Some(s) => + switch (table_of(s)) { + | Some(s) => s + | None => failwith("Table: get: Not a table") + } + | None => failwith("Table: get: Not a table") + }; + +let len_seg = (utility: utility, seg: Segment.t): int => + seg |> utility.seg_to_string |> String.length; + +let seg_of_exp = (utility: utility, exp: Exp.t): (Segment.t, int) => { + let seg = utility.term_to_seg(Exp(exp)); + (seg, len_seg(utility, seg)); +}; + +let abbreviated_seg_of = + (utility: utility, available: int, exp: Exp.t): (Segment.t, int) => { + let (abbr_exp, _length) = + exp |> DHExp.strip_ascriptions |> Abbreviate.abbreviate_exp(~available); + seg_of_exp(utility, abbr_exp); +}; +let length_cls = (length: int): string => + if (length > 10) { + "extra"; + } else if (length > 9) { + "s6"; + } else if (length > 8) { + "s5"; + } else if (length > 7) { + "s4"; + } else if (length > 6) { + "s3"; + } else if (length > 5) { + "s2"; + } else if (length > 4) { + "s1"; + } else { + "s0"; + }; +let value_view = (_info: info, utility: utility, view_seg, exp) => { + let (seg, length) = abbreviated_seg_of(utility, max_column_length, exp); + + Node.div( + ~attrs=[Attr.classes(["value", length_cls(length)])], + [view_seg(Sort.Exp, seg)], + ); +}; + +let table = + ( + info, + ~parent as _: external_action => Ui_effect.t(unit), + (headers, rows): (list(LabeledTuple.label), list(list(Exp.t))), + ~view_seg: (Sort.t, Segment.t) => Node.t, + ) => + Node.table( + ~attrs=[Attr.classes(["table"])], + [ + Node.thead([ + Node.tr(List.map(h => Node.th([Node.text(h)]), headers)), + ]), + Node.tbody( + List.map( + row => + Node.tr( + List.map( + e => Node.td([value_view(info, info.utility, view_seg, e)]), + row, + ), + ), + rows, + ), + ), + ], + ); + +module M: Projector = { + [@deriving (show({with_path: false}), sexp, yojson)] + type model = unit; + [@deriving (show({with_path: false}), sexp, yojson)] + type action = unit; + + let init = (any: Any.t) => + switch (table_of(any)) { + | Some(_) => Some() + | None => None + }; + + let focusable = + Focusable.{ + pointer: None, + keyboard: None, + }; + let dynamics = false; + let placeholder = (_, info) => { + let (header, rows): (list(string), list(list(TermBase.exp_t))) = + info |> get; + let max_header_length = + header |> List.map(String.length) |> List.fold_left((+), 0); + let max_row_length = + rows + |> List.map(row => + row + |> List.map(e => + Abbreviate.abbreviate_exp(~available=max_column_length, e) + |> snd + ) + |> List.fold_left((+), 0, _) + ) + |> List.fold_left(max, 0, _); + let max_length = max(max_header_length, max_row_length); + + let num_rows = List.length(rows); + let num_cols = List.length(header); + ProjectorCore.Shape.{ + vertical: Block(min(num_rows, 10)), // +1 for header row + /* +2 for left and right padding */ + horizontal: 4 + max_length * 1 + num_cols * 2 // +2 for left and right padding + }; + }; + let update = (model, _, _) => model; + + let view = ({info, parent, view_seg, _}: View.args(model, action)): View.t => + View.mk(table(info, ~view_seg, ~parent, info |> get)); +}; diff --git a/src/haz3lcore/zipper/action/Introduce.re b/src/haz3lcore/zipper/action/Introduce.re index 4f2a3665cb..457341a228 100644 --- a/src/haz3lcore/zipper/action/Introduce.re +++ b/src/haz3lcore/zipper/action/Introduce.re @@ -245,6 +245,7 @@ module Make = label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: true, diff --git a/src/language/CoreSettings.re b/src/language/CoreSettings.re index 3449bc3eae..038779bde7 100644 --- a/src/language/CoreSettings.re +++ b/src/language/CoreSettings.re @@ -10,6 +10,7 @@ module Evaluation = { show_case_steps: bool, show_lookup_steps: bool, show_stepper_filters: bool, + project_tables: bool, // TODO[Matt]: Move this to somewhere where it is a per-scratch setting stepper_history: bool, show_settings: bool, @@ -25,6 +26,7 @@ module Evaluation = { show_case_steps: false, show_lookup_steps: false, show_stepper_filters: false, + project_tables: false, stepper_history: false, show_settings: false, show_hidden_steps: false, diff --git a/src/language/ProjectorKind.re b/src/language/ProjectorKind.re index 1a2f3e010f..b1002e1381 100644 --- a/src/language/ProjectorKind.re +++ b/src/language/ProjectorKind.re @@ -16,6 +16,7 @@ type t = | Card | Livelit | TextArea + | Table | Csv; let livelit_projectors: list(t) = [ @@ -27,6 +28,7 @@ let livelit_projectors: list(t) = [ TextArea, Card, Livelit, + Table, ]; /* Note: Probe intentionally excluded - probes use separate action path */ @@ -51,6 +53,7 @@ let name = (p: t): string => | Livelit => "livelit" | TextArea => "text" | Csv => "csv" + | Table => "table" }; /* This must be updated and kept 1-to-1 with the above @@ -68,6 +71,7 @@ let of_name = (p: string): t => | "livelit" => Livelit | "card" => Card | "csv" => Csv + | "table" => Table | _ => failwith("Unknown projector kind") }; diff --git a/src/web/Settings.re b/src/web/Settings.re index 72d069474a..6abb3f62da 100644 --- a/src/web/Settings.re +++ b/src/web/Settings.re @@ -33,6 +33,7 @@ module Model = { show_case_steps: false, show_lookup_steps: false, show_stepper_filters: false, + project_tables: false, stepper_history: false, show_settings: false, show_hidden_steps: false, @@ -96,6 +97,7 @@ module Update = { | ShowAscriptionSteps | ShowCaseSteps | ShowFixpoints + | ProjectTables | ShowLookups | ShowFilters | ShowSettings @@ -215,6 +217,10 @@ module Update = { ...evaluation, show_fixpoints: !evaluation.show_fixpoints, } + | ProjectTables => { + ...evaluation, + project_tables: !evaluation.project_tables, + } | ShowLookups => { ...evaluation, show_lookup_steps: !evaluation.show_lookup_steps, diff --git a/src/web/app/editors/SettingsModal.re b/src/web/app/editors/SettingsModal.re index 6da16ff79d..b52c429761 100644 --- a/src/web/app/editors/SettingsModal.re +++ b/src/web/app/editors/SettingsModal.re @@ -47,6 +47,12 @@ let view = settings.show_fn_bodies, Evaluation(ShowFnBodies), ), + setting( + "☰", + "project tables", + settings.project_tables, + Evaluation(ProjectTables), + ), setting( "x", "show fixpoints", diff --git a/src/web/app/editors/code/ContextMenu.re b/src/web/app/editors/code/ContextMenu.re index b640b02fcc..5854044553 100644 --- a/src/web/app/editors/code/ContextMenu.re +++ b/src/web/app/editors/code/ContextMenu.re @@ -420,6 +420,7 @@ module Projectors = { | Csv => "CSV" | Livelit => "Livelit" | Probe => "Probe" /* shouldn't appear in menu */ + | Table => "Table" }; /* Get applicable projector kinds */ diff --git a/src/web/app/input/Shortcut.re b/src/web/app/input/Shortcut.re index a25c35aa77..26e17f97e4 100644 --- a/src/web/app/input/Shortcut.re +++ b/src/web/app/input/Shortcut.re @@ -209,6 +209,12 @@ let shortcuts = (sys: Util.Key.sys): list(t) => "Toggle Show Hidden Steps", Globals(Set(Evaluation(ShowHiddenSteps))), ), + mk_shortcut( + ~section="Settings", + ~mdIcon="table_view", + "Toggle Project Tables in Evaluation Output", + Globals(Set(Evaluation(ProjectTables))), + ), mk_shortcut( ~section="Settings", ~mdIcon="tune", diff --git a/src/web/app/inspector/CursorInspector.re b/src/web/app/inspector/CursorInspector.re index 08c9135b1c..5a66754ad5 100644 --- a/src/web/app/inspector/CursorInspector.re +++ b/src/web/app/inspector/CursorInspector.re @@ -81,6 +81,7 @@ let code_view_settings: Haz3lcore.ExpToSegment.Settings.t = { label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: false, diff --git a/src/web/init/docs/Tables.ml b/src/web/init/docs/Tables.ml index 2596d60bc7..e812b4e62a 100644 --- a/src/web/init/docs/Tables.ml +++ b/src/web/init/docs/Tables.ml @@ -860,9 +860,14 @@ let out : string * Haz3lcore.PersistentSegment.t = c8914c4f-b504-4e5a-b462-e545ef19efee)(content(Whitespace\" \ \")))))((Secondary((id \ d0857c03-4753-4bc6-aa1e-aac34adebfb4)(content(Whitespace\" \ - \"))))(Tile((id 34450029-7496-4f29-a768-c1854cea295d)(label([ \ - ]))(mold((out Exp)(in_(Exp))(nibs(((shape Convex)(sort Exp))((shape \ - Convex)(sort Exp))))))(shards(0 1))(children(((Secondary((id \ + \"))))(Projector((id d41ddbf3-af04-445e-9343-66c053fc0416)(kind \ + Table)(syntax(Tile((id \ + 2c587950-c900-45dd-88bc-adab62496507)(label(\"(\"\")\"))(mold((out \ + Exp)(in_(Exp))(nibs(((shape Convex)(sort Exp))((shape Convex)(sort \ + Exp))))))(shards(0 1))(children(((Tile((id \ + 34450029-7496-4f29-a768-c1854cea295d)(label([ ]))(mold((out \ + Exp)(in_(Exp))(nibs(((shape Convex)(sort Exp))((shape Convex)(sort \ + Exp))))))(shards(0 1))(children(((Secondary((id \ 161e02f7-0866-4367-872c-d9899ae17092)(content(Whitespace\"\\n\"))))(Tile((id \ 579f2be8-669c-4dcb-8898-172f4bf43cfa)(label(\"(\"\")\"))(mold((out \ Exp)(in_(Exp))(nibs(((shape Convex)(sort Exp))((shape Convex)(sort \ @@ -1080,7 +1085,7 @@ let out : string * Haz3lcore.PersistentSegment.t = ee896392-5bf6-45f1-bcfd-3e21474ef040)(label(1300))(mold((out \ Exp)(in_())(nibs(((shape Convex)(sort Exp))((shape Convex)(sort \ Exp))))))(shards(0))(children()))))))))(Secondary((id \ - 63c08e6a-3250-4c46-96dc-9d3ed8fb4be6)(content(Whitespace\"\\n\")))))))))(Secondary((id \ + 63c08e6a-3250-4c46-96dc-9d3ed8fb4be6)(content(Whitespace\"\\n\")))))))))))))))(model\"()\")))(Secondary((id \ 36288d15-9624-4ee3-ae6f-17e22fff688f)(content(Whitespace\" \ \")))))))))(Secondary((id \ 287d26a3-9943-4873-8e80-7084a5218cf4)(content(Whitespace\"\\n\"))))(Secondary((id \ @@ -3055,13 +3060,13 @@ let out : string * Haz3lcore.PersistentSegment.t = # `group_by_label`: Group records by a label's value and pivot into a \ tuple of lists #\n\ # Example: Game leaderboard entries grouped by level #\n\ - let leaderboard = [\n\ + let leaderboard = ^^table([\n\ (level=\"forest\", player=\"Aria\", score=1200),\n\ (level=\"desert\", player=\"Ben\", score=900),\n\ (level=\"forest\", player=\"Cleo\", score=1500),\n\ (level=\"cave\", player=\"Dana\", score=700),\n\ (level=\"desert\", player=\"Eli\", score=1300)\n\ - ] in\n\n\ + ]) in\n\n\ let ^^probe(by_level) = group_by_label(leaderboard, `level`) in\n\ ^^probe(by_level.forest);\n\ ^^probe(by_level.cave);\n\ diff --git a/src/web/view/ContextInspector.re b/src/web/view/ContextInspector.re index 179b72cd68..9a446143ef 100644 --- a/src/web/view/ContextInspector.re +++ b/src/web/view/ContextInspector.re @@ -17,6 +17,7 @@ let context_entry_view = (~globals, entry: Language.Ctx.entry): Node.t => { label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: false, diff --git a/src/web/view/Kind.re b/src/web/view/Kind.re index 6ad892b5d3..48847fb104 100644 --- a/src/web/view/Kind.re +++ b/src/web/view/Kind.re @@ -16,6 +16,7 @@ let view = (~globals, kind: Language.Ctx.kind): Node.t => label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: false, diff --git a/src/web/view/NutMenu.re b/src/web/view/NutMenu.re index 1f862d02ac..2db11475ed 100644 --- a/src/web/view/NutMenu.re +++ b/src/web/view/NutMenu.re @@ -66,6 +66,7 @@ let values_group = (~globals: Globals.t) => { ("λ", "Functions", s.show_fn_bodies, Evaluation(ShowFnBodies)), ("|", "Cases", s.show_case_clauses, Evaluation(ShowCaseClauses)), ("f", "Fixpoints", s.show_fixpoints, Evaluation(ShowFixpoints)), + ("☰", "Tables", s.project_tables, Evaluation(ProjectTables)), ], ); }; diff --git a/src/web/www/style/projectors/proj-base.css b/src/web/www/style/projectors/proj-base.css index 4c764c800e..46707ef0ac 100644 --- a/src/web/www/style/projectors/proj-base.css +++ b/src/web/www/style/projectors/proj-base.css @@ -9,6 +9,7 @@ @import "proj-checkbox.css"; @import "proj-slider.css"; @import "proj-livelit.css"; +@import "proj-table.css"; @import "proj-csv.css"; /* Default projector styles */ diff --git a/src/web/www/style/projectors/proj-table.css b/src/web/www/style/projectors/proj-table.css new file mode 100644 index 0000000000..bfd1b0d2d0 --- /dev/null +++ b/src/web/www/style/projectors/proj-table.css @@ -0,0 +1,63 @@ +/* PROJECTOR: Tables */ + +/* Tables */ +.table table { + border-collapse: collapse; + /* font-size: 0.875rem; */ + width: 95%; + margin: 0 auto; + table-layout: auto; + text-align: center; + background-color: var(--df-bg); + height: 98%; +} +.table th { + position: sticky; + top: 0; + z-index: calc(var(--projector-z) + 1); + opacity: 1; + height: calc(var(--line-height) * 1em); + background-color: var(--table-header-bg); + font-weight: 600; + color: var(--BR4); + font-family: var(--code-font); +} +.table th, +.table td { + vertical-align: middle; + /* height: 24.125px; */ + /* line-height: 24.125px; */ + overflow: hidden; + padding: 0px; +} + +.table tr:nth-child(even) { + background-color: var(--df-zebra-bg); +} + +.table tr:hover { + background-color: var(--df-hover-bg); +} + +div.projector.table:has(tbody tr:nth-child(10)) { + overflow: auto; +} + + +.projector.table { + height: 100%; + width: 100%; + overflow: hidden; + box-sizing: content-box; + + display: flex; + flex-direction: initial; + justify-content: initial; + align-items: initial; + gap: initial; +} + +.projector.table .code { + overflow-y: scroll; + font-size: calc(var(--base-font-size) * 0.9); +} \ No newline at end of file diff --git a/src/web/www/style/variables.css b/src/web/www/style/variables.css index 395aeffe97..7204b134e5 100644 --- a/src/web/www/style/variables.css +++ b/src/web/www/style/variables.css @@ -226,4 +226,10 @@ /* HACKS */ --modal-back-z: 100; --modal-z: 101; -} + + /* TABLE TABLES */ + --df-bg: var(--SAND); + --table-header-bg: var(--shard-caret-exp); + --df-zebra-bg: var(--T2); + --df-hover-bg: oklch(90% 0.1 250); /* gentle blue highlight */ +} \ No newline at end of file diff --git a/test/Test_ExpToSegment.re b/test/Test_ExpToSegment.re index b9cd5d3bc2..e9aa65b7cc 100644 --- a/test/Test_ExpToSegment.re +++ b/test/Test_ExpToSegment.re @@ -10,6 +10,7 @@ let exp_to_segment_settings: ExpToSegment.Settings.t = { label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: true, @@ -352,6 +353,22 @@ let tests = ( ), ) }), + test_case( + "Nested reverse application no parens", + `Quick, + () => { + let segment = + Parser.to_term("1 |> 2 |> 3") |> Option.get |> exp_to_segment; + let serialized = print_seg(segment); + + check( + string, + "Nested reverse application", + "1 |> 2 |> 3", + serialized, + ); + }, + ), test_case("Dot operator on float", `Quick, () => { check( string, @@ -440,6 +457,7 @@ let exp_to_segment_roundtrip_settings: ExpToSegment.Settings.t = { hide_fixpoints: false, show_filters: true, show_unknown_as_hole: true, + project_tables: false, }; let exp_to_segment_roundtrip = @@ -1018,6 +1036,7 @@ let grout_structural_settings: ExpToSegment.Settings.t = { hide_fixpoints: false, show_filters: true, show_unknown_as_hole: true, + project_tables: false, }; /* String-to-string grout tests: parse strings, verify round-trip preserves text. diff --git a/test/Test_Menhir.re b/test/Test_Menhir.re index 53ccbbb96b..a44537ddfe 100644 --- a/test/Test_Menhir.re +++ b/test/Test_Menhir.re @@ -167,6 +167,7 @@ let qcheck_menhir_serialized_equivalent_test = label_format: QuoteWhenNecessary, inline: true, fold_case_clauses: false, + project_tables: false, fold_fn_bodies: `NoFold, hide_fixpoints: false, show_filters: true,