diff --git a/src/haz3lcore/zipper/action/Action.re b/src/haz3lcore/zipper/action/Action.re index 9b1e045e3a..fdad7e46a8 100644 --- a/src/haz3lcore/zipper/action/Action.re +++ b/src/haz3lcore/zipper/action/Action.re @@ -34,6 +34,7 @@ type rel = [@deriving (show({with_path: false}), sexp, yojson, eq)] type select = | All + | PointToPoint((Point.t, Point.t)) | Resize(move) | Smart(int) | Tile(rel) @@ -224,6 +225,7 @@ let should_animate: t => bool = switch (s) { | Resize(_) => false | All + | PointToPoint(_) | Smart(_) | Tile(_) | Term(_) diff --git a/src/haz3lcore/zipper/action/Perform.re b/src/haz3lcore/zipper/action/Perform.re index 79ac700901..604e80aecb 100644 --- a/src/haz3lcore/zipper/action/Perform.re +++ b/src/haz3lcore/zipper/action/Perform.re @@ -94,6 +94,13 @@ let go = |> return(Cant_select) | Select(Resize(Goal(_))) => failwith("Select not implemented for goals") | Select(All) => Ok(Select.all(z)) + | Select(PointToPoint((p1, p2))) => + z + |> Move.to_point(~measured=syntax.measured, ~goal=p1) + |> OptUtil.and_then(z => + Select.to_point(~measured=syntax.measured, ~goal=p2, z) + ) + |> return(Cant_select) | Select(Term(Current)) => Select.current_term( syntax.term_data, diff --git a/src/util/JsUtil.re b/src/util/JsUtil.re index 7973607f92..15a6e7f687 100644 --- a/src/util/JsUtil.re +++ b/src/util/JsUtil.re @@ -48,6 +48,13 @@ let timestamp = () => date_now()##valueOf; let precise_timestamp = () => Js.Unsafe.global##.performance##now()##valueOf; +let print_timestamp = (ts: float): string => { + let date = + Js.Unsafe.new_obj(Js.date_fromTimeValue, [|Js.Unsafe.inject(ts)|]); + let date_str = date##toLocaleString(Js.undefined, Js.undefined); + date_str; +}; + let download_string_file = (~filename: string, ~content_type: string, ~contents: string) => { let blob = File.blob_from_string(~contentType=content_type, contents); diff --git a/src/web/Export.re b/src/web/Export.re index 189018f2c1..c51dae1736 100644 --- a/src/web/Export.re +++ b/src/web/Export.re @@ -80,3 +80,21 @@ let import_all = ); import_log(all.log); }; + +let import_just_log = (data: string) => { + let all = + try(data |> Yojson.Safe.from_string |> all_of_yojson) { + | _ => + let all_public = data |> Yojson.Safe.from_string |> all_public_of_yojson; + { + settings: all_public.settings, + scratch: all_public.scratch, + documentation: "", + exercise: all_public.exercise, + tutorial: all_public.tutorial, + log: all_public.log, + explainThisModel: "", + }; + }; + all.log; +}; diff --git a/src/web/Main.re b/src/web/Main.re index 6ae86a3787..569500332e 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -19,21 +19,21 @@ let restart_caret_animation = () => let apply = ( - model: History.Model.t, - action: History.Update.t, + model: Logged.Model.t, + action: Logged.Update.t, ~schedule_action, ~schedule_autosave, ) - : History.Model.t => { + : Logged.Model.t => { restart_caret_animation(); /* This function is split into two phases, update and calculate. The intention is that eventually, the calculate phase will be done automatically by incremental calculation. */ // ---------- UPDATE PHASE ---------- - let updated: Updated.t(History.Model.t) = + let updated: Updated.t(Logged.Model.t) = try( - History.Update.update( + Logged.Update.update( ~import_log=Log.import, ~get_log_and=Log.get_and, ~schedule_action, @@ -56,12 +56,24 @@ let apply = }; // ---------- CALCULATE PHASE ---------- let model' = - updated.model - |> History.Update.calculate( - ~schedule_action, - ~is_edited=updated.is_edit, - ~dynamics=true, - ); + try( + updated.model + |> Logged.Update.calculate( + ~schedule_action, + ~is_edited=updated.is_edit, + ~dynamics=true, + ) + ) { + | exc => + Printf.printf( + "ERROR: Exception during calculate: %s\n", + Printexc.to_string(exc), + ); + { + ...model, + replay_toggle: false, + }; + }; if (updated.is_edit) { schedule_autosave( @@ -86,8 +98,8 @@ let start = { let%sub save_scheduler = BonsaiUtil.Alarm.alarm; let%sub (app_model, app_inject) = Bonsai.state_machine1( - (module History.Model), - (module History.Update), + (module Logged.Model), + (module Logged.Update), ~apply_action= (~inject, ~schedule_event, input) => { let schedule_action = x => schedule_event(inject(x)); @@ -100,8 +112,8 @@ let start = { apply(~schedule_action, ~schedule_autosave); }, ~default_model= - History.Model.init() - |> History.Update.calculate( + Logged.Model.load() + |> Logged.Update.calculate( ~schedule_action=_ => (), ~is_edited=true, ~dynamics=false, @@ -114,6 +126,22 @@ let start = { Bonsai.Value.map(~f=g => g(Page.Update.Save), app_inject); let%sub () = BonsaiUtil.Alarm.listen(save_scheduler, ~event=save_effect); + let replay_effect = { + let%map app_inject = app_inject + and model = app_model; + Ui_effect.Many( + model.replay_toggle + ? [app_inject(Page.Update.Globals(Log(NextLog)))] : [], + ); + }; + + let%sub () = + Bonsai.Clock.every( + ~when_to_start_next_effect=`Wait_period_after_previous_effect_finishes_blocking, + Core.Time_ns.Span.of_sec(0.1), + replay_effect, + ); + // Update font metrics on resize let%sub size = BonsaiUtil.SizeObserver.observer( @@ -155,6 +183,8 @@ let start = { JsUtil.focus_clipboard_shim(); /* Setup scroll listener for floating elements (backpack) */ FloatingElement.setup_scroll_listener(); + // Sync log count from database + Log.sync_count(); schedule_action( Assistant(AssistantUpdate.ChatAction(FilterLoadingMessages)), ); @@ -190,7 +220,8 @@ let start = { let _ = Haz3lcore.ProbePerform.FocusEffect.execute(); /* Update floating elements (backpack) to viewport coordinates */ FloatingElement.update_all(); - model.current.globals.settings.core.statics ? Animation.go() : (); + model.current.current.globals.settings.core.statics + ? Animation.go() : (); }, (), ); @@ -200,7 +231,18 @@ let start = { // View function let%arr app_model = app_model and app_inject = app_inject; - History.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and); + try( + Logged.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and) + ) { + | exc => + print_endline( + "ERROR: Exception during view: " ++ Printexc.to_string(exc), + ); + WebUtil.Node.div( + ~attrs=[WebUtil.Attr.id("page")], + [WebUtil.Node.text("An error occurred.")], + ); + }; }; switch (JsUtil.Fragment.get_current()) { diff --git a/src/web/Settings.re b/src/web/Settings.re index 72d069474a..8903390d81 100644 --- a/src/web/Settings.re +++ b/src/web/Settings.re @@ -10,6 +10,7 @@ module Model = { context_inspector: bool, instructor_mode: bool, benchmark: bool, + show_log_panel: bool, explainThis: ExplainThisModel.Settings.t, assistant: AssistantSettings.t, sidebar: SidebarModel.Settings.t, @@ -43,6 +44,7 @@ module Model = { context_inspector: false, instructor_mode: false, benchmark: false, + show_log_panel: false, explainThis: { show: true, show_feedback: false, @@ -113,6 +115,7 @@ module Update = { | Benchmark | ContextInspector | InstructorMode + | ShowLogPanel | Evaluation(evaluation) | Sidebar(SidebarModel.Settings.action) | ExplainThis(ExplainThisModel.Settings.action) @@ -312,6 +315,11 @@ module Update = { }, } } + | ShowLogPanel => { + ...settings, + show_log_panel: + !settings.show_log_panel && ExerciseSettings.show_instructor, + } | Benchmark => { ...settings, benchmark: !settings.benchmark, diff --git a/src/web/Store.re b/src/web/Store.re index 95c0155551..905602ef77 100644 --- a/src/web/Store.re +++ b/src/web/Store.re @@ -54,21 +54,21 @@ module F = let save = (data: t): unit => JsUtil.set_localstore(key_to_string(key), serialize(data)); - let init = () => { + let reset = () => { JsUtil.set_localstore(key_to_string(key), serialize(default())); default(); }; let load = (): t => switch (JsUtil.get_localstore(key_to_string(key))) { - | None => init() + | None => reset() | Some(data) => deserialize(data, default()) }; let rec export = () => switch (JsUtil.get_localstore(key_to_string(key))) { | None => - let _ = init(); + let _ = reset(); export(); | Some(data) => data }; diff --git a/src/web/Updated.re b/src/web/Updated.re index 7a323f9660..1ea479270f 100644 --- a/src/web/Updated.re +++ b/src/web/Updated.re @@ -54,3 +54,9 @@ let return_quiet = historic, }; }; + +exception InvalidAction; + +let raise_invalid_action = _ => { + raise(InvalidAction); +}; diff --git a/src/web/app/History.re b/src/web/app/History.re index d28b319f43..1d564e543c 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -13,11 +13,17 @@ module Model = { let equal = (===); - let init = () => { + let load = () => { current: Page.Store.load(), undo_stack: [], redo_stack: [], }; + + let reset = (~font_metrics=?, ()) => { + current: Page.Model.reset(~font_metrics?, ()), + undo_stack: [], + redo_stack: [], + }; }; module Update = { @@ -41,7 +47,7 @@ module Update = { switch (model.undo_stack) { | [] => print_endline("Cannot undo"); - model |> return_quiet; + model |> Updated.raise_invalid_action; | [x, ...rest] => { ...x, model: { @@ -61,7 +67,7 @@ module Update = { switch (model.redo_stack) { | [] => print_endline("Cannot redo"); - model |> return_quiet; + model |> Updated.raise_invalid_action; | [x, ...rest] => { ...x, model: { @@ -86,7 +92,6 @@ module Update = { action, model.current, ); - let _ = Log.update(action, current); if (Page.Update.can_undo(action)) { { ...current, diff --git a/src/web/app/Log.re b/src/web/app/Log.re index c2b596cc14..2501af9a2b 100644 --- a/src/web/app/Log.re +++ b/src/web/app/Log.re @@ -23,10 +23,12 @@ module DB = { openDB(~upgrade, ~error, ~version=1, db_name, db => f(db)); }; - let add = (key: string, value: string): unit => + let add = (key: string, value: string): unit => { + LogCount.increment(); with_db(db => Store.add(~key, ~callback=_key => (), kv_store(db), value) ); + }; let get = (key: string, f: option(string) => unit): unit => { let error = _ => Printf.printf("ERROR: Log.IDBKV.get"); @@ -40,6 +42,7 @@ module DB = { let clear_and = (callback): unit => { let error = _ => Printf.printf("ERROR: Log.IDBKV.clear"); + LogCount.clear(); with_db(db => Store.clear(~error, ~callback, kv_store(db))); }; }; @@ -63,11 +66,38 @@ module Entry = { Printf.sprintf("%.0f", ts), (ts, action) |> sexp_of_t |> Sexplib.Sexp.to_string, ); + + let s_of_sexp_opt = (sexp: Sexplib.Sexp.t): list(option(t)) => + switch (sexp) { + | Sexplib.Sexp.List(lst) => + List.rev_map( + entry_sexp => + try(Some(t_of_sexp(entry_sexp))) { + | _ => None + }, + lst, + ) + |> List.rev + | _ => [] + }; }; +let get_and = (f: string => unit): unit => + DB.get_all(entries => f("(" ++ String.concat(" ", entries) ++ ")")); + +let get_count = (f: int => unit): unit => + DB.get_all(entries => f(List.length(entries))); + +// Synchronously get the cached count (may be stale until sync_count is called) +let get_count_sync = (): int => LogCount.get(); + +// Sync the cached count with the database +let sync_count = (): unit => + DB.get_all(entries => LogCount.set(List.length(entries))); + let import = (data: string): unit => /* Should be fine to fire saves concurrently? */ - DB.clear_and(() => + DB.clear_and(() => { try( data |> Sexplib.Sexp.of_string @@ -75,13 +105,57 @@ let import = (data: string): unit => |> List.iter(Entry.save) ) { | _ => Printf.printf("Log.Entry.import: Deserialization error") - } - ); + }; + // Sync count after import completes + sync_count(); + }); let update = (action: Page.Update.t, result: Updated.t('a)): unit => if (result.logged) { Entry.save(Entry.mk(action)); }; -let get_and = (f: string => unit): unit => - DB.get_all(entries => f("(" ++ String.concat(" ", entries) ++ ")")); +let to_actions = () => { + print_endline("HELLO??"); + let actions = ref([]); + DB.get_all(entries => { + print_endline( + "num of entries: " ++ string_of_int(List.length(entries)), + ); + entries + |> List.iter(entry_str => + try({ + let (_ts, action) = + entry_str |> Sexplib.Sexp.of_string |> Entry.t_of_sexp; + actions := [action, ...actions^]; + }) { + | _ => print_endline("Log.to_actions: Deserialization error") + } + ); + actions := List.rev(actions^); + }); + print_endline("num of actions: " ++ string_of_int(List.length(actions^))); + actions^; +}; + +// If the user switched browsers or devices, they may have imported a save state from another device, this includes the log from the previous device in a complete stitched log. +let flatten_imports = + ( + ~of_data: string => list((float, Page.Update.t)), + log: list((float, Page.Update.t)), + ) + : list((float, Page.Update.t)) => { + let rec inner = + ( + log: list((float, Page.Update.t)), + acc: list((float, Page.Update.t)), + ) => { + switch (log) { + | [] => acc + | [(_t, Globals(FinishImportAll(Some(data)))), ..._rest] => + inner(List.rev(of_data(data)), acc) + | [x, ...rest] => inner(rest, [x, ...acc]) + }; + }; + log |> List.rev |> inner(_, []); +}; diff --git a/src/web/app/LogCount.re b/src/web/app/LogCount.re new file mode 100644 index 0000000000..3c5ebb34f7 --- /dev/null +++ b/src/web/app/LogCount.re @@ -0,0 +1,17 @@ +/* Cached log entry count to avoid async queries on every render */ + +let count = ref(0); + +let get = (): int => count^; + +let set = (n: int): unit => { + count := n; +}; + +let increment = (): unit => { + count := count^ + 1; +}; + +let clear = (): unit => { + count := 0; +}; diff --git a/src/web/app/Logged.re b/src/web/app/Logged.re new file mode 100644 index 0000000000..48ec424b61 --- /dev/null +++ b/src/web/app/Logged.re @@ -0,0 +1,250 @@ +open Util; + +module Model = { + [@deriving (show({with_path: false}), sexp, yojson)] + type state = History.Model.t; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + current: state, + future_log: list((float, History.Update.t)), + past_log: list((float, History.Update.t)), + replay_messages: list(string), + replay_toggle: bool, + }; + + let equal = (===); + + let load = () => { + current: History.Model.load(), + future_log: [], + past_log: [], + replay_messages: [], + replay_toggle: false, + }; +}; + +module Update = { + open Updated; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = History.Update.t; + + // let sexp = History.Update.sexp_of_t(action); + + [@deriving (show({with_path: false}), sexp, yojson)] + let update = + ( + ~import_log, + ~get_log_and, + ~schedule_action: t => unit, + action: t, + model: Model.t, + ) + : Updated.t(Model.t) => + switch (action) { + | Globals(Log(a)) => + switch (a) { + | InitImport(f) => + JsUtil.read_file(f, data => + schedule_action(Globals(Log(FinishImport(data)))) + ); + model |> Updated.return_quiet; + | FinishImport(None) => + LogSidebar.log_error("Log import failed"); + model |> Updated.return_quiet; + | FinishImport(Some(data)) => + let of_data = (data: string): list((float, History.Update.t)) => + Export.import_just_log(data) + |> Sexplib.Sexp.of_string + |> Log.Entry.s_of_sexp_opt + |> List.filter_map(x => x); + let actions = data |> of_data |> Log.flatten_imports(~of_data); + let current = + History.Model.reset( + ~font_metrics=model.current.current.globals.font_metrics, // Keep old font metrics - otherwise it goes weird + (), + ); + // Retain log panel after import + let current = { + ...current, + current: { + ...current.current, + globals: { + ...current.current.globals, + settings: { + ...current.current.globals.settings, + show_log_panel: true, + sidebar: model.current.current.globals.settings.sidebar, + }, + }, + }, + }; + { + ...model, + current, + future_log: actions, + replay_messages: [ + "Imported log entries: " ++ string_of_int(List.length(actions)), + ...model.replay_messages, + ], + } + |> Updated.return_quiet; + | NextLog => + switch (model.future_log) { + | [] => + { + ...model, + replay_messages: [ + "log replay finished", + ...model.replay_messages, + ], + replay_toggle: false, + } + |> Updated.return_quiet + | [(t, next), ...rest] => + print_endline("Applying next log action..."); + try({ + let updated = + History.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + next, + model.current, + ); + { + ...updated, + model: { + current: updated.model, + future_log: rest, + past_log: [(t, next), ...model.past_log], + replay_messages: model.replay_messages, + replay_toggle: model.replay_toggle, + }, + }; + }) { + | _ => + LogSidebar.log_error("Failed to apply log action"); + Model.{ + ...model, + replay_messages: [ + "Error applying log action : " ++ History.Update.show(next), + ...model.replay_messages, + ], + future_log: model.future_log, + replay_toggle: false, + } + |> return_quiet; + }; + } + | SkipLog => + switch (model.future_log) { + | [] => + { + ...model, + replay_messages: [ + "No log entry to skip", + ...model.replay_messages, + ], + } + |> return_quiet + | [(_, _), ...rest] => + { + ...model, + replay_messages: [ + "Skipped a log entry", + ...model.replay_messages, + ], + future_log: rest, + } + |> return_quiet + } + | ToggleReplay => + Model.{ + ...model, + replay_toggle: !model.replay_toggle, + } + |> return_quiet + | ClearLog => + Log.DB.clear_and(() => ()); + { + ...model, + future_log: [], + past_log: [], + replay_messages: [ + "Cleared all log entries", + ...model.replay_messages, + ], + } + |> return_quiet; + } + | action => + let current = + History.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + action, + model.current, + ); + let _ = Log.update(action, current); + { + ...current, + model: { + current: current.model, + future_log: model.future_log, + past_log: model.past_log, + replay_toggle: model.replay_toggle, + replay_messages: model.replay_messages, + }, + }; + }; + + let calculate = + ( + ~schedule_action: t => unit, + ~is_edited: bool, + ~dynamics, + model: Model.t, + ) + : Model.t => { + current: + model.current + |> History.Update.calculate(~schedule_action, ~is_edited, ~dynamics), + future_log: model.future_log, + past_log: model.past_log, + replay_messages: model.replay_messages, + replay_toggle: model.replay_toggle, + }; +}; + +module Selection = { + type t = History.Selection.t; + + let handle_key_event = (model: Model.t) => + History.Selection.handle_key_event(model.current); + + let get_cursor_info = (model: Model.t) => + History.Selection.get_cursor_info(model.current); +}; + +module View = { + let view = + (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { + History.View.view( + ~log_model= + LogSidebar.Model.{ + messages: model.replay_messages, + is_playing: model.replay_toggle, + current_step: List.length(model.past_log), + total_steps: + List.length(model.past_log) + List.length(model.future_log), + show_details: true, + }, + ~get_log_and, + ~inject, + model.current, + ); + }; +}; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index 518c72374a..fe84d552f1 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -21,6 +21,20 @@ module Model = { }; let equal = (===); + + let reset = (~font_metrics=?, ()) => { + let globals = Globals.Model.init(~font_metrics?, ()); + let settings = globals.settings; + let instructor_mode = globals.settings.instructor_mode; + let editors = Editors.Store.reset(~settings, ~instructor_mode); + { + globals, + editors, + explain_this: ExplainThisModel.init, + assistant: AssistantModel.init(), + selection: Editors.Selection.default_selection(editors), + }; + }; }; module Store = { @@ -131,7 +145,7 @@ module Update = { model.editors, ); switch (jump) { - | None => model |> Updated.return_quiet + | None => model |> Updated.raise_invalid_action | Some((action, selection)) => let* editors = Editors.Update.update( @@ -244,8 +258,10 @@ module Update = { editors, }; }; + | Log(_) | Undo - | Redo => failwith("Undo/Redo are handled in the history module") + | Redo => + failwith("Undo/Redo/Log import are handled in the history module") }; }; @@ -659,6 +675,7 @@ module View = { let main_view = ( ~get_log_and: (string => unit) => unit, + ~log_model, ~inject: Update.t => Ui_effect.t(unit), ~cursor: Cursor.cursor(Editors.Update.t), { @@ -669,10 +686,13 @@ module View = { selection, } as model: Model.t, ) => { + let log_count = LogCount.get(); let globals = { ...globals, inject_global: x => inject(Globals(x)), get_log_and, + get_log_count: _ => + failwith("get_log_count is deprecated, use Log.get_count_sync"), export_all: Export.export_all, }; let bottom_bar = CursorInspector.view(~globals, cursor); @@ -687,6 +707,8 @@ module View = { | MakeActive(s) => inject(MakeActive(Scratch(s))), ~explainThisModel, ~assistantModel, + ~log_model, + ~log_count, ~editor=Update.get_editor(model), cursor.info, ); @@ -754,12 +776,17 @@ module View = { }; let view = - (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { + ( + ~log_model, + ~get_log_and, + ~inject: Update.t => Ui_effect.t(unit), + model: Model.t, + ) => { let cursor = Selection.get_cursor_info(~selection=model.selection, model); div( ~attrs=[Attr.id("page"), ...handlers(~cursor, ~inject, model)], [FontSpecimen.view, JsUtil.clipboard_shim] - @ main_view(~get_log_and, ~cursor, ~inject, model), + @ main_view(~log_model, ~get_log_and, ~cursor, ~inject, model), ); }; }; diff --git a/src/web/app/editors/Editors.re b/src/web/app/editors/Editors.re index 2958025af7..8d5a0425b2 100644 --- a/src/web/app/editors/Editors.re +++ b/src/web/app/editors/Editors.re @@ -90,6 +90,15 @@ module Store = { ExercisesMode.Store.save(~instructor_mode, m); }; }; + + let reset = (~settings, ~instructor_mode) => { + StoreMode.save(Tutorial); + let _ = ScratchMode.Store.reset(); + let _ = ScratchMode.StoreDocumentation.reset(); + let _ = TutorialsMode.Store.reset(~settings, ~instructor_mode); + let _ = ExercisesMode.Store.reset(~settings, ~instructor_mode); + load(~settings, ~instructor_mode); + }; }; module Update = { @@ -168,10 +177,10 @@ module Update = { | (Scratch(_), Exercises(_)) | (Scratch(_), Tutorial(_)) | (Exercises(_), Scratch(_)) - | (Exercises(_), Documentation(_)) => model |> return_quiet + | (Exercises(_), Tutorial(_)) + | (Exercises(_), Documentation(_)) => model |> raise_invalid_action | (SwitchMode(Scratch), Scratch(_)) | (SwitchMode(Documentation), Documentation(_)) - | (Exercises(_), Tutorial(_)) => model |> return_quiet | (SwitchMode(Exercises), Exercises(_)) => model |> return_quiet | (SwitchMode(Scratch), _) => Model.Scratch( @@ -185,7 +194,7 @@ module Update = { |> ScratchMode.Model.unpersist(~settings=globals.settings.core), ) |> return - | (SwitchMode(Tutorial), Tutorial(_)) => model |> return_quiet + | (SwitchMode(Tutorial), Tutorial(_)) => model |> raise_invalid_action | (SwitchMode(Tutorial), _) => Model.Tutorial( TutorialsMode.Store.load( diff --git a/src/web/app/editors/mode/ExercisesMode.re b/src/web/app/editors/mode/ExercisesMode.re index 341c614796..7d9068fe67 100644 --- a/src/web/app/editors/mode/ExercisesMode.re +++ b/src/web/app/editors/mode/ExercisesMode.re @@ -271,6 +271,17 @@ module Store = { exercise_export.exercise_data, ); }; + + let reset = (~settings, ~instructor_mode) => { + let _ = StoreExerciseKey.reset(); + List.iter( + spec => { + let _ = init_exercise(~settings, spec, ~instructor_mode); + (); + }, + ExerciseSettings.exercises, + ); + }; }; module Update = { @@ -347,7 +358,7 @@ module Update = { current: model.current, exercises: new_exercises, }; - | (_, Exercise(_)) => model |> return_quiet + | (_, Exercise(_)) => model |> raise_invalid_action | (Theorem(ex), TheoremExercise(action)) => let* new_current = TheoremExerciseMode.Update.update( @@ -365,7 +376,7 @@ module Update = { current: model.current, exercises: new_exercises, }; - | (_, TheoremExercise(_)) => model |> return_quiet + | (_, TheoremExercise(_)) => model |> raise_invalid_action | (_, SwitchExercise(n)) => Model.{ current: n, @@ -568,6 +579,18 @@ module View = { }, ~tooltip="Import Submission", ); + let import_logs = + Widgets.file_select_button_named( + "import-logs", + Icons.import, + file => { + switch (file) { + | None => Virtual_dom.Vdom.Effect.Ignore + | Some(file) => globals.inject_global(Log(InitImport(file))) + } + }, + ~tooltip="Import Logs", + ); let reset_hazel = button_named( @@ -597,7 +620,7 @@ module View = { NutMenu.item_group( ~inject, "File", - [export_submission, import_submission], + [export_submission, import_submission, import_logs], ); let reset_group_exercises = () => diff --git a/src/web/app/editors/mode/TutorialsMode.re b/src/web/app/editors/mode/TutorialsMode.re index 46b12fc2de..8e55d2a74c 100644 --- a/src/web/app/editors/mode/TutorialsMode.re +++ b/src/web/app/editors/mode/TutorialsMode.re @@ -161,6 +161,17 @@ module Store = { exercise_export.exercise_data, ); }; + + let reset = (~settings, ~instructor_mode) => { + let _ = StoreTutorialKey.reset(); + List.iter( + spec => { + let _ = init_exercise(~settings, spec, ~instructor_mode); + (); + }, + TutorialSettings.lessons, + ); + }; }; module Update = { open Updated; diff --git a/src/web/app/editors/result/EvalResult.re b/src/web/app/editors/result/EvalResult.re index fe8688e5a4..bb34f4edd9 100644 --- a/src/web/app/editors/result/EvalResult.re +++ b/src/web/app/editors/result/EvalResult.re @@ -132,7 +132,7 @@ module Update = { ...model, display: Stepper(stepper), }; - | (StepperAction(_), _) => model |> Updated.return_quiet + | (StepperAction(_), _) => model |> Updated.raise_invalid_action | ( EvalEditorAction(a), {display: Evaluation(Calculated(Some((exp, editor)))), _}, @@ -142,7 +142,7 @@ module Update = { ...model, display: Evaluation(Calculated(Some((exp, editor)))), }; - | (EvalEditorAction(_), _) => model |> Updated.return_quiet + | (EvalEditorAction(_), _) => model |> Updated.raise_invalid_action | (TheoremsAction(action), _) => let* theorems = Theorems.Update.update(~settings, action, model.theorems); diff --git a/src/web/app/editors/result/StepperEditor.re b/src/web/app/editors/result/StepperEditor.re index 5b4cbb9aa1..0361bc8a7f 100644 --- a/src/web/app/editors/result/StepperEditor.re +++ b/src/web/app/editors/result/StepperEditor.re @@ -130,17 +130,36 @@ module View = { taken_steps(model.taken_steps) @ next_steps(model.next_steps, ~inject=x => - Some(List.nth(model.next_steps, x)) == selected_id - ? signal(TakeStep(x)) - : inject(Select(Term(Id(List.nth(model.next_steps, x), Right)))) + { + open OptUtil.Syntax; + let+ range = + TermData.extreme_measures( + List.nth(model.next_steps, x), + model.editor.editor.syntax.term_data, + model.editor.editor.syntax.measured, + ); + Some(List.nth(model.next_steps, x)) == selected_id + ? signal(TakeStep(x)) : inject(Select(PointToPoint(range))); + } + |> Option.value(~default=Ui_effect.Ignore) ) - @ refl_steps(model.refls, ~inject=x => { - Some(List.nth(model.refls, x)) == selected_id - ? signal(Refl(x)) - : { - inject(Select(Term(Id(List.nth(model.refls, x), Right)))); - } - }); + @ refl_steps(model.refls, ~inject=x => + { + open OptUtil.Syntax; + let+ range = + TermData.extreme_measures( + List.nth(model.refls, x), + model.editor.editor.syntax.term_data, + model.editor.editor.syntax.measured, + ); + Some(List.nth(model.refls, x)) == selected_id + ? signal(Refl(x)) + : { + inject(Select(PointToPoint(range))); + }; + } + |> Option.value(~default=Ui_effect.Ignore) + ); }; /* Steppers don't support probe dynamics - expressions shown are diff --git a/src/web/app/editors/result/Theorems.re b/src/web/app/editors/result/Theorems.re index 89d0dca6b3..645e8632f7 100644 --- a/src/web/app/editors/result/Theorems.re +++ b/src/web/app/editors/result/Theorems.re @@ -139,7 +139,7 @@ module Update = { ...model, thm_map, }; - | None => model |> Updated.return_quiet + | None => model |> Updated.raise_invalid_action }; }; }; diff --git a/src/web/app/editors/stepper/InductionStep.re b/src/web/app/editors/stepper/InductionStep.re index fea6a6c537..073cba731e 100644 --- a/src/web/app/editors/stepper/InductionStep.re +++ b/src/web/app/editors/stepper/InductionStep.re @@ -137,7 +137,7 @@ module F = ...model, cases: ListUtil.put_nth(i, new_case, model.cases), }; - | None => model |> return_quiet + | None => model |> raise_invalid_action } | AddCase => let new_case = InductionCase.init; @@ -154,7 +154,7 @@ module F = cases: new_cases, } |> return - | None => model |> return_quiet + | None => model |> raise_invalid_action } } ); diff --git a/src/web/app/editors/stepper/MissingStep.re b/src/web/app/editors/stepper/MissingStep.re index b5a577c737..a19fbdcf1f 100644 --- a/src/web/app/editors/stepper/MissingStep.re +++ b/src/web/app/editors/stepper/MissingStep.re @@ -106,7 +106,7 @@ module Update = { editor: new_editor, }), }; - | (RewriteEditorAction(_), _) => model |> Updated.return_quiet + | (RewriteEditorAction(_), _) => model |> Updated.raise_invalid_action | (UpdateResult(result), RewritesOpen(r)) => Model.{ ...model, @@ -116,15 +116,15 @@ module Update = { cached_result: Some(result), }), } - |> Updated.return_quiet - | (UpdateResult(_), _) => model |> Updated.return_quiet + |> Updated.return_quiet(~logged=true) + | (UpdateResult(_), _) => model |> Updated.raise_invalid_action | (AxiomBoxAction(action), AxiomsOpen(m)) => let* updated = AxiomsBox.Update.update(~settings, action, m); Model.{ ...model, open_box: Model.AxiomsOpen(updated), }; - | (AxiomBoxAction(_), _) => model |> Updated.return_quiet(~logged=true) + | (AxiomBoxAction(_), _) => model |> Updated.raise_invalid_action }; }; diff --git a/src/web/app/editors/stepper/StepperBase.re b/src/web/app/editors/stepper/StepperBase.re index 740e0b76d4..52fe31fa13 100644 --- a/src/web/app/editors/stepper/StepperBase.re +++ b/src/web/app/editors/stepper/StepperBase.re @@ -173,7 +173,7 @@ module rec StepKind: { AlgebriteStep(_), _, ) => - model |> Updated.return_quiet + model |> Updated.raise_invalid_action } ); }; @@ -632,7 +632,7 @@ and Stepper: { switch (action, model.step_kind, model.next_step) { | (EditorAction(ea), _, _) => switch (model.editor) { - | Calc.Pending => model |> return_quiet + | Calc.Pending => model |> raise_invalid_action | Calc.Calculated(editor) => let* new_editor = CodeSelectable.Update.update(~settings, ea, editor); @@ -647,7 +647,7 @@ and Stepper: { ...model, next_step: Some(new_next_step), }; - | (NextStep(_), _, None) => model |> return_quiet + | (NextStep(_), _, None) => model |> raise_invalid_action | (RemoveStep, _, _) => { ...model, @@ -675,23 +675,23 @@ and Stepper: { }), } |> return - | None => model |> return_quiet + | None => model |> raise_invalid_action }; - | (StepForward(_), _, _) => model |> return_quiet + | (StepForward(_), _, _) => model |> raise_invalid_action | (AddInduction(exp), MissingStep(_), _) => { ...model, step_kind: InductionStep(InductionStep.init(~exp?, ())), } |> return - | (AddInduction(_), _, _) => model |> return_quiet + | (AddInduction(_), _, _) => model |> raise_invalid_action | (AddForall, MissingStep(_), _) => { ...model, step_kind: ForallStep(ForallStep.init(init)), } |> return - | (AddForall, _, _) => model |> return_quiet + | (AddForall, _, _) => model |> raise_invalid_action | ( AddAxiomStep(name, at_idx, at_exp, direction, equality), MissingStep(_), @@ -710,7 +710,7 @@ and Stepper: { }), } |> return - | (AddAxiomStep(_, _, _, _, _), _, _) => model |> return_quiet + | (AddAxiomStep(_, _, _, _, _), _, _) => model |> raise_invalid_action | (AddAlgebriteStep(at_idx, at_exp, with_exp), MissingStep(_), _) => { ...model, @@ -723,7 +723,7 @@ and Stepper: { }), } |> return - | (AddAlgebriteStep(_, _, _), _, _) => model |> return_quiet + | (AddAlgebriteStep(_, _, _), _, _) => model |> raise_invalid_action | (StepKindAction(sk_action), _, _) => let* new_step_kind = StepKind.update(~settings, sk_action, model.step_kind); diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 6cfa9f51f9..5fba99da06 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -44,6 +44,15 @@ module VisibleRows = { }; module Action = { + [@deriving (show({with_path: false}), yojson, sexp)] + type log = + | InitImport([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) + | FinishImport(option(string)) + | NextLog + | SkipLog + | ToggleReplay + | ClearLog; + [@deriving (show({with_path: false}), sexp, yojson)] type t = | SetFontMetrics(FontMetrics.t) @@ -55,6 +64,7 @@ module Action = { | ActiveEditor(Haz3lcore.Action.t) | Undo // These two currently happen at the editor level, and are just | Redo // global actions so they can be accessed by the command palette + | Log(log) | SetMetaDown(bool) | UpdateVisibleRows(VisibleRows.t); }; @@ -76,6 +86,7 @@ module Model = { convenience to avoid having to pass it around everywhere. Can only be used in view functions. */ get_log_and: (string => unit) => unit, + get_log_count: (int => unit) => unit, export_all: ( ~settings: Language.CoreSettings.t, @@ -86,31 +97,36 @@ module Model = { export_persistent: unit => unit, }; + let init = + (~settings=Settings.Model.init, ~font_metrics=FontMetrics.init, ()) => { + settings, + font_metrics, + meta_down: false, + visible_rows: None, + color_highlights: None, + inject_global: _ => + failwith("Cannot use inject_global outside of the main view function!"), + get_log_and: _ => + failwith( + "Cannot use get_log_and outside of the main view or update functions!", + ), + get_log_count: _ => + failwith( + "Cannot use get_log_count outside of the main view or update functions!", + ), + export_all: (~settings as _, ~instructor_mode as _, ~log as _) => + failwith( + "Cannot use export_all outside of the main view or update functions!", + ), + export_persistent: () => + failwith( + "Cannot use export_persistent outside of the main view function!", + ), + }; + let load = () => { let settings = Settings.Store.load(); - { - font_metrics: FontMetrics.init, - settings, - meta_down: false, - visible_rows: None, - color_highlights: None, - inject_global: _ => - failwith( - "Cannot use inject_global outside of the main view function!", - ), - get_log_and: _ => - failwith( - "Cannot use get_log_and outside of the main view or update functions!", - ), - export_all: (~settings as _, ~instructor_mode as _, ~log as _) => - failwith( - "Cannot use export_all outside of the main view or update functions!", - ), - export_persistent: () => - failwith( - "Cannot use export_persistent outside of the main view function!", - ), - }; + init(~settings, ()); }; let save = model => { @@ -141,6 +157,7 @@ module Update = { | Redo => false | SetMetaDown(_) => false | UpdateVisibleRows(_) => false + | Log(_) => false }; }; }; diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re new file mode 100644 index 0000000000..c9336571fc --- /dev/null +++ b/src/web/app/log/LogSidebar.re @@ -0,0 +1,264 @@ +open Virtual_dom.Vdom; +open Node; +open Util.WebUtil; +open Util; + +module Model = { + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + messages: list(string), + is_playing: bool, + current_step: int, + total_steps: int, + show_details: bool, + }; +}; + +let button = (~attrs=[], ~tooltip="", ~onclick, ~disabled=false, children) => { + let base_attrs = [ + Attr.class_("log-button"), + Attr.on_pointerdown(onclick), + disabled ? Attr.disabled : Attr.empty, + ]; + let all_attrs = + tooltip != "" ? [Attr.title(tooltip), ...base_attrs] : base_attrs; + div(~attrs=all_attrs @ attrs, children); +}; + +let file_input = (~onchange, ~id, ~accept_extensions) => { + label( + ~attrs=[Attr.for_(id)], + [ + Vdom_input_widgets.File_select.single( + ~extra_attrs=[Attr.class_("file-select-button"), Attr.id(id)], + ~accept=accept_extensions, + ~on_input=onchange, + (), + ), + ], + ); +}; + +let progress_bar = (~current, ~total) => { + let percentage = total > 0 ? current * 100 / total : 0; + div( + ~attrs=[Attr.class_("log-progress")], + [ + div( + ~attrs=[ + Attr.class_("log-progress-bar"), + Attr.style( + Css_gen.create( + ~field="width", + ~value=Printf.sprintf("%d%%", percentage), + ), + ), + ], + [], + ), + div( + ~attrs=[Attr.class_("log-progress-text")], + [text(Printf.sprintf("%d / %d", current, total))], + ), + ], + ); +}; + +let message_item = (message: string) => { + let timestamp = Printf.sprintf("%.0f", Js_of_ocaml.Js.date##now); + div( + ~attrs=[Attr.class_("log-message")], + [ + span( + ~attrs=[Attr.class_("log-timestamp")], + [text("[" ++ timestamp ++ "]")], + ), + span(~attrs=[Attr.class_("log-content")], [text(message)]), + ], + ); +}; + +let controls_section = (~globals: Globals.t, ~model: Model.t) => { + let play_pause_icon = model.is_playing ? "⏸️" : "▶️"; + let play_pause_tooltip = + model.is_playing ? "Pause log replay" : "Start log replay"; + + div( + ~attrs=[Attr.class_("log-controls")], + [ + div( + ~attrs=[Attr.class_("log-control-row")], + [ + button( + ~tooltip="Import log file and reset state", + ~onclick= + _ => { + let elem = Util.JsUtil.get_elem_by_id("log-import-input"); + elem##click; + Ui_effect.Ignore; + }, + [text("Import & Reset")], + ), + button( + ~tooltip="Export current submission", + ~onclick= + _ => { + globals.get_log_and(log => { + let data = + globals.export_all( + ~settings=globals.settings.core, + ~instructor_mode=globals.settings.instructor_mode, + ~log, + ); + JsUtil.download_json(ExerciseSettings.filename, data); + }); + Ui_effect.Ignore; + }, + [text("Export Submission")], + ), + file_input( + ~onchange= + file => { + switch (file) { + | None => Virtual_dom.Vdom.Effect.Ignore + | Some(file) => + globals.inject_global(Log(InitImport(file))) + } + }, + ~id="log-import-input", + ~accept_extensions=[ + `Extension("txt"), + `Extension("log"), + `Extension("json"), + ], + ), + ], + ), + div( + ~attrs=[Attr.class_("log-control-row")], + [ + button( + ~tooltip=play_pause_tooltip, + ~onclick=_ => globals.inject_global(Log(ToggleReplay)), + [text(play_pause_icon)], + ), + button( + ~tooltip="Execute next log step", + ~onclick=_ => globals.inject_global(Log(NextLog)), + [text("Next Step")], + ), + button( + ~tooltip="Skip current log entry", + ~onclick=_ => globals.inject_global(Log(SkipLog)), + [text("Skip")], + ), + ], + ), + progress_bar(~current=model.current_step, ~total=model.total_steps), + ], + ); +}; + +let messages_section = (~model: Model.t) => { + div( + ~attrs=[Attr.class_("log-messages")], + [ + div( + ~attrs=[Attr.class_("log-messages-header")], + [span([text("Log Messages")])], + ), + div( + ~attrs=[ + Attr.class_("log-messages-content"), + Attr.style(Css_gen.create(~field="max-height", ~value="20em")), + Attr.style(Css_gen.create(~field="overflow", ~value="auto")), + ], + model.messages == [] + ? [ + div( + ~attrs=[Attr.class_("log-empty")], + [text("No log messages")], + ), + ] + : List.map(message_item, model.messages), + ), + ], + ); +}; + +let debug_section = (~globals: Globals.t, ~log_entries_count: int) => { + div( + ~attrs=[Attr.class_("log-debug")], + [ + div( + ~attrs=[Attr.class_("log-debug-header")], + [text("Log Debug Info")], + ), + div( + ~attrs=[Attr.class_("log-debug-content")], + [ + div( + ~attrs=[Attr.class_("log-debug-info")], + [ + text( + Printf.sprintf("Current log entries: %d", log_entries_count), + ), + ], + ), + div( + ~attrs=[ + Attr.class_("log-button"), + Attr.title("Clear all log entries"), + Attr.on_pointerdown(_ => globals.inject_global(Log(ClearLog))), + ], + [text("Clear Log")], + ), + ], + ), + ], + ); +}; + +let view = (~globals: Globals.t, ~model: Model.t, ~log_entries_count: int) => { + div( + ~attrs=[Attr.class_("log-sidebar")], + [ + div(~attrs=[Attr.class_("log-header")], [text("Log Control Panel")]), + controls_section(~globals, ~model), + messages_section(~model), + debug_section(~globals, ~log_entries_count), + ], + ); +}; + +// Helper functions for external use +let log_message = (message: string): unit => { + // This would be called from History.re to add messages to the sidebar + // For now, we'll use a simple print as a fallback, but in a full implementation + // this would update the log sidebar model + Printf.printf( + "[LOG SIDEBAR] %s\n", + message, + ); +}; + +let log_action = (action_name: string, details: option(string)): unit => { + let message = + switch (details) { + | None => action_name + | Some(d) => action_name ++ ": " ++ d + }; + log_message(message); +}; + +let log_error = (error: string): unit => { + log_message("ERROR: " ++ error); +}; + +let log_info = (info: string): unit => { + log_message("INFO: " ++ info); +}; + +let log_step_update = (current: int, total: int): unit => { + log_message(Printf.sprintf("Step %d of %d", current, total)); +}; diff --git a/src/web/app/sidebar/Sidebar.re b/src/web/app/sidebar/Sidebar.re index 875474dba6..8f1472b99e 100644 --- a/src/web/app/sidebar/Sidebar.re +++ b/src/web/app/sidebar/Sidebar.re @@ -73,6 +73,15 @@ let probes_tab = (~globals: Globals.t): Node.t => ~globals, ); +let log_control_tab = (~globals: Globals.t): Node.t => + tab_of( + ~panel=LogControl, + ~cls=["log-control-button"], + ~icon=Icons.gear, + ~tooltip="Switch to Log Control Panel", + ~globals, + ); + let collapse_tab = (~globals: Globals.t): Node.t => { let tooltip = globals.settings.sidebar.show ? "Collapse Sidebar" : "Expand Sidebar"; @@ -93,7 +102,10 @@ let persistent_view = (~globals: Globals.t) => explain_this_tab(~globals), assistant_tab(~globals), probes_tab(~globals), - ], + ] + @ ( + globals.settings.show_log_panel ? [log_control_tab(~globals)] : [] + ), ), ], ); @@ -193,6 +205,8 @@ let view = ~signal, ~explainThisModel: ExplainThisModel.t, ~assistantModel: AssistantModel.t, + ~log_model: LogSidebar.Model.t, + ~log_count: int, ~editor, info: option(Language.Info.t), ) => { @@ -225,6 +239,12 @@ let view = ~cursor, ~editor, ) + | LogControl => + LogSidebar.view( + ~globals, + ~model=log_model, + ~log_entries_count=log_count, + ) }, ], ) diff --git a/src/web/app/sidebar/SidebarModel.re b/src/web/app/sidebar/SidebarModel.re index 73b1cdddaf..ee35b2493c 100644 --- a/src/web/app/sidebar/SidebarModel.re +++ b/src/web/app/sidebar/SidebarModel.re @@ -5,7 +5,8 @@ module Settings = { type panel = | LanguageDocumentation | HelpfulAssistant - | Probes; + | Probes + | LogControl; [@deriving (show({with_path: false}), sexp, yojson)] type t = { diff --git a/src/web/view/ExerciseMode.re b/src/web/view/ExerciseMode.re index 2ff29a527e..5519a76c15 100644 --- a/src/web/view/ExerciseMode.re +++ b/src/web/view/ExerciseMode.re @@ -337,9 +337,7 @@ module Update = { ...model, cells: Exercise.put_stitched(pos, model.cells, new_cell), }; - | Editor(_, ResultAction(_)) => - print_endline("IMPOSSIBLE!!!!"); - Updated.return_quiet(model); // TODO: I think this case should never happen + | Editor(_, ResultAction(_)) => Updated.raise_invalid_action(model) // TODO: I think this case should never happen | ResetEditor(pos) => let spec = Exercise.main_editor_of_state(~selection=pos, model.spec); let new_editor = Editor.Model.mk(spec); diff --git a/src/web/view/NutMenu.re b/src/web/view/NutMenu.re index 1f862d02ac..c193c5411e 100644 --- a/src/web/view/NutMenu.re +++ b/src/web/view/NutMenu.re @@ -116,7 +116,12 @@ let dev_group = (~globals) => { ~globals, "Developer", [ - ("✓", "Benchmarks", globals.settings.benchmark, Benchmark), + ( + "✓", + "Benchmarks", + globals.settings.benchmark, + Settings.Update.Benchmark, + ), ("𝑒", "Elaboration", globals.settings.core.elaborate, Elaborate), ("↵", "Whitespace", globals.settings.secondary_icons, SecondaryIcons), ( @@ -125,7 +130,19 @@ let dev_group = (~globals) => { globals.settings.core.flip_animations, FlipAnimations, ), - ], + ] + @ ( + ExerciseSettings.show_instructor + ? [ + ( + "📃", + "Log Panel", + globals.settings.show_log_panel, + ShowLogPanel, + ), + ] + : [] + ), ); }; diff --git a/src/web/view/TutorialMode.re b/src/web/view/TutorialMode.re index 349e10c0a0..b9fe31c2a7 100644 --- a/src/web/view/TutorialMode.re +++ b/src/web/view/TutorialMode.re @@ -183,7 +183,7 @@ module Update = { ...model, cells: Tutorial.put_stitched(pos, model.cells, new_cell), }; - | Editor(_, ResultAction(_)) => Updated.return_quiet(model) // TODO: I think this case should never happen + | Editor(_, ResultAction(_)) => Updated.raise_invalid_action(model) // TODO: I think this case should never happen | ResetEditor(pos) => let spec = Tutorial.main_editor_of_state(~selection=pos, model.spec); let new_editor = Editor.Model.mk(spec); diff --git a/src/web/www/style/probesystem.css b/src/web/www/style/probesystem.css index f076233c93..e6c57b3a62 100644 --- a/src/web/www/style/probesystem.css +++ b/src/web/www/style/probesystem.css @@ -226,3 +226,190 @@ align-items: center; gap: 0.5em; } + +/* Log Sidebar Styles */ +.log-sidebar { + background-color: var(--T1); + border: 1px solid var(--BR1); + border-radius: 0.3em; + padding: 1em; + margin-bottom: 1em; + display: flex; + flex-direction: column; + gap: 0.75em; +} + +.log-header { + font-weight: bold; + font-size: 1.1em; + color: var(--ui-header-text); + text-transform: uppercase; + border-bottom: 1px solid var(--BR1); + padding-bottom: 0.5em; +} + +.log-controls { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.log-control-row { + display: flex; + gap: 0.5em; + align-items: center; + flex-wrap: wrap; +} + +.log-button { + padding: 0.4em 0.8em; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.3em; + color: var(--text-primary); + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.2s ease; + user-select: none; +} + +.log-button:hover { + background-color: var(--BR1); +} + +.log-button:active { + background-color: var(--BR2); +} + +.log-button[disabled] { + background-color: var(--T2); + color: var(--text-disabled); + cursor: not-allowed; + opacity: 0.6; +} + +.log-progress { + position: relative; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.3em; + height: 1.5em; + overflow: hidden; +} + +.log-progress-bar { + background-color: var(--G1); + height: 100%; + transition: width 0.3s ease; +} + +.log-progress-text { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + color: var(--text-primary); + font-weight: bold; +} + +.log-messages { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.log-messages-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; + color: var(--ui-header-text); +} + +.log-clear-btn, +.log-details-btn { + padding: 0.2em 0.5em; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.2em; + color: var(--text-primary); + cursor: pointer; + font-size: 0.8em; + transition: background-color 0.2s ease; +} + +.log-clear-btn:hover, +.log-details-btn:hover { + background-color: var(--BR1); +} + +.log-messages-content { + border: 1px solid var(--BR1); + border-radius: 0.3em; + padding: 0.5em; + background-color: var(--T2); + max-height: 20em; + overflow-y: auto; +} + +.log-message { + display: flex; + gap: 0.5em; + margin-bottom: 0.3em; + font-family: monospace; + font-size: 0.85em; + line-height: 1.3; +} + +.log-message:last-child { + margin-bottom: 0; +} + +.log-timestamp { + color: var(--G2); + flex-shrink: 0; + font-weight: bold; +} + +.log-content { + color: var(--text-primary); + word-break: break-word; +} + +.log-empty { + color: var(--text-disabled); + font-style: italic; + text-align: center; + padding: 1em; +} + +.log-debug { + border-top: 1px solid var(--BR1); + padding-top: 0.75em; + margin-top: 0.75em; +} + +.log-debug-header { + font-weight: bold; + font-size: 0.9em; + color: var(--ui-header-text); + margin-bottom: 0.5em; +} + +.log-debug-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5em; +} + +.log-debug-info { + font-size: 0.85em; + color: var(--text-primary); + font-family: monospace; +}