diff --git a/src/app/client/webgpu/editor.cljs b/src/app/client/webgpu/editor.cljs index 61bc8a6..646ac1d 100644 --- a/src/app/client/webgpu/editor.cljs +++ b/src/app/client/webgpu/editor.cljs @@ -73,15 +73,6 @@ return vec4(params.color_r, params.color_g, params.color_b, opacity); }") -;; Calculate caret rectangle (thin vertical bar at cursor position) -(defn calculate-caret-rect [cursor font-size start-x start-y line-h visible?] - (when (and cursor visible?) - (let [char-w (* font-size 0.6) - x (+ start-x (* (:col cursor) char-w)) - y (+ start-y (* (:line cursor) line-h))] - {:x x :y y :w 2 :h line-h - :r 0.9 :g 0.9 :b 0.9 :a 1.0}))) - ;; Calculate bracket highlight rectangles (defn calculate-bracket-rects [bracket-match font-size start-x start-y line-h] (when bracket-match @@ -113,50 +104,6 @@ (min line-len))] {:line line-idx :col col-idx})) -;; Updated calculate-selection-rects: uses actual line lengths -(defn calculate-selection-rects [sel-start sel-end font-size start-x start-y line-h line-lengths] - (if (and sel-start sel-end) - (let [;; Normalize selection direction - [start end] (if (or (> (:line sel-start) (:line sel-end)) - (and (= (:line sel-start) (:line sel-end)) - (> (:col sel-start) (:col sel-end)))) - [sel-end sel-start] - [sel-start sel-end]) - char-w (* font-size 0.6) - ;; Selection highlight color - r 0.2 g 0.4 b 0.9 a 0.5] - - (loop [curr-line (:line start) - rects []] - (if (> curr-line (:line end)) - rects - (let [;; Get actual length of this line - line-len (get line-lengths curr-line 0) - is-first? (= curr-line (:line start)) - is-last? (= curr-line (:line end)) - - ;; Column range for this line - col-start (if is-first? (:col start) 0) - col-end (if is-last? - (:col end) - line-len) ;; Use actual line length, not hardcoded! - - ;; Only create rect if there's content to highlight - width-chars (- col-end col-start)] - - (if (and (> width-chars 0) (> line-len 0)) - ;; Create rect for this line's selection - (let [px-x (+ start-x (* col-start char-w)) - px-y (+ start-y (* curr-line line-h)) - px-w (* width-chars char-w) - px-h line-h] - (recur (inc curr-line) - (conj rects {:x px-x :y px-y :w px-w :h px-h - :r r :g g :b b :a a}))) - ;; Skip empty lines or zero-width selections - (recur (inc curr-line) rects)))))) - [])) - ;; --- 2. INITIALIZATION --- (defn init-rect-system [^js/GPUDevice device fformat camera-buffer & {:keys [initial-capacity] :or {initial-capacity 1000}}] @@ -236,11 +183,12 @@ (let [code (.charCodeAt ch 0)] (cond (= ch \newline) (do (reset! !x start-x) (reset! !y (+ @!y (* fsize line-h)))) - (= ch \space) (swap! !x + (* fsize 0.25)) + (= ch \space) (swap! !x + (* fsize 0.6)) ;; Must match char-w in cursor calculations :else (when-let [g (get glyphs code)] (let [pb (:planeBounds g) ab (:atlasBounds g) - advance (* fsize (or (:advance g) 0)) + ;; Use fixed 0.6 advance for monospace consistency with cursor calculations + advance (* fsize 0.6) sl (+ @!x (* fsize (or (:left pb) 0))) sr (+ @!x (* fsize (or (:right pb) 0))) st (- @!y (* fsize (or (:top pb) 0))) @@ -349,48 +297,89 @@ (.writeBuffer (.-queue device) camera-buffer 0 floats)) -(defn draw-frame! [^js device ^js context text-sys rect-sys camera-floats _ignored_pass_descriptor pan-x pan-y w h] +(defn draw-frame! [^js device ^js context text-sys editor-rect-sys cmd-rect-sys camera-floats _ignored_pass_descriptor pan-x pan-y w h + & {:keys [cmd-panel-visible cmd-panel-h editor-line-count] + :or {cmd-panel-visible false cmd-panel-h 40 editor-line-count nil}}] (update-camera device (:camera-uniform-buffer text-sys) camera-floats pan-x pan-y 1.0 w h) - + (let [encoder (.createCommandEncoder device) texture (.getCurrentTexture context) view (.createView texture) - - pass-descriptor (clj->js + + pass-descriptor (clj->js {:colorAttachments [{:view view :clearValue {:r 0.0 :g 0.0 :b 0.0 :a 1.0} :loadOp "clear" :storeOp "store"}]}) - + pass (.beginRenderPass encoder pass-descriptor)] - (when (and rect-sys (> (:num-instances rect-sys) 0)) - (.setPipeline pass (:pipeline rect-sys)) - (.setBindGroup pass 0 (:bind-group rect-sys)) - (.setVertexBuffer pass 0 (:instance-buffer rect-sys)) - (.draw pass 6 (:num-instances rect-sys))) + ;; Draw editor rects first (selection, brackets, fold indicators, caret, eval) + (when (and editor-rect-sys (> (:num-instances editor-rect-sys) 0)) + (.setPipeline pass (:pipeline editor-rect-sys)) + (.setBindGroup pass 0 (:bind-group editor-rect-sys)) + (.setVertexBuffer pass 0 (:instance-buffer editor-rect-sys)) + (.draw pass 6 (:num-instances editor-rect-sys))) + ;; Draw editor text (with viewport culling) (when (and text-sys (> (:num-instances text-sys) 0)) (.setPipeline pass (:pipeline text-sys)) (.setBindGroup pass 0 (:bind-group text-sys)) (.setVertexBuffer pass 0 (:instance-buffer text-sys)) - + (let [line-offsets (:line-offsets text-sys) line-h (:line-height text-sys) total-lines (count line-offsets) - - scroll-y (- pan-y) + ;; If we know how many editor lines there are, only cull those + ;; Command panel lines are at the end and need different handling + editor-lines (or editor-line-count total-lines) + + ;; Effective viewport height (exclude command panel area) + effective-h (if cmd-panel-visible (- h cmd-panel-h) h) + + scroll-y (- pan-y) start-line (max 0 (Math/floor (/ scroll-y line-h))) - end-line (min total-lines (+ (Math/ceil (/ (+ scroll-y h) line-h)) 2))] - - (when (< start-line end-line) + end-line (min editor-lines (+ (Math/ceil (/ (+ scroll-y effective-h) line-h)) 2))] + + ;; Draw visible editor lines + (when (and (< start-line end-line) (< start-line (count line-offsets))) (let [start-inst (nth line-offsets start-line) - end-inst (if (< end-line total-lines) + end-inst (if (< end-line (count line-offsets)) (nth line-offsets end-line) - (:num-instances text-sys)) + (if (< editor-lines total-lines) + (nth line-offsets editor-lines) + (:num-instances text-sys))) draw-count (- end-inst start-inst)] - - (.draw pass 6 draw-count 0 start-inst))))) + (when (> draw-count 0) + (.draw pass 6 draw-count 0 start-inst)))) + + ;; Command panel: draw background, then text, then caret + (when cmd-panel-visible + ;; Draw command panel BACKGROUND rect (covers editor text bleeding into panel area) + (when (and cmd-rect-sys (>= (:num-instances cmd-rect-sys) 1)) + (.setPipeline pass (:pipeline cmd-rect-sys)) + (.setBindGroup pass 0 (:bind-group cmd-rect-sys)) + (.setVertexBuffer pass 0 (:instance-buffer cmd-rect-sys)) + (.draw pass 6 1 0 0)) + + ;; Draw command panel TEXT (on top of background) + (when (< editor-lines total-lines) + (let [cmd-start-inst (nth line-offsets editor-lines) + cmd-end-inst (:num-instances text-sys) + cmd-draw-count (- cmd-end-inst cmd-start-inst)] + (when (> cmd-draw-count 0) + ;; Need to set up text pipeline again after drawing rect + (.setPipeline pass (:pipeline text-sys)) + (.setBindGroup pass 0 (:bind-group text-sys)) + (.setVertexBuffer pass 0 (:instance-buffer text-sys)) + (.draw pass 6 cmd-draw-count 0 cmd-start-inst)))) + + ;; Draw command panel CARET rect (on top of text) + (when (and cmd-rect-sys (>= (:num-instances cmd-rect-sys) 2)) + (.setPipeline pass (:pipeline cmd-rect-sys)) + (.setBindGroup pass 0 (:bind-group cmd-rect-sys)) + (.setVertexBuffer pass 0 (:instance-buffer cmd-rect-sys)) + (.draw pass 6 1 0 1))))) (.end pass) (.submit (.-queue device) #js [(.finish encoder)]))) diff --git a/src/app/client/webgpu/loop.cljs b/src/app/client/webgpu/loop.cljs index 90db268..5708bda 100644 --- a/src/app/client/webgpu/loop.cljs +++ b/src/app/client/webgpu/loop.cljs @@ -1,12 +1,33 @@ (ns app.client.webgpu.loop + "Reactive-first editor loop following Electric/Missionary patterns. + + Architecture: + - Layer 1: Primary Sources (6 atoms) + - Layer 2: Event Flows (keyboard, mouse, wheel, resize, blink) + - Layer 3: Derived Flows (line-lengths, fold-regions, tokenized, render-ops) + - Layer 4: Focus-based Event Routing + - Layer 5: Component Update Flows + - Layer 6: GPU State Derived Flows + - Layer 7: Terminal Render Consumer" (:require [clojure.string :as str] [hyperfiddle.electric3 :as e] [hyperfiddle.electric-dom3 :as dom] [missionary.core :as m] [contrib.missionary-contrib :as mx] - [app.client.webgpu.editor :as editor])) + [app.client.webgpu.editor :as editor] + [app.client.webgpu.text-input :as text-input])) -(defn window-resize [] + "Flow that emits viewport dimensions on resize" (->> (m/observe (fn [!] (let [handler (fn [] @@ -14,120 +35,103 @@ :height js/window.innerHeight :dpr (or js/window.devicePixelRatio 1)}))] (js/window.addEventListener "resize" handler) - (handler) + (handler) ;; Emit initial value #(js/window.removeEventListener "resize" handler)))) (m/relieve (fn [_old new] new)))) -(defn >wheel-deltas [node] - (m/observe - (fn [!] - (let [handler (fn [e] - (.preventDefault e) - (! (.-deltaY e)))] - (.addEventListener node "wheel" handler #js {:passive false}) - #(.removeEventListener node "wheel" handler))))) - -(defn >mouse-events [node] +(defn >wheel [node] + "Flow that emits wheel delta values" + (->> (m/observe + (fn [!] + (let [handler (fn [e] + (.preventDefault e) + (! (.-deltaY e)))] + (.addEventListener node "wheel" handler #js {:passive false}) + #(.removeEventListener node "wheel" handler)))) + (m/relieve +))) + +(defn >mouse [node] + "Flow that emits mouse events [:mousedown/:mouseup/:mousemove coords]" (m/observe (fn [!] (let [get-coords (fn [e] (let [rect (.getBoundingClientRect node)] {:x (- (.-clientX e) (.-left rect)) :y (- (.-clientY e) (.-top rect))})) - down-h (fn [e] (! [:mousedown (get-coords e)])) up-h (fn [e] (! [:mouseup (get-coords e)])) move-h (fn [e] (! [:mousemove (get-coords e)]))] - (.addEventListener node "mousedown" down-h) (.addEventListener js/window "mouseup" up-h) (.addEventListener js/window "mousemove" move-h) - (fn [] (.removeEventListener node "mousedown" down-h) (.removeEventListener js/window "mouseup" up-h) (.removeEventListener js/window "mousemove" move-h)))))) -(defn >keyboard-events [node] +(defn parse-key-event + "Parse DOM keyboard event into semantic event map" + [e] + (let [key (.-key e) + ctrl? (or (.-ctrlKey e) (.-metaKey e)) + shift? (.-shiftKey e)] + (cond + ;; Global shortcuts (not affected by focus) + (and ctrl? (= key "k")) {:type :toggle-command-panel :global? true} + (and ctrl? (= key "s")) {:type :save :global? true} + + ;; Escape - context dependent but handled globally + (= key "Escape") {:type :escape :global? true} + + ;; Editor-specific shortcuts + (and ctrl? (= key "Enter")) {:type :eval} + (and ctrl? (= key "z") (not shift?)) {:type :undo} + (and ctrl? (= key "z") shift?) {:type :redo} + (and ctrl? (= key "y")) {:type :redo} + (and ctrl? (= key "c")) {:type :copy} + (and ctrl? (= key "x")) {:type :cut} + (and ctrl? (= key "v")) {:type :paste} + + ;; Word navigation + (and ctrl? (= key "ArrowLeft")) {:type :word-left} + (and ctrl? (= key "ArrowRight")) {:type :word-right} + + ;; Navigation keys + (= key "ArrowLeft") {:type :left} + (= key "ArrowRight") {:type :right} + (= key "ArrowUp") {:type :up} + (= key "ArrowDown") {:type :down} + (= key "Home") {:type :home} + (= key "End") {:type :end} + + ;; Editing keys + (= key "Backspace") {:type :backspace} + (= key "Delete") {:type :delete} + (= key "Enter") {:type :enter} + + ;; Character input + (and (= 1 (count key)) (not ctrl?) (not (.-altKey e))) + {:type :char :char key} + + :else nil))) + +(defn >keyboard [node] + "Flow that emits parsed keyboard events" + (->> (m/observe + (fn [!] + (let [handler (fn [e] + (when-let [event (parse-key-event e)] + (.preventDefault e) + (! event)))] + (.addEventListener node "keydown" handler) + #(.removeEventListener node "keydown" handler)))) + (m/relieve (fn [_ x] x)))) + +(defn make-raf-flow + "Creates a fresh RAF flow - emits timestamps on each animation frame. + IMPORTANT: Must be called fresh for each subscription, not shared!" + [] (m/observe - (fn [!] - (let [handler (fn [e] - (let [key (.-key e) - ctrl? (or (.-ctrlKey e) (.-metaKey e))] - (cond - ;; Ctrl+Enter / Cmd+Enter - Evaluate form - (and ctrl? (= key "Enter")) - (do (.preventDefault e) - (! [:eval nil])) - - ;; Ctrl+C - Copy - (and ctrl? (= key "c")) - (do (.preventDefault e) - (! [:copy nil])) - - ;; Ctrl+X - Cut - (and ctrl? (= key "x")) - (do (.preventDefault e) - (! [:cut nil])) - - ;; Ctrl+V - Paste - (and ctrl? (= key "v")) - (do (.preventDefault e) - (! [:paste nil])) - - ;; Ctrl+Z - Undo - (and ctrl? (not (.-shiftKey e)) (= key "z")) - (do (.preventDefault e) - (! [:undo nil])) - - ;; Ctrl+Shift+Z or Ctrl+Y - Redo - (or (and ctrl? (.-shiftKey e) (= key "z")) - (and ctrl? (= key "y"))) - (do (.preventDefault e) - (! [:redo nil])) - - ;; Ctrl+S - Save - (and ctrl? (= key "s")) - (do (.preventDefault e) - (! [:save nil])) - - ;; Ctrl+Arrow word navigation - (and ctrl? (contains? #{"ArrowLeft" "ArrowRight"} key)) - (do (.preventDefault e) - (! [:word-nav key])) - - ;; Navigation keys - (contains? #{"ArrowLeft" "ArrowRight" "ArrowUp" "ArrowDown" - "Home" "End"} key) - (do (.preventDefault e) - (! [:keydown key])) - - ;; Printable characters (length 1, not control keys) - (and (= 1 (.-length key)) - (not ctrl?) - (not (.-altKey e))) - (do (.preventDefault e) - (! [:char-input key])) - - ;; Enter key (plain, no modifier) - (and (= key "Enter") (not ctrl?)) - (do (.preventDefault e) - (! [:enter nil])) - - ;; Backspace - (= key "Backspace") - (do (.preventDefault e) - (! [:backspace nil])) - - ;; Delete (forward delete) - (= key "Delete") - (do (.preventDefault e) - (! [:delete nil])))))] - (.addEventListener node "keydown" handler) - (fn [] (.removeEventListener node "keydown" handler)))))) - -(def >raf - (m/observe (fn [!] (let [active? (volatile! true) callback (fn loop [t] @@ -137,8 +141,10 @@ (js/requestAnimationFrame callback) #(vreset! active? false))))) -(def >blink-timer - "Emits true/false every 530ms for caret blinking" +(defn make-blink-timer + "Creates a fresh blink timer flow - emits true/false every 530ms. + IMPORTANT: Must be called fresh for each subscription, not shared!" + [] (m/ap (loop [] (m/amb true @@ -147,725 +153,929 @@ (do (m/? (m/sleep 530)) (recur)))))))) -(defn start-loop! [node device ctx geometry line-lengths lines tokenize-fn layout-fn find-bracket-fn detect-folds-fn find-form-fn eval-form-fn atlas] - (let [;; Layout Configuration (Must match Editor defaults) +;; ============================================================================ +;; LAYER 3: DERIVED FLOWS (Pure Transformations) +;; ============================================================================ + +(defn logical line index + (loop [logical-idx 0 + mapping []] + (if (>= logical-idx num-lines) + mapping + (let [;; Check if this line should be visible + visible? (not (some (fn [{:keys [start-line end-line]}] + (and (contains? folded start-line) + (> logical-idx start-line) + (<= logical-idx end-line))) + regions))] + (if visible? + (recur (inc logical-idx) (conj mapping logical-idx)) + (recur (inc logical-idx) mapping)))))))) + +(defn keyboard] + (->> >keyboard + (m/eduction (filter :global?)))) + +(defn keyboard !focus] + (->> >keyboard + (m/eduction (filter (fn [event] + (and (= @!focus :editor) + (not (:global? event)))))))) + +(defn keyboard !focus] + (->> >keyboard + (m/eduction (filter (fn [event] + (and (= @!focus :command-panel) + (not (:global? event)))))))) + +;; ============================================================================ +;; LAYER 5: COMPONENT UPDATE FLOWS +;; ============================================================================ + +(defn editor-apply-event + "Pure function: apply event to editor doc, returns new doc" + [doc event line-lengths clipboard] + (let [input {:lines (:lines doc) + :cursor (:cursor doc) + :selection (:selection doc) + :desired-col (:desired-col doc)}] + (case (:type event) + :char + (let [new-input (text-input/insert-char input (:char event) true)] + (merge doc new-input {:selection nil})) + + :backspace + (let [new-input (text-input/delete-backward input true)] + (merge doc new-input)) + + :delete + (let [new-input (text-input/delete-forward input true)] + (merge doc new-input)) + + :enter + (let [new-input (text-input/insert-char input "\n" true)] + (merge doc new-input {:selection nil})) + + :left + (let [new-input (text-input/move-cursor input :left true line-lengths)] + (merge doc new-input)) + + :right + (let [new-input (text-input/move-cursor input :right true line-lengths)] + (merge doc new-input)) + + :up + (let [new-input (text-input/move-cursor input :up true line-lengths)] + (merge doc new-input)) + + :down + (let [new-input (text-input/move-cursor input :down true line-lengths)] + (merge doc new-input)) + + :home + (let [new-input (text-input/move-cursor input :home true line-lengths)] + (merge doc new-input)) + + :end + (let [new-input (text-input/move-cursor input :end true line-lengths)] + (merge doc new-input)) + + :word-left + (let [new-input (text-input/move-word input :left true line-lengths)] + (merge doc new-input)) + + :word-right + (let [new-input (text-input/move-word input :right true line-lengths)] + (merge doc new-input)) + + :paste + (if clipboard + (let [new-input (text-input/paste input clipboard true)] + (merge doc new-input)) + doc) + + ;; Default: no change + doc))) + +(defn cmd-panel-apply-event + "Pure function: apply event to command panel, returns new panel state" + [panel event clipboard] + (let [input {:text (:text panel) :cursor (:cursor panel)}] + (case (:type event) + :char + (let [new-input (text-input/insert-char input (:char event) false)] + (assoc panel :text (:text new-input) :cursor (:cursor new-input))) + + :backspace + (let [new-input (text-input/delete-backward input false)] + (assoc panel :text (:text new-input) :cursor (:cursor new-input))) + + :delete + (let [new-input (text-input/delete-forward input false)] + (assoc panel :text (:text new-input) :cursor (:cursor new-input))) + + :enter + ;; Submit command - will be handled by caller + panel + + :left + (let [new-input (text-input/move-cursor input :left false nil)] + (assoc panel :cursor (:cursor new-input))) + + :right + (let [new-input (text-input/move-cursor input :right false nil)] + (assoc panel :cursor (:cursor new-input))) + + :home + (let [new-input (text-input/move-cursor input :home false nil)] + (assoc panel :cursor (:cursor new-input))) + + :end + (let [new-input (text-input/move-cursor input :end false nil)] + (assoc panel :cursor (:cursor new-input))) + + :word-left + (let [new-input (text-input/move-word input :left false nil)] + (assoc panel :cursor (:cursor new-input))) + + :word-right + (let [new-input (text-input/move-word input :right false nil)] + (assoc panel :cursor (:cursor new-input))) + + :paste + (if clipboard + (let [new-input (text-input/paste input clipboard false)] + (assoc panel :text (:text new-input) :cursor (:cursor new-input))) + panel) + + ;; Default: no change + panel))) + +;; ============================================================================ +;; LAYER 6: GPU STATE DERIVED FLOWS +;; ============================================================================ + +(defn calculate-logical->visual + "Create reverse mapping from logical line to visual line" + [line-mapping] + (reduce-kv (fn [m visual-idx logical-idx] + (assoc m logical-idx visual-idx)) + {} + (vec line-mapping))) + +(defn compute-editor-rects + "Pure function: compute all editor rectangles from inputs" + [doc folded eval-result caret-visible + detect-folds-fn find-bracket-fn + font-size layout-x layout-y line-h gutter-w] + (let [lines (:lines doc) + cursor (:cursor doc) + selection (:selection doc) + + lengths (mapv count lines) + regions (or (detect-folds-fn lines lengths) []) + + ;; Build line mapping + line-mapping (loop [logical-idx 0 mapping []] + (if (>= logical-idx (count lines)) + mapping + (let [visible? (not (some (fn [{:keys [start-line end-line]}] + (and (contains? folded start-line) + (> logical-idx start-line) + (<= logical-idx end-line))) + regions))] + (if visible? + (recur (inc logical-idx) (conj mapping logical-idx)) + (recur (inc logical-idx) mapping))))) + + logical->visual (calculate-logical->visual line-mapping) + + ;; Helper to get visual y for logical line + logical->visual-y (fn [logical-line] + (when-let [visual-idx (get logical->visual logical-line)] + (+ layout-y (* visual-idx line-h)))) + + char-w (* font-size 0.6) + + ;; Fold indicator rects + fold-rects (keep (fn [{:keys [start-line]}] + (when-let [visual-y (logical->visual-y start-line)] + (let [is-folded? (contains? folded start-line) + indicator-size 10 + x (+ 50 (/ (- gutter-w indicator-size) 2)) + y (+ visual-y (/ (- line-h indicator-size) 2))] + {:x x :y y :w indicator-size :h indicator-size + :r (if is-folded? 0.3 0.7) + :g (if is-folded? 0.5 0.6) + :b (if is-folded? 0.9 0.3) + :a 0.8}))) + regions) + + ;; Bracket match rects + bracket-match (when (and cursor (not selection)) + (find-bracket-fn cursor lines lengths)) + bracket-rects (when bracket-match + (keep (fn [{:keys [line col]}] + (when-let [visual-y (logical->visual-y line)] + {:x (+ layout-x (* col char-w)) + :y visual-y + :w char-w + :h line-h + :r 0.8 :g 0.6 :b 0.2 :a 0.4})) + [(:open bracket-match) (:close bracket-match)])) + + ;; Caret rect (when no selection) + caret-rect (when (and cursor caret-visible (not selection)) + (when-let [visual-y (logical->visual-y (:line cursor))] + {:x (+ layout-x (* (:col cursor) char-w)) + :y visual-y + :w 2 + :h line-h + :r 0.9 :g 0.9 :b 0.9 :a 1.0})) + + ;; Selection rects + selection-rects (when selection + (let [{:keys [start end]} selection + [s e] (if (or (> (:line start) (:line end)) + (and (= (:line start) (:line end)) + (> (:col start) (:col end)))) + [end start] + [start end])] + (keep (fn [logical-line] + (when-let [visual-y (logical->visual-y logical-line)] + (let [line-len (get lengths logical-line 0) + col-start (if (= logical-line (:line s)) (:col s) 0) + col-end (if (= logical-line (:line e)) (:col e) line-len) + width-chars (- col-end col-start)] + (when (> width-chars 0) + {:x (+ layout-x (* col-start char-w)) + :y visual-y + :w (* width-chars char-w) + :h line-h + :r 0.2 :g 0.4 :b 0.9 :a 0.5})))) + (range (:line s) (inc (:line e)))))) + + ;; Eval result rect + eval-rect (when eval-result + (let [now (js/Date.now)] + (when (< now (:expires-at eval-result)) + (when-let [visual-y (logical->visual-y (:line eval-result))] + (let [line-len (get lengths (:line eval-result) 0) + result-x (+ layout-x (* (+ line-len 2) char-w)) + result-w (* (count (:text eval-result)) char-w)] + {:x result-x + :y visual-y + :w (+ result-w 16) + :h line-h + :r (if (str/starts-with? (:text eval-result) "=>") 0.1 0.4) + :g (if (str/starts-with? (:text eval-result) "=>") 0.3 0.1) + :b 0.1 + :a 0.8})))))] + + (vec (concat fold-rects + (or bracket-rects []) + (or selection-rects []) + (if caret-rect [caret-rect] []) + (if eval-rect [eval-rect] []))))) + +(defn " + :type :macro + :from 0 :to 2 + :x 40 :y cmd-text-y + :size font-size + :r 0.3 :g 0.6 :b 1.0 :a 1.0}] + (when (empty? (:text panel)) + [{:text "Type a task..." + :type :comment + :from 0 :to 14 + :x 60 :y cmd-text-y + :size font-size + :r 0.5 :g 0.5 :b 0.5 :a 0.7}])]))] + + {:render-ops (if (:visible panel) + (vec (concat editor-ops (filter some? cmd-ops))) + editor-ops) + :line-mapping (:line-mapping layout-result) + :editor-line-count (count editor-ops)})) + (m/watch !editor-doc) + (m/watch !cmd-panel) + (m/watch !scroll-y) + (m/watch !viewport) + (m/watch !folded-lines))) + +;; ============================================================================ +;; LAYER 7: TERMINAL RENDER CONSUMER +;; ============================================================================ + +(defn start-loop! + "Start the reactive editor loop. + + This is the main entry point. It creates the reactive flow graph and + returns a Missionary task that runs the render loop." + [node device ctx geometry initial-line-lengths initial-lines + tokenize-fn layout-fn find-bracket-fn detect-folds-fn + find-form-fn eval-form-fn atlas] + + (let [;; Layout Configuration font-size 16 - gutter-w 40 ;; Width of gutter for fold indicators - layout-x (+ 50 gutter-w) ;; Shift text right to make room for gutter + gutter-w 40 + layout-x (+ 50 gutter-w) layout-y 100 line-h (* font-size 1.2) - - initial-state {:scroll-y 0 - :width (.-innerWidth js/window) - :height (.-innerHeight js/window) - :dpr (or (.-devicePixelRatio js/window) 1) - :dragging? false - :sel-start nil - :sel-end nil - :desired-col 0 - :caret-visible true - :folded-lines #{} - :eval-result nil} ;; {:text "=> 42" :line 5 :expires-at } - - !state (atom initial-state) - !rect-sys (atom (:rect geometry)) - !lines (atom lines) - !line-lengths (atom line-lengths) - !text-geo (atom (:text geometry)) - !fold-regions (atom []) - !line-mapping (atom []) ;; Maps visual line index -> logical line index - !clipboard (atom nil) ;; Internal clipboard for copy/cut/paste - !undo-stack (atom []) ;; Stack of previous states for undo - !redo-stack (atom []) ;; Stack of undone states for redo - - ;; Helper to save state before edit - save-undo! (fn [lines-val cursor] - (swap! !undo-stack conj {:lines lines-val :cursor cursor}) - ;; Limit stack size to 100 + cmd-panel-h 40 + + ;; ===================================================================== + ;; LAYER 1: PRIMARY SOURCE ATOMS + ;; ===================================================================== + + !editor-doc (atom {:lines initial-lines + :cursor {:line 0 :col 0} + :selection nil + :desired-col 0}) + + !cmd-panel (atom {:text "" :cursor 0 :visible false}) + + !focus (atom :editor) + + !scroll-y (atom 0) + + !viewport (atom {:width (.-innerWidth js/window) + :height (.-innerHeight js/window) + :dpr (or (.-devicePixelRatio js/window) 1)}) + + !folded-lines (atom #{}) + + ;; Additional state atoms (not primary sources, but needed for features) + !caret-visible (atom true) + !clipboard (atom nil) + !undo-stack (atom []) + !redo-stack (atom []) + !eval-result (atom nil) + !dragging? (atom false) + + ;; GPU state atoms (terminals update these) + !text-geo (atom (:text geometry)) + !editor-rect-sys (atom (:rect geometry)) + !cmd-rect-sys (atom (let [capacity 16 + instance-buffer (.createBuffer device + (clj->js {:size (* capacity 32) + :usage (bit-or js/GPUBufferUsage.VERTEX + js/GPUBufferUsage.COPY_DST)}))] + {:pipeline (:pipeline (:rect geometry)) + :bind-group (:bind-group (:rect geometry)) + :instance-buffer instance-buffer + :num-instances 0})) + + ;; Helper functions + save-undo! (fn [lines cursor] + (swap! !undo-stack conj {:lines lines :cursor cursor}) (when (> (count @!undo-stack) 100) (swap! !undo-stack #(vec (drop 1 %)))) - ;; Clear redo stack on new edit (reset! !redo-stack [])) - - ;; Event Streams - wheel-deltas (->> (>wheel-deltas node) (m/relieve +)) - mouse-events (->> (>mouse-events node) (m/relieve (fn [_ x] x))) - keyboard-events (->> (>keyboard-events js/window) (m/relieve (fn [_ x] x))) - window-metrics (> (mx/mix - (m/eduction (map (fn [m] [:resize m])) window-metrics) - (m/eduction (map (fn [x] [:wheel x])) wheel-deltas) - (m/eduction (map (fn [v] [:blink v])) >blink-timer) - mouse-events - keyboard-events) + ;; ===================================================================== + ;; LAYER 2: EVENT FLOWS + ;; ===================================================================== + ;; IMPORTANT: These must be fresh flows, not shared top-level defs! + + >raf (make-raf-flow) ;; Fresh RAF flow for this instance + >blink-timer (make-blink-timer) ;; Fresh blink timer for this instance + >resize (>window-resize) + >wheel-events (>wheel node) + >mouse-events (->> (>mouse node) (m/relieve (fn [_ x] x))) + >keyboard-events (>keyboard js/window) + + ;; ===================================================================== + ;; LAYER 4: FOCUS-BASED EVENT ROUTING + ;; ===================================================================== + + keyboard-events) + keyboard-events !focus) + keyboard-events !focus)] + + (m/join vector + + ;; ===================================================================== + ;; BLINK TIMER CONSUMER + ;; ===================================================================== + (->> >blink-timer + (m/reduce (fn [_ v] (reset! !caret-visible v) nil) nil)) + + ;; ===================================================================== + ;; VIEWPORT RESIZE CONSUMER + ;; ===================================================================== + (->> >resize (m/reduce - (fn [_ [type value]] - (swap! !state - (fn [state] - (case type - :resize (let [{:keys [width height dpr]} value] - (set! (.-width node) (Math/floor (* width dpr))) - (set! (.-height node) (Math/floor (* height dpr))) - (merge state value)) - - :wheel (update state :scroll-y + value) - - :blink (assoc state :caret-visible value) - - :mousedown - (let [{:keys [x y]} value - adj-y (+ y (:scroll-y state))] - ;; Check if click is in gutter area (for fold toggle) - ;; Gutter spans from x=50 to x=50+gutter-w - (if (and (>= x 50) (< x (+ 50 gutter-w))) - ;; Gutter click - toggle fold - ;; Use visual line to find which fold indicator was clicked - (let [visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) - ;; Map visual line to logical line - logical-line (get @!line-mapping visual-line visual-line) - ;; Find fold region starting at this logical line - fold-region (first (filter #(= (:start-line %) logical-line) @!fold-regions))] - (if fold-region - (update state :folded-lines - (fn [folded] - (if (contains? folded logical-line) - (disj folded logical-line) - (conj folded logical-line)))) - state)) - ;; Normal click - cursor placement with visual->logical mapping - (let [visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) - logical-line (get @!line-mapping visual-line (min visual-line (dec (count @!line-lengths)))) - line-len (get @!line-lengths logical-line 0) - char-w (* font-size 0.6) - col (-> (/ (- x layout-x) char-w) - (Math/round) - (max 0) - (min line-len)) - pos {:line logical-line :col col}] - (assoc state :dragging? true :sel-start pos :sel-end pos - :desired-col col :caret-visible true)))) - - :mousemove - (if (:dragging? state) - (let [{:keys [x y]} value - adj-y (+ y (:scroll-y state)) - ;; Use visual->logical mapping for selection - visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) - logical-line (get @!line-mapping visual-line (min visual-line (dec (count @!line-lengths)))) - line-len (get @!line-lengths logical-line 0) - char-w (* font-size 0.6) - col (-> (/ (- x layout-x) char-w) - (Math/round) - (max 0) - (min line-len)) - pos {:line logical-line :col col}] - (assoc state :sel-end pos)) - state) - - :mouseup - (assoc state :dragging? false) - - :char-input - (if-let [pos (:sel-start state)] - (let [_ (save-undo! @!lines pos) - line-idx (:line pos) - col (:col pos) - current-line (get @!lines line-idx "") - before (subs current-line 0 col) - after (subs current-line col) - new-line (str before value after) - new-lines (assoc @!lines line-idx new-line) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state - :sel-start {:line line-idx :col (inc col)} - :sel-end {:line line-idx :col (inc col)} - :desired-col (inc col) - :caret-visible true)) - state) - - :backspace - (if-let [pos (:sel-start state)] - (let [line-idx (:line pos) - col (:col pos)] - (cond - ;; At start of line - join with previous line - (and (= col 0) (> line-idx 0)) - (let [_ (save-undo! @!lines pos) - current-line (get @!lines line-idx "") - prev-line (get @!lines (dec line-idx) "") - prev-len (count prev-line) - merged-line (str prev-line current-line) - new-lines (vec (concat (subvec @!lines 0 (dec line-idx)) - [merged-line] - (subvec @!lines (inc line-idx)))) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state - :sel-start {:line (dec line-idx) :col prev-len} - :sel-end {:line (dec line-idx) :col prev-len} - :desired-col prev-len - :caret-visible true)) - - ;; Delete character before cursor - (> col 0) - (let [_ (save-undo! @!lines pos) - current-line (get @!lines line-idx "") - before (subs current-line 0 (dec col)) - after (subs current-line col) - new-line (str before after) - new-lines (assoc @!lines line-idx new-line) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state - :sel-start {:line line-idx :col (dec col)} - :sel-end {:line line-idx :col (dec col)} - :desired-col (dec col) - :caret-visible true)) - - ;; At start of first line - do nothing - :else state)) - state) - - :delete - (if-let [pos (:sel-start state)] - (let [line-idx (:line pos) - col (:col pos) - current-line (get @!lines line-idx "") - line-len (count current-line) - max-line (dec (count @!lines))] - (cond - ;; At end of line - join with next line - (and (= col line-len) (< line-idx max-line)) - (let [_ (save-undo! @!lines pos) - next-line (get @!lines (inc line-idx) "") - merged-line (str current-line next-line) - new-lines (vec (concat (subvec @!lines 0 line-idx) - [merged-line] - (subvec @!lines (+ line-idx 2)))) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state :caret-visible true)) - - ;; Delete character at cursor - (< col line-len) - (let [_ (save-undo! @!lines pos) - before (subs current-line 0 col) - after (subs current-line (inc col)) - new-line (str before after) - new-lines (assoc @!lines line-idx new-line) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state :caret-visible true)) - - ;; At end of last line - do nothing - :else state)) - state) - - :copy - (let [sel-start (:sel-start state) - sel-end (:sel-end state)] - (if (and sel-start sel-end - (not (and (= (:line sel-start) (:line sel-end)) - (= (:col sel-start) (:col sel-end))))) - ;; Has selection - copy it - (let [[start end] (if (or (> (:line sel-start) (:line sel-end)) - (and (= (:line sel-start) (:line sel-end)) - (> (:col sel-start) (:col sel-end)))) - [sel-end sel-start] - [sel-start sel-end]) - lines-val @!lines - text (if (= (:line start) (:line end)) - ;; Single line selection - (subs (get lines-val (:line start) "") - (:col start) (:col end)) - ;; Multi-line selection - (let [first-line (subs (get lines-val (:line start) "") (:col start)) - middle-lines (for [i (range (inc (:line start)) (:line end))] - (get lines-val i "")) - last-line (subs (get lines-val (:line end) "") 0 (:col end))] - (str/join "\n" (concat [first-line] middle-lines [last-line]))))] - (reset! !clipboard text) - (js/console.log "Copied:" text) - state) - ;; No selection - copy current line - (when-let [pos sel-start] - (let [line-text (get @!lines (:line pos) "")] - (reset! !clipboard (str line-text "\n")) - (js/console.log "Copied line:" line-text) - state)))) - - :cut - (let [sel-start (:sel-start state) - sel-end (:sel-end state)] - (if (and sel-start sel-end - (not (and (= (:line sel-start) (:line sel-end)) - (= (:col sel-start) (:col sel-end))))) - ;; Has selection - copy then delete - (let [_ (save-undo! @!lines sel-start) - [start end] (if (or (> (:line sel-start) (:line sel-end)) - (and (= (:line sel-start) (:line sel-end)) - (> (:col sel-start) (:col sel-end)))) - [sel-end sel-start] - [sel-start sel-end]) - lines-val @!lines - text (if (= (:line start) (:line end)) - (subs (get lines-val (:line start) "") - (:col start) (:col end)) - (let [first-line (subs (get lines-val (:line start) "") (:col start)) - middle-lines (for [i (range (inc (:line start)) (:line end))] - (get lines-val i "")) - last-line (subs (get lines-val (:line end) "") 0 (:col end))] - (str/join "\n" (concat [first-line] middle-lines [last-line])))) - ;; Delete selection - before (subs (get lines-val (:line start) "") 0 (:col start)) - after (subs (get lines-val (:line end) "") (:col end)) - merged-line (str before after) - new-lines (vec (concat (subvec lines-val 0 (:line start)) - [merged-line] - (subvec lines-val (inc (:line end))))) - new-line-lengths (mapv count new-lines)] - (reset! !clipboard text) - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (js/console.log "Cut:" text) - (assoc state :sel-start start :sel-end start - :desired-col (:col start) :caret-visible true)) - ;; No selection - cut current line - (when-let [pos sel-start] - (let [_ (save-undo! @!lines pos) - line-idx (:line pos) - line-text (get @!lines line-idx "") - lines-val @!lines] - (reset! !clipboard (str line-text "\n")) - (if (= 1 (count lines-val)) - ;; Only one line - clear it - (do (reset! !lines [""]) - (reset! !line-lengths [0]) - (assoc state :sel-start {:line 0 :col 0} - :sel-end {:line 0 :col 0} - :desired-col 0 :caret-visible true)) - ;; Multiple lines - remove this one - (let [new-lines (vec (concat (subvec lines-val 0 line-idx) - (subvec lines-val (inc line-idx)))) - new-line-lengths (mapv count new-lines) - new-line-idx (min line-idx (dec (count new-lines)))] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state :sel-start {:line new-line-idx :col 0} - :sel-end {:line new-line-idx :col 0} - :desired-col 0 :caret-visible true))))))) - - :paste - (if-let [text @!clipboard] - (if-let [pos (:sel-start state)] - (let [_ (save-undo! @!lines pos) - line-idx (:line pos) - col (:col pos) - current-line (get @!lines line-idx "") - before (subs current-line 0 col) - after (subs current-line col) - paste-lines (str/split-lines text) - new-lines - (if (= 1 (count paste-lines)) - ;; Single line paste - (assoc @!lines line-idx (str before (first paste-lines) after)) - ;; Multi-line paste - (let [first-new-line (str before (first paste-lines)) - middle-lines (subvec paste-lines 1 (dec (count paste-lines))) - last-new-line (str (last paste-lines) after)] - (vec (concat (subvec @!lines 0 line-idx) - [first-new-line] - middle-lines - [last-new-line] - (subvec @!lines (inc line-idx)))))) - new-line-lengths (mapv count new-lines) - ;; Calculate new cursor position - new-pos (if (= 1 (count paste-lines)) - {:line line-idx :col (+ col (count (first paste-lines)))} - {:line (+ line-idx (dec (count paste-lines))) - :col (count (last paste-lines))})] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state :sel-start new-pos :sel-end new-pos - :desired-col (:col new-pos) :caret-visible true)) - state) - state) - - :undo - (if-let [prev (peek @!undo-stack)] - (let [current-lines @!lines - current-cursor (:sel-start state)] - ;; Push current state to redo stack - (swap! !redo-stack conj {:lines current-lines :cursor current-cursor}) - ;; Pop from undo stack - (swap! !undo-stack pop) - ;; Restore previous state - (reset! !lines (:lines prev)) - (reset! !line-lengths (mapv count (:lines prev))) - (let [cursor (:cursor prev)] - (assoc state :sel-start cursor :sel-end cursor - :desired-col (:col cursor) :caret-visible true))) - state) - - :redo - (if-let [next-state (peek @!redo-stack)] - (let [current-lines @!lines - current-cursor (:sel-start state)] - ;; Push current state to undo stack - (swap! !undo-stack conj {:lines current-lines :cursor current-cursor}) - ;; Pop from redo stack - (swap! !redo-stack pop) - ;; Restore next state - (reset! !lines (:lines next-state)) - (reset! !line-lengths (mapv count (:lines next-state))) - (let [cursor (:cursor next-state)] - (assoc state :sel-start cursor :sel-end cursor - :desired-col (:col cursor) :caret-visible true))) - state) - - :save - (let [content (str/join "\n" @!lines) - blob (js/Blob. #js [content] #js {:type "text/plain"}) - url (js/URL.createObjectURL blob) - a (js/document.createElement "a")] - (set! (.-href a) url) - (set! (.-download a) "code.clj") - (.click a) - (js/URL.revokeObjectURL url) - (js/console.log "Saved file: code.clj" (count content) "bytes") - state) - - :enter - (if-let [pos (:sel-start state)] - (let [_ (save-undo! @!lines pos) - line-idx (:line pos) - col (:col pos) - current-line (get @!lines line-idx "") - before (subs current-line 0 col) - after (subs current-line col) - new-lines (vec (concat (subvec @!lines 0 line-idx) - [before after] - (subvec @!lines (inc line-idx)))) - new-line-lengths (mapv count new-lines)] - (reset! !lines new-lines) - (reset! !line-lengths new-line-lengths) - (assoc state - :sel-start {:line (inc line-idx) :col 0} - :sel-end {:line (inc line-idx) :col 0} - :desired-col 0 - :caret-visible true)) - state) - - :keydown - (if-let [pos (:sel-start state)] - (let [line (:line pos) - col (:col pos) - desired (:desired-col state) - line-lengths-val @!line-lengths - max-line (dec (count line-lengths-val)) - line-len (get line-lengths-val line 0) - [new-pos new-desired] - (case value - "ArrowLeft" - (let [np (if (> col 0) - {:line line :col (dec col)} - (if (> line 0) - (let [prev-len (get line-lengths-val (dec line) 0)] - {:line (dec line) :col prev-len}) - pos))] - [np (:col np)]) - - "ArrowRight" - (let [np (if (< col line-len) - {:line line :col (inc col)} - (if (< line max-line) - {:line (inc line) :col 0} - pos))] - [np (:col np)]) - - "ArrowUp" - (if (> line 0) - (let [prev-len (get line-lengths-val (dec line) 0)] - [{:line (dec line) :col (min desired prev-len)} desired]) - [pos desired]) - - "ArrowDown" - (if (< line max-line) - (let [next-len (get line-lengths-val (inc line) 0)] - [{:line (inc line) :col (min desired next-len)} desired]) - [pos desired]) - - "Home" - [{:line line :col 0} 0] - - "End" - [{:line line :col line-len} line-len] - - [pos desired]) - - ;; Auto-scroll to keep caret visible - caret-y (+ layout-y (* (:line new-pos) line-h)) - viewport-top (:scroll-y state) - viewport-bottom (+ viewport-top (:height state)) - - ;; Add padding (one line worth) - padding line-h - - new-scroll (cond - ;; Caret above viewport - scroll up - (< caret-y (+ viewport-top padding)) - (max 0 (- caret-y padding)) - - ;; Caret below viewport - scroll down - (> (+ caret-y line-h) (- viewport-bottom padding)) - (+ (- caret-y (:height state)) line-h padding) - - ;; Caret in view - don't scroll - :else - (:scroll-y state))] - - (assoc state :sel-start new-pos :sel-end new-pos - :desired-col new-desired :caret-visible true - :scroll-y new-scroll)) - state) - - :word-nav - (if-let [pos (:sel-start state)] - (let [line-idx (:line pos) - col (:col pos) - current-line (get @!lines line-idx "") - line-lengths-val @!line-lengths - max-line (dec (count line-lengths-val)) - ;; Word boundary detection helper - word-char? (fn [c] (re-matches #"[\w]" (str c))) - new-pos - (case value - "ArrowLeft" - (if (= col 0) - ;; At start of line, go to end of previous line - (if (> line-idx 0) - {:line (dec line-idx) :col (get line-lengths-val (dec line-idx) 0)} - pos) - ;; Find previous word boundary - (let [before (subs current-line 0 col) - ;; Skip whitespace, then skip word chars - skip-ws (loop [i (dec (count before))] - (if (and (>= i 0) - (= \space (nth before i))) - (recur (dec i)) - i)) - ;; Now skip word chars - new-col (loop [i skip-ws] - (if (and (>= i 0) - (word-char? (nth before i))) - (recur (dec i)) - (inc i)))] - {:line line-idx :col (max 0 new-col)})) - - "ArrowRight" - (let [line-len (count current-line)] - (if (= col line-len) - ;; At end of line, go to start of next line - (if (< line-idx max-line) - {:line (inc line-idx) :col 0} - pos) - ;; Find next word boundary - (let [after (subs current-line col) - ;; Skip current word chars, then skip whitespace - skip-word (loop [i 0] - (if (and (< i (count after)) - (word-char? (nth after i))) - (recur (inc i)) - i)) - ;; Now skip whitespace - new-col (loop [i skip-word] - (if (and (< i (count after)) - (= \space (nth after i))) - (recur (inc i)) - i))] - {:line line-idx :col (+ col new-col)}))) - - pos)] - (assoc state :sel-start new-pos :sel-end new-pos - :desired-col (:col new-pos) :caret-visible true)) - state) - - :eval - (if-let [pos (:sel-start state)] - (if-let [form-info (find-form-fn pos @!lines @!line-lengths)] - (let [result-text (eval-form-fn (:form-str form-info)) - ;; Show result after the form's end line - result-line (:end-line form-info)] - (js/console.log "SCI Eval:" (:form-str form-info) "=>" result-text) - (assoc state :eval-result {:text result-text - :line result-line - :expires-at (+ (js/Date.now) 5000)})) - ;; No form found at cursor - (do (js/console.log "SCI: No form at cursor") - (assoc state :eval-result {:text "No form at cursor" - :line (:line pos) - :expires-at (+ (js/Date.now) 2000)}))) - state) - - state)))) + (fn [_ {:keys [width height dpr]}] + (reset! !viewport {:width width :height height :dpr dpr}) + (set! (.-width node) (Math/floor (* width dpr))) + (set! (.-height node) (Math/floor (* height dpr))) + nil) + nil)) + + ;; ===================================================================== + ;; SCROLL CONSUMER + ;; ===================================================================== + (->> >wheel-events + (m/reduce (fn [_ delta] + #_(js/console.log "Scroll:" delta) + (swap! !scroll-y + delta) nil) nil)) + + ;; ===================================================================== + ;; MOUSE EVENTS CONSUMER + ;; ===================================================================== + (->> >mouse-events + (m/reduce + (fn [_ [type coords]] + #_(js/console.log "Mouse:" type coords) + (case type + :mousedown + (let [{:keys [x y]} coords + viewport @!viewport + scroll-y @!scroll-y + cmd-panel @!cmd-panel + cmd-visible? (:visible cmd-panel) + cmd-panel-top (if cmd-visible? + (- (:height viewport) cmd-panel-h) + (:height viewport)) + clicked-in-cmd? (and cmd-visible? (>= y cmd-panel-top))] + (if clicked-in-cmd? + ;; Click in command panel + (let [char-w (* font-size 0.6) + text (:text cmd-panel) + col (-> (/ (- x 60) char-w) + (Math/round) + (max 0) + (min (count text)))] + (reset! !focus :command-panel) + (swap! !cmd-panel assoc :cursor col) + (reset! !caret-visible true)) + ;; Click in editor + (let [adj-y (+ y scroll-y)] + (if (and (>= x 50) (< x (+ 50 gutter-w))) + ;; Gutter click - toggle fold + (let [text-result @!text-geo + line-mapping (or (:line-mapping text-result) []) + visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) + logical-line (get line-mapping visual-line visual-line) + regions (detect-folds-fn (:lines @!editor-doc) + (mapv count (:lines @!editor-doc))) + fold-region (first (filter #(= (:start-line %) logical-line) + (or regions [])))] + (when fold-region + (swap! !folded-lines + (fn [folded] + (if (contains? folded logical-line) + (disj folded logical-line) + (conj folded logical-line))))) + (reset! !focus :editor)) + ;; Normal click - place cursor + (let [text-result @!text-geo + line-mapping (or (:line-mapping text-result) []) + lengths (mapv count (:lines @!editor-doc)) + visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) + logical-line (get line-mapping visual-line + (min visual-line (dec (count lengths)))) + line-len (get lengths logical-line 0) + char-w (* font-size 0.6) + col (-> (/ (- x layout-x) char-w) + (Math/round) + (max 0) + (min line-len)) + pos {:line logical-line :col col}] + (reset! !dragging? true) + (swap! !editor-doc assoc + :cursor pos + :selection nil + :desired-col col) + (reset! !caret-visible true) + (reset! !focus :editor)))))) + + :mousemove + (when @!dragging? + (let [{:keys [x y]} coords + scroll-y @!scroll-y + adj-y (+ y scroll-y) + text-result @!text-geo + line-mapping (or (:line-mapping text-result) []) + lengths (mapv count (:lines @!editor-doc)) + visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) + logical-line (get line-mapping visual-line + (min visual-line (dec (count lengths)))) + line-len (get lengths logical-line 0) + char-w (* font-size 0.6) + col (-> (/ (- x layout-x) char-w) + (Math/round) + (max 0) + (min line-len)) + pos {:line logical-line :col col} + doc @!editor-doc + start-pos (:cursor doc)] + (when (not= pos start-pos) + (swap! !editor-doc assoc + :selection {:start start-pos :end pos})))) + + :mouseup + (reset! !dragging? false)) + nil) + nil)) + + ;; ===================================================================== + ;; GLOBAL KEYBOARD EVENTS CONSUMER + ;; ===================================================================== + (->> > (m/latest vector - (m/watch !lines) - (m/eduction (map :folded-lines) (m/watch !state))) - (m/eduction (dedupe)) + ;; ===================================================================== + ;; EDITOR KEYBOARD EVENTS CONSUMER + ;; ===================================================================== + (->> (+ caret-y line-h) (- viewport-bottom padding)) + (+ (- caret-y (:height viewport)) line-h padding) + + :else scroll-y)] + (reset! !editor-doc new-doc) + (reset! !scroll-y new-scroll) + (reset! !caret-visible true)) + + :copy + (let [input {:lines (:lines doc) + :cursor (:cursor doc) + :selection (:selection doc)} + text (text-input/copy input true)] + (when text + (reset! !clipboard text) + (js/console.log "Copied:" text))) + + :cut + (let [input {:lines (:lines doc) + :cursor (:cursor doc) + :selection (:selection doc)} + result (text-input/cut input true)] + (when (:text result) + (save-undo! (:lines doc) (:cursor doc)) + (reset! !clipboard (:text result)) + (reset! !editor-doc (merge doc (:state result))) + (js/console.log "Cut:" (:text result)))) + + :undo + (when-let [prev (peek @!undo-stack)] + (swap! !redo-stack conj {:lines (:lines doc) :cursor (:cursor doc)}) + (swap! !undo-stack pop) + (reset! !editor-doc (merge doc {:lines (:lines prev) + :cursor (:cursor prev) + :selection nil + :desired-col (:col (:cursor prev))})) + (reset! !caret-visible true)) + + :redo + (when-let [next-state (peek @!redo-stack)] + (swap! !undo-stack conj {:lines (:lines doc) :cursor (:cursor doc)}) + (swap! !redo-stack pop) + (reset! !editor-doc (merge doc {:lines (:lines next-state) + :cursor (:cursor next-state) + :selection nil + :desired-col (:col (:cursor next-state))})) + (reset! !caret-visible true)) + + :eval + (when-let [pos (:cursor doc)] + (if-let [form-info (find-form-fn pos (:lines doc) lengths)] + (let [result-text (eval-form-fn (:form-str form-info))] + (js/console.log "SCI Eval:" (:form-str form-info) "=>" result-text) + (reset! !eval-result {:text result-text + :line (:end-line form-info) + :expires-at (+ (js/Date.now) 5000)})) + (do (js/console.log "SCI: No form at cursor") + (reset! !eval-result {:text "No form at cursor" + :line (:line pos) + :expires-at (+ (js/Date.now) 2000)})))) + + nil))) nil) nil)) - ;; 3. SELECTION & CARET GEOMETRY UPDATER (with bracket matching + fold indicators + eval results) - (->> (m/watch !state) - (m/eduction - (map (fn [s] {:sel-start (:sel-start s) - :sel-end (:sel-end s) - :caret-visible (:caret-visible s) - :folded-lines (:folded-lines s) - :eval-result (:eval-result s)})) - (dedupe)) + ;; ===================================================================== + ;; COMMAND PANEL KEYBOARD EVENTS CONSUMER + ;; ===================================================================== + (->> visual line mapping (inverse) - logical->visual (reduce-kv (fn [m visual-idx logical-idx] - (assoc m logical-idx visual-idx)) - {} - (vec line-mapping)) - ;; Helper to get visual y for a logical line - logical-line->visual-y (fn [logical-line] - (if-let [visual-idx (get logical->visual logical-line)] - (+ layout-y (* visual-idx line-h)) - nil)) ;; Line is hidden (folded) - - has-selection? (and sel-start sel-end - (not (and (= (:line sel-start) (:line sel-end)) - (= (:col sel-start) (:col sel-end))))) - ;; Calculate fold indicator rects in gutter (at visual positions) - fold-rects (keep (fn [{:keys [start-line]}] - (when-let [visual-y (logical-line->visual-y start-line)] - (let [is-folded? (contains? folded-lines start-line) - indicator-size 10 - x (+ 50 (/ (- gutter-w indicator-size) 2)) - y (+ visual-y (/ (- line-h indicator-size) 2))] - {:x x :y y :w indicator-size :h indicator-size - ;; Gold for expanded, blue for folded - :r (if is-folded? 0.3 0.7) - :g (if is-folded? 0.5 0.6) - :b (if is-folded? 0.9 0.3) - :a 0.8}))) - @!fold-regions) - ;; Calculate bracket match highlights (at visual positions) - bracket-match (when (and sel-start (not has-selection?)) - (find-bracket-fn sel-start @!lines @!line-lengths)) - bracket-rects (when bracket-match - (let [{:keys [open close]} bracket-match - char-w (* font-size 0.6)] - (keep (fn [{:keys [line col]}] - (when-let [visual-y (logical-line->visual-y line)] - {:x (+ layout-x (* col char-w)) - :y visual-y - :w char-w - :h line-h - :r 0.8 :g 0.6 :b 0.2 :a 0.4})) - [open close]))) - ;; Calculate caret rect at visual position - caret-rect (when (and sel-start caret-visible (not has-selection?)) - (when-let [visual-y (logical-line->visual-y (:line sel-start))] - (let [char-w (* font-size 0.6)] - {:x (+ layout-x (* (:col sel-start) char-w)) - :y visual-y - :w 2 - :h line-h - :r 0.9 :g 0.9 :b 0.9 :a 1.0}))) - ;; Calculate selection rects at visual positions - selection-rects (when has-selection? - (let [[start end] (if (or (> (:line sel-start) (:line sel-end)) - (and (= (:line sel-start) (:line sel-end)) - (> (:col sel-start) (:col sel-end)))) - [sel-end sel-start] - [sel-start sel-end]) - char-w (* font-size 0.6)] - (keep (fn [logical-line] - (when-let [visual-y (logical-line->visual-y logical-line)] - (let [line-len (get @!line-lengths logical-line 0) - col-start (if (= logical-line (:line start)) (:col start) 0) - col-end (if (= logical-line (:line end)) (:col end) line-len) - width-chars (- col-end col-start)] - (when (> width-chars 0) - {:x (+ layout-x (* col-start char-w)) - :y visual-y - :w (* width-chars char-w) - :h line-h - :r 0.2 :g 0.4 :b 0.9 :a 0.5})))) - (range (:line start) (inc (:line end)))))) - ;; Eval result indicator (shows for 5 seconds) - eval-rect (when eval-result - (let [now (js/Date.now)] - (when (< now (:expires-at eval-result)) - ;; Render background rect for result - (when-let [visual-y (logical-line->visual-y (:line eval-result))] - ;; Position result indicator to the right of the line - (let [line-len (get @!line-lengths (:line eval-result) 0) - char-w (* font-size 0.6) - result-x (+ layout-x (* (+ line-len 2) char-w)) - result-w (* (count (:text eval-result)) char-w)] - {:x result-x - :y visual-y - :w (+ result-w 16) ;; padding - :h line-h - ;; Green background for success, red for error - :r (if (str/starts-with? (:text eval-result) "=>") 0.1 0.4) - :g (if (str/starts-with? (:text eval-result) "=>") 0.3 0.1) - :b 0.1 - :a 0.8}))))) - ;; Combine all rects - rects (vec (concat fold-rects - (or bracket-rects []) - (or selection-rects []) - (if caret-rect [caret-rect] []) - (if eval-rect [eval-rect] [])))] - (reset! !rect-sys (editor/update-rects device (:rect geometry) rects))) + (fn [_ event] + (when event + (js/console.log "Cmd Key:" (:type event)) + (case (:type event) + :enter + (let [cmd-text (:text @!cmd-panel)] + (when (seq cmd-text) + (js/console.log "Command submitted:" cmd-text)) + (swap! !cmd-panel assoc :text "" :cursor 0 :visible false) + (reset! !focus :editor)) + + ;; Edit/navigation operations + (let [panel @!cmd-panel + new-panel (cmd-panel-apply-event panel event @!clipboard)] + (reset! !cmd-panel new-panel) + (reset! !caret-visible true)))) nil) nil)) - ;; 4. RENDER LOOP (just draws, no rect calculation) - (m/reduce - (fn [_ state] - (editor/draw-frame! device ctx - @!text-geo - @!rect-sys - (:camera-floats (:pipelines geometry)) - (:pass-descriptor (:pipelines geometry)) - 0 (- (:scroll-y state)) (:width state) (:height state)) - nil) - nil - (m/sample (fn [s _t] s) (m/watch !state) >raf))))) + ;; ===================================================================== + ;; RENDER LOOP (SINGLE TERMINAL - "PULL" MODEL) + ;; ===================================================================== + ;; + ;; This is the ONLY place GPU operations happen. We "pull" the latest + ;; computed state from all derived flows when RAF fires, then upload + ;; and draw in a single atomic operation per frame. + ;; + ;; This ensures: + ;; 1. Consistent snapshot - all data is from the same logical moment + ;; 2. No wasted GPU uploads - only upload when we're about to draw + ;; 3. Frame-synchronized updates - GPU state changes aligned with vsync + ;; + (let [;; Derived flows (pure computation, no GPU side effects) + raf)))))) diff --git a/src/app/client/webgpu/text_input.cljs b/src/app/client/webgpu/text_input.cljs new file mode 100644 index 0000000..af81c86 --- /dev/null +++ b/src/app/client/webgpu/text_input.cljs @@ -0,0 +1,578 @@ +(ns app.client.webgpu.text-input + (:require [clojure.string :as str])) + +(defn- clamp [v min-v max-v] + (-> v (max min-v) (min max-v))) + +(defn- whitespace? [ch] + (boolean (re-matches #"\s" (str ch)))) + +(defn- word-char? [ch] + (boolean (re-matches #"\w" (str ch)))) + +(defn- normalize-single-selection [selection text-len] + (when (and selection (number? (:start selection)) (number? (:end selection))) + (let [start (clamp (int (:start selection)) 0 text-len) + end (clamp (int (:end selection)) 0 text-len) + [s e] (if (<= start end) [start end] [end start])] + (when (not= s e) + {:start s :end e})))) + +(defn- normalize-pos [lines pos] + (let [line-count (count lines) + line (clamp (int (or (:line pos) 0)) 0 (max 0 (dec line-count))) + line-text (get lines line "") + col (clamp (int (or (:col pos) 0)) 0 (count line-text))] + {:line line :col col})) + +(defn- pos<= [a b] + (or (< (:line a) (:line b)) + (and (= (:line a) (:line b)) + (<= (:col a) (:col b))))) + +(defn- normalize-multi-selection [selection lines] + (when (and selection (map? (:start selection)) (map? (:end selection))) + (let [start (normalize-pos lines (:start selection)) + end (normalize-pos lines (:end selection)) + [s e] (if (pos<= start end) [start end] [end start])] + (when (not (and (= (:line s) (:line e)) + (= (:col s) (:col e)))) + {:start s :end e})))) + +(defn- normalize-single [state] + (let [text (or (:text state) "") + len (count text) + cursor (clamp (int (or (:cursor state) 0)) 0 len) + selection (normalize-single-selection (:selection state) len)] + (assoc state :text text :cursor cursor :selection selection))) + +(defn- normalize-multi [state] + (let [lines (vec (or (:lines state) [""])) + lines (if (seq lines) lines [""]) + cursor (normalize-pos lines (or (:cursor state) {:line 0 :col 0})) + desired (or (:desired-col state) (:col cursor)) + selection (normalize-multi-selection (:selection state) lines)] + (assoc state + :lines lines + :cursor cursor + :desired-col desired + :selection selection))) + +(defn- resolve-line-lengths [lines line-lengths] + (if (and line-lengths (= (count line-lengths) (count lines))) + line-lengths + (mapv count lines))) + +(declare delete-selection) + +;; === Character Operations === + +(defn insert-char + "Insert character at cursor position. Works for single or multi-line." + [state ch multi-line?] + (if (nil? ch) + state + (let [ch-str (str ch)] + (if (zero? (count ch-str)) + state + (if multi-line? + (let [state (normalize-multi state) + state (if (:selection state) (delete-selection state true) state) + {:keys [lines cursor]} state + line-idx (:line cursor) + col (:col cursor) + current-line (get lines line-idx "") + before (subs current-line 0 col) + after (subs current-line col) + parts (str/split ch-str #"\n" -1) + part-count (count parts)] + (if (= part-count 1) + (let [new-line (str before (first parts) after) + new-lines (assoc lines line-idx new-line) + new-col (+ col (count (first parts)))] + (assoc state :lines new-lines + :cursor {:line line-idx :col new-col} + :desired-col new-col + :selection nil)) + (let [first-line (str before (first parts)) + last-line (str (last parts) after) + middle (subvec (vec parts) 1 (dec part-count)) + new-lines (vec (concat (subvec lines 0 line-idx) + [first-line] + middle + [last-line] + (subvec lines (inc line-idx)))) + new-line-idx (+ line-idx (dec part-count)) + new-col (count (last parts))] + (assoc state :lines new-lines + :cursor {:line new-line-idx :col new-col} + :desired-col new-col + :selection nil)))) + (let [state (normalize-single state) + state (if (:selection state) (delete-selection state false) state) + {:keys [text cursor]} state + before (subs text 0 cursor) + after (subs text cursor) + new-text (str before ch-str after) + new-cursor (+ cursor (count ch-str))] + (assoc state :text new-text :cursor new-cursor :selection nil))))))) + +(defn delete-backward + "Delete character before cursor (backspace)." + [state multi-line?] + (if multi-line? + (let [state (normalize-multi state)] + (if (:selection state) + (delete-selection state true) + (let [{:keys [lines cursor]} state + line-idx (:line cursor) + col (:col cursor) + current-line (get lines line-idx "")] + (cond + (> col 0) + (let [before (subs current-line 0 (dec col)) + after (subs current-line col) + new-line (str before after) + new-lines (assoc lines line-idx new-line) + new-col (dec col)] + (assoc state :lines new-lines + :cursor {:line line-idx :col new-col} + :desired-col new-col + :selection nil)) + + (> line-idx 0) + (let [prev-line (get lines (dec line-idx) "") + prev-len (count prev-line) + merged (str prev-line current-line) + new-lines (vec (concat (subvec lines 0 (dec line-idx)) + [merged] + (subvec lines (inc line-idx))))] + (assoc state :lines new-lines + :cursor {:line (dec line-idx) :col prev-len} + :desired-col prev-len + :selection nil)) + + :else state)))) + (let [state (normalize-single state)] + (if (:selection state) + (delete-selection state false) + (let [{:keys [text cursor]} state] + (if (> cursor 0) + (let [before (subs text 0 (dec cursor)) + after (subs text cursor) + new-text (str before after) + new-cursor (dec cursor)] + (assoc state :text new-text :cursor new-cursor :selection nil)) + state)))))) + +(defn delete-forward + "Delete character after cursor (delete key)." + [state multi-line?] + (if multi-line? + (let [state (normalize-multi state)] + (if (:selection state) + (delete-selection state true) + (let [{:keys [lines cursor]} state + line-idx (:line cursor) + col (:col cursor) + current-line (get lines line-idx "") + line-len (count current-line) + max-line (dec (count lines))] + (cond + (< col line-len) + (let [before (subs current-line 0 col) + after (subs current-line (inc col)) + new-line (str before after) + new-lines (assoc lines line-idx new-line)] + (assoc state :lines new-lines :selection nil)) + + (< line-idx max-line) + (let [next-line (get lines (inc line-idx) "") + merged (str current-line next-line) + new-lines (vec (concat (subvec lines 0 line-idx) + [merged] + (subvec lines (+ line-idx 2))))] + (assoc state :lines new-lines :selection nil)) + + :else state)))) + (let [state (normalize-single state)] + (if (:selection state) + (delete-selection state false) + (let [{:keys [text cursor]} state + text-len (count text)] + (if (< cursor text-len) + (let [before (subs text 0 cursor) + after (subs text (inc cursor)) + new-text (str before after)] + (assoc state :text new-text :selection nil)) + state)))))) + +;; === Navigation === + +(defn move-cursor + "Move cursor in direction (:left :right :up :down :home :end)." + [state direction multi-line? line-lengths] + (if multi-line? + (let [state (normalize-multi state) + {:keys [lines cursor]} state + line-lengths (resolve-line-lengths lines line-lengths) + line (:line cursor) + col (:col cursor) + desired (:desired-col state) + max-line (dec (count line-lengths)) + line-len (get line-lengths line 0) + [new-pos new-desired] + (case direction + :left + (let [np (if (> col 0) + {:line line :col (dec col)} + (if (> line 0) + (let [prev-len (get line-lengths (dec line) 0)] + {:line (dec line) :col prev-len}) + cursor))] + [np (:col np)]) + + :right + (let [np (if (< col line-len) + {:line line :col (inc col)} + (if (< line max-line) + {:line (inc line) :col 0} + cursor))] + [np (:col np)]) + + :up + (if (> line 0) + (let [prev-len (get line-lengths (dec line) 0)] + [{:line (dec line) :col (min desired prev-len)} desired]) + [cursor desired]) + + :down + (if (< line max-line) + (let [next-len (get line-lengths (inc line) 0)] + [{:line (inc line) :col (min desired next-len)} desired]) + [cursor desired]) + + :home + [{:line line :col 0} 0] + + :end + [{:line line :col line-len} line-len] + + [cursor desired])] + (assoc state :cursor new-pos :desired-col new-desired :selection nil)) + (let [state (normalize-single state) + {:keys [text cursor]} state + text-len (count text) + new-cursor (case direction + :left (max 0 (dec cursor)) + :right (min text-len (inc cursor)) + :home 0 + :end text-len + cursor)] + (assoc state :cursor new-cursor :selection nil)))) + +(defn move-word + "Move cursor by word (:left :right)." + [state direction multi-line? line-lengths] + (if multi-line? + (let [state (normalize-multi state) + {:keys [lines cursor]} state + line-lengths (resolve-line-lengths lines line-lengths) + line-idx (:line cursor) + col (:col cursor) + max-line (dec (count line-lengths)) + current-line (get lines line-idx "") + new-pos + (case direction + :left + (if (= col 0) + (if (> line-idx 0) + {:line (dec line-idx) :col (get line-lengths (dec line-idx) 0)} + cursor) + (let [before (subs current-line 0 col) + skip-ws (loop [i (dec (count before))] + (if (and (>= i 0) (whitespace? (nth before i))) + (recur (dec i)) + i)) + new-col (loop [i skip-ws] + (if (and (>= i 0) (word-char? (nth before i))) + (recur (dec i)) + (inc i)))] + {:line line-idx :col (max 0 new-col)})) + + :right + (let [line-len (count current-line)] + (if (= col line-len) + (if (< line-idx max-line) + {:line (inc line-idx) :col 0} + cursor) + (let [after (subs current-line col) + skip-word (loop [i 0] + (if (and (< i (count after)) (word-char? (nth after i))) + (recur (inc i)) + i)) + new-col (loop [i skip-word] + (if (and (< i (count after)) (whitespace? (nth after i))) + (recur (inc i)) + i))] + {:line line-idx :col (+ col new-col)}))) + cursor)] + (assoc state :cursor new-pos :desired-col (:col new-pos) :selection nil)) + (let [state (normalize-single state) + {:keys [text cursor]} state + text-len (count text) + new-cursor + (case direction + :left + (if (zero? cursor) + 0 + (let [before (subs text 0 cursor) + skip-ws (loop [i (dec (count before))] + (if (and (>= i 0) (whitespace? (nth before i))) + (recur (dec i)) + i)) + new-col (loop [i skip-ws] + (if (and (>= i 0) (word-char? (nth before i))) + (recur (dec i)) + (inc i)))] + (max 0 new-col))) + + :right + (if (= cursor text-len) + text-len + (let [after (subs text cursor) + skip-word (loop [i 0] + (if (and (< i (count after)) (word-char? (nth after i))) + (recur (inc i)) + i)) + new-col (loop [i skip-word] + (if (and (< i (count after)) (whitespace? (nth after i))) + (recur (inc i)) + i))] + (+ cursor new-col))) + cursor)] + (assoc state :cursor new-cursor :selection nil)))) + +;; === Selection === + +(defn select-all [state multi-line?] + (if multi-line? + (let [state (normalize-multi state) + lines (:lines state) + last-line (max 0 (dec (count lines))) + last-col (count (get lines last-line ""))] + (assoc state + :selection {:start {:line 0 :col 0} + :end {:line last-line :col last-col}} + :cursor {:line last-line :col last-col} + :desired-col last-col)) + (let [state (normalize-single state) + text (:text state) + text-len (count text)] + (assoc state :selection {:start 0 :end text-len} + :cursor text-len)))) + +(defn get-selected-text [state multi-line?] + (if multi-line? + (let [state (normalize-multi state) + {:keys [selection lines]} state] + (when selection + (let [{:keys [start end]} selection] + (if (= (:line start) (:line end)) + (subs (get lines (:line start) "") (:col start) (:col end)) + (let [first-line (subs (get lines (:line start) "") (:col start)) + middle (for [i (range (inc (:line start)) (:line end))] + (get lines i "")) + last-line (subs (get lines (:line end) "") 0 (:col end))] + (str/join "\n" (concat [first-line] middle [last-line]))))))) + (let [state (normalize-single state) + {:keys [selection text]} state] + (when selection + (subs text (:start selection) (:end selection)))))) + +(defn delete-selection + "Delete selected text, return new state with cursor at selection start." + [state multi-line?] + (if multi-line? + (let [state (normalize-multi state) + {:keys [selection lines]} state] + (if selection + (let [{:keys [start end]} selection + start-line (:line start) + end-line (:line end) + start-col (:col start) + end-col (:col end)] + (if (= start-line end-line) + (let [line (get lines start-line "") + before (subs line 0 start-col) + after (subs line end-col) + new-lines (assoc lines start-line (str before after))] + (assoc state :lines new-lines + :cursor {:line start-line :col start-col} + :desired-col start-col + :selection nil)) + (let [first-line (subs (get lines start-line "") 0 start-col) + last-line (subs (get lines end-line "") end-col) + merged (str first-line last-line) + new-lines (vec (concat (subvec lines 0 start-line) + [merged] + (subvec lines (inc end-line))))] + (assoc state :lines new-lines + :cursor {:line start-line :col start-col} + :desired-col start-col + :selection nil)))) + state)) + (let [state (normalize-single state) + {:keys [selection text]} state] + (if selection + (let [start (:start selection) + end (:end selection) + before (subs text 0 start) + after (subs text end) + new-text (str before after)] + (assoc state :text new-text :cursor start :selection nil)) + state)))) + +;; === Clipboard === + +(defn cut + "Cut selection. Returns {:state new-state :text cut-text}." + [state multi-line?] + (if multi-line? + (let [state (normalize-multi state) + {:keys [selection lines cursor]} state] + (if selection + (let [text (get-selected-text state true) + new-state (delete-selection state true)] + {:state new-state :text text}) + (let [line-idx (:line cursor) + line-text (get lines line-idx "") + line-text (str line-text "\n")] + (if (= 1 (count lines)) + {:state (assoc state :lines [""] + :cursor {:line 0 :col 0} + :desired-col 0 + :selection nil) + :text line-text} + (let [new-lines (vec (concat (subvec lines 0 line-idx) + (subvec lines (inc line-idx)))) + new-line-idx (min line-idx (dec (count new-lines)))] + {:state (assoc state :lines new-lines + :cursor {:line new-line-idx :col 0} + :desired-col 0 + :selection nil) + :text line-text}))))) + (let [state (normalize-single state)] + (if (:selection state) + (let [text (get-selected-text state false) + new-state (delete-selection state false)] + {:state new-state :text text}) + {:state state :text nil})))) + +(defn copy + "Copy selection. Returns selected text or current line for multi-line." + [state multi-line?] + (if multi-line? + (let [state (normalize-multi state) + {:keys [selection lines cursor]} state] + (if selection + (get-selected-text state true) + (let [line-text (get lines (:line cursor) "")] + (str line-text "\n")))) + (let [state (normalize-single state)] + (when (:selection state) + (get-selected-text state false))))) + +(defn paste [state text multi-line?] + (if (nil? text) + state + (let [text-str (str text)] + (if (zero? (count text-str)) + state + (if multi-line? + (let [state (normalize-multi state) + state (if (:selection state) (delete-selection state true) state) + {:keys [lines cursor]} state + line-idx (:line cursor) + col (:col cursor) + current-line (get lines line-idx "") + before (subs current-line 0 col) + after (subs current-line col) + parts (str/split text-str #"\n" -1) + part-count (count parts)] + (if (= part-count 1) + (let [new-line (str before (first parts) after) + new-lines (assoc lines line-idx new-line) + new-col (+ col (count (first parts)))] + (assoc state :lines new-lines + :cursor {:line line-idx :col new-col} + :desired-col new-col + :selection nil)) + (let [first-line (str before (first parts)) + last-line (str (last parts) after) + middle (subvec (vec parts) 1 (dec part-count)) + new-lines (vec (concat (subvec lines 0 line-idx) + [first-line] + middle + [last-line] + (subvec lines (inc line-idx)))) + new-line-idx (+ line-idx (dec part-count)) + new-col (count (last parts))] + (assoc state :lines new-lines + :cursor {:line new-line-idx :col new-col} + :desired-col new-col + :selection nil)))) + (let [state (normalize-single state) + state (if (:selection state) (delete-selection state false) state) + {:keys [text cursor]} state + before (subs text 0 cursor) + after (subs text cursor) + new-text (str before text-str after) + new-cursor (+ cursor (count text-str))] + (assoc state :text new-text :cursor new-cursor :selection nil))))))) + +;; === Rendering Helpers === + +(defn calculate-caret-rect + "Calculate caret rectangle for rendering." + [cursor font-size origin-x origin-y line-h visible?] + (when (and cursor visible?) + (let [{:keys [line col]} (if (map? cursor) cursor {:line 0 :col cursor}) + char-w (* font-size 0.6) + x (+ origin-x (* col char-w)) + y (+ origin-y (* line line-h))] + {:x x :y y :w 2 :h line-h + :r 0.9 :g 0.9 :b 0.9 :a 1.0}))) + +(defn calculate-selection-rects + "Calculate selection highlight rectangles." + [selection font-size origin-x origin-y line-h line-lengths] + (when selection + (let [{:keys [start end]} selection + multi-line? (and (map? start) (contains? start :line)) + char-w (* font-size 0.6) + r 0.2 g 0.4 b 0.9 a 0.5] + (if (not multi-line?) + (let [s (min start end) + e (max start end) + width-chars (- e s)] + (when (> width-chars 0) + [{:x (+ origin-x (* s char-w)) + :y origin-y + :w (* width-chars char-w) + :h line-h + :r r :g g :b b :a a}])) + (let [line-lengths (or line-lengths []) + [s e] (if (pos<= start end) [start end] [end start])] + (keep (fn [line-idx] + (let [line-len (get line-lengths line-idx 0) + col-start (if (= line-idx (:line s)) (:col s) 0) + col-end (if (= line-idx (:line e)) (:col e) line-len) + width-chars (- col-end col-start)] + (when (> width-chars 0) + {:x (+ origin-x (* col-start char-w)) + :y (+ origin-y (* line-idx line-h)) + :w (* width-chars char-w) + :h line-h + :r r :g g :b b :a a}))) + (range (:line s) (inc (:line e))))))))) diff --git a/src/app/electric_flow.cljc b/src/app/electric_flow.cljc index 45df91f..4bbc541 100644 --- a/src/app/electric_flow.cljc +++ b/src/app/electric_flow.cljc @@ -368,25 +368,25 @@ (e/defn main [ring-request] (e/server - (let [file-content source-code] - + (let [file-content source-code] + (e/client (binding [dom/node js/document.body] - (dom/style {:margin "0" :padding "0" - :width "100vw" :height "100vh" - :overflow "hidden" :background "#111" + (dom/style {:margin "0" :padding "0" + :width "100vw" :height "100vh" + :overflow "hidden" :background "#111" :user-select "none"}) - + (init-lezer-parser!) (init-sci!) - + (let [resources (LoadWebGPU)] (when resources (let [device (get resources :device) format (get resources :format) atlas (get resources :atlas) pipelines (editor/create-editor-state resources)] - + (let [lines (str/split-lines file-content) tokenized-lines (mapv tokenize-line lines) ;; Layout constants (must match loop.cljs) @@ -398,16 +398,15 @@ ;; Compute line lengths (character count per line) line-lengths (mapv count lines)] - + (let [geometry (Prepare-Geometry device pipelines render-ops atlas)] (dom/canvas - (dom/props {:id "webgpu-canvas" + (dom/props {:id "webgpu-canvas" :style {:width "100vw" :height "100vh" :display "block"}}) (let [ctx (.getContext dom/node "webgpu" (clj->js {:alpha true}))] (.configure ^js ctx (clj->js {:device device :format format :alphaMode "premultiplied"})) ;; Pass all functions to start-loop! - (let [loop-flow (e/Task (loop/start-loop! dom/node device ctx geometry line-lengths - lines tokenize-line layout-tokens - find-matching-bracket detect-fold-regions - find-form-at-cursor sci-eval-form atlas))] - (e/input loop-flow)))))))))))))) + (e/Task (loop/start-loop! dom/node device ctx geometry line-lengths + lines tokenize-line layout-tokens + find-matching-bracket detect-fold-regions + find-form-at-cursor sci-eval-form atlas))))))))))))))