diff --git a/src/app/client/webgpu/editor.cljs b/src/app/client/webgpu/editor.cljs index 61bc8a6..2427089 100644 --- a/src/app/client/webgpu/editor.cljs +++ b/src/app/client/webgpu/editor.cljs @@ -349,8 +349,8 @@ (.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] - (update-camera device (:camera-uniform-buffer text-sys) camera-floats pan-x pan-y 1.0 w h) +(defn draw-frame! [^js device ^js context text-sys rect-sys camera-floats _ignored_pass_descriptor pan-x pan-y zoom w h] + (update-camera device (:camera-uniform-buffer text-sys) camera-floats pan-x pan-y zoom w h) (let [encoder (.createCommandEncoder device) texture (.getCurrentTexture context) diff --git a/src/app/client/webgpu/loop.cljs b/src/app/client/webgpu/loop.cljs index 90db268..c757a3b 100644 --- a/src/app/client/webgpu/loop.cljs +++ b/src/app/client/webgpu/loop.cljs @@ -23,7 +23,11 @@ (fn [!] (let [handler (fn [e] (.preventDefault e) - (! (.-deltaY e)))] + (let [rect (.getBoundingClientRect node)] + (! {:delta-y (.-deltaY e) + :cursor-x (- (.-clientX e) (.-left rect)) + :cursor-y (- (.-clientY e) (.-top rect)) + :ctrl? (or (.-ctrlKey e) (.-metaKey e))})))] (.addEventListener node "wheel" handler #js {:passive false}) #(.removeEventListener node "wheel" handler))))) @@ -33,20 +37,30 @@ (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)])) + :y (- (.-clientY e) (.-top rect)) + :button (.-button e)})) + + down-h (fn [e] + ;; Prevent context menu on middle click + (when (= 1 (.-button e)) + (.preventDefault e)) + (! [:mousedown (get-coords e)])) up-h (fn [e] (! [:mouseup (get-coords e)])) - move-h (fn [e] (! [:mousemove (get-coords e)]))] + move-h (fn [e] (! [:mousemove (get-coords e)])) + context-h (fn [e] + ;; Prevent context menu (right click) + nil)] (.addEventListener node "mousedown" down-h) (.addEventListener js/window "mouseup" up-h) (.addEventListener js/window "mousemove" move-h) + (.addEventListener node "contextmenu" context-h) (fn [] (.removeEventListener node "mousedown" down-h) (.removeEventListener js/window "mouseup" up-h) - (.removeEventListener js/window "mousemove" move-h)))))) + (.removeEventListener js/window "mousemove" move-h) + (.removeEventListener node "contextmenu" context-h)))))) (defn >keyboard-events [node] (m/observe @@ -91,6 +105,56 @@ (do (.preventDefault e) (! [:save nil])) + ;; Ctrl+K - Toggle command panel + (and ctrl? (= key "k")) + (do (.preventDefault e) + (! [:toggle-command-panel nil])) + + ;; Ctrl+J - Toggle AI response panel + (and ctrl? (= key "j")) + (do (.preventDefault e) + (! [:toggle-ai-panel nil])) + + ;; Ctrl+B - Toggle file tree sidebar + (and ctrl? (= key "b")) + (do (.preventDefault e) + (! [:toggle-file-tree nil])) + + ;; Ctrl+Shift+C - Copy AI response (when AI panel visible) + (and ctrl? (.-shiftKey e) (= key "C")) + (do (.preventDefault e) + (! [:copy-ai-response nil])) + + ;; Ctrl+0 - Reset zoom to 100% + (and ctrl? (= key "0")) + (do (.preventDefault e) + (! [:reset-zoom nil])) + + ;; Ctrl+Plus - Zoom in + (and ctrl? (or (= key "=") (= key "+"))) + (do (.preventDefault e) + (! [:zoom-in nil])) + + ;; Ctrl+Minus - Zoom out + (and ctrl? (= key "-")) + (do (.preventDefault e) + (! [:zoom-out nil])) + + ;; Ctrl+Shift+I - Toggle Live Value Inspector + (and ctrl? (.-shiftKey e) (= key "I")) + (do (.preventDefault e) + (! [:toggle-inspector nil])) + + ;; Ctrl+Shift+S - Toggle Number Scrub Mode + (and ctrl? (.-shiftKey e) (= key "S")) + (do (.preventDefault e) + (! [:toggle-scrub-mode nil])) + + ;; Escape - Close panels / clear focus + (= key "Escape") + (do (.preventDefault e) + (! [:escape nil])) + ;; Ctrl+Arrow word navigation (and ctrl? (contains? #{"ArrowLeft" "ArrowRight"} key)) (do (.preventDefault e) @@ -147,7 +211,16 @@ (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] +;; Atom for external events (AI responses, file loading, etc.) +(defonce !external-events (atom nil)) + +(defn dispatch-external-event! + "Dispatch an event from outside the main event loop. + Used for async operations like AI responses." + [event-type value] + (reset! !external-events [event-type value])) + +(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 & [{:keys [ai-request-fn inspect-values-fn find-literals-fn]}]] (let [;; Layout Configuration (Must match Editor defaults) font-size 16 gutter-w 40 ;; Width of gutter for fold indicators @@ -155,17 +228,69 @@ layout-y 100 line-h (* font-size 1.2) + ;; Command panel configuration + cmd-panel-h 40 ;; Height of command panel when visible + + ;; AI panel configuration + ai-panel-w 400 ;; Width of AI response panel + status-bar-h 24 ;; Height of status bar + file-tree-w 200 ;; Width of file tree sidebar + + ;; External events flow (for AI responses, etc.) + external-events (->> (m/watch !external-events) + (m/eduction (filter some?))) + initial-state {:scroll-y 0 + :scroll-x 0 ;; Horizontal scroll/pan + :zoom 1.0 ;; Zoom factor (1.0 = 100%) :width (.-innerWidth js/window) :height (.-innerHeight js/window) :dpr (or (.-devicePixelRatio js/window) 1) :dragging? false + :panning? false ;; Middle-mouse panning + :pan-start nil ;; {x y} of pan start :sel-start nil :sel-end nil :desired-col 0 :caret-visible true :folded-lines #{} - :eval-result nil} ;; {:text "=> 42" :line 5 :expires-at } + :eval-result nil + ;; Command panel state + :cmd-visible false ;; Is command panel shown? + :cmd-text "" ;; Text in command panel + :cmd-cursor 0 ;; Cursor position in command text + :focus :editor ;; :editor, :command-panel, or :ai-panel + ;; AI panel state + :ai-visible false ;; Is AI response panel shown? + :ai-loading false ;; Is AI request in progress? + :ai-response "" ;; AI response text + :ai-error nil ;; Error message if any + :ai-scroll-y 0 ;; Scroll position in AI panel + ;; Status bar state + :status-visible true ;; Status bar always visible by default + ;; File tree state + :tree-visible false ;; File tree sidebar + :tree-files [{:name "electric_flow.cljc" :path "src/app/electric_flow.cljc" :type :file} + {:name "loop.cljs" :path "src/app/client/webgpu/loop.cljs" :type :file} + {:name "editor.cljs" :path "src/app/client/webgpu/editor.cljs" :type :file}] + :tree-selected nil ;; Currently selected file path + ;; Live Value Inspector (Project 1) + :inspector-visible false ;; Show inline value annotations? + :inspected-values [] ;; [{:line :col :display :value-type}] + ;; Direct Manipulation (Project 2) + :scrub-mode false ;; Enable number scrubbing? + :scrubbing? false ;; Currently scrubbing a number? + :scrub-literal nil ;; The literal being scrubbed + :scrub-start-x nil ;; Mouse X at scrub start + :scrub-start-val nil ;; Value at scrub start + :hover-literal nil ;; Literal under cursor (for highlight) + :all-literals []} ;; Cached literals in document + + ;; Helper to calculate effective editor X offset based on panels + calc-editor-x (fn [tree-visible?] + (if tree-visible? + (+ layout-x file-tree-w) ;; Shift right when tree is open + layout-x)) !state (atom initial-state) !rect-sys (atom (:rect geometry)) @@ -177,6 +302,7 @@ !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 + !editor-render-ops (atom []) ;; Cached editor render ops for combining with command panel ;; Helper to save state before edit save-undo! (fn [lines-val cursor] @@ -188,7 +314,7 @@ (reset! !redo-stack [])) ;; Event Streams - wheel-deltas (->> (>wheel-deltas node) (m/relieve +)) + wheel-deltas (->> (>wheel-deltas node) (m/relieve (fn [_ x] x))) mouse-events (->> (>mouse-events node) (m/relieve (fn [_ x] x))) keyboard-events (->> (>keyboard-events js/window) (m/relieve (fn [_ x] x))) window-metrics (blink-timer) mouse-events - keyboard-events) + keyboard-events + external-events) ;; AI responses, file loads, etc. (m/reduce (fn [_ [type value]] @@ -213,127 +340,312 @@ (set! (.-height node) (Math/floor (* height dpr))) (merge state value)) - :wheel (update state :scroll-y + value) + :wheel (let [{:keys [delta-y cursor-x cursor-y ctrl?]} value] + (if ctrl? + ;; Ctrl+Wheel = Zoom around cursor + (let [current-zoom (:zoom state) + scale (if (< delta-y 0) 1.05 0.95) + new-zoom (-> (* current-zoom scale) + (max 0.1) ;; Min 10% + (min 5.0)) ;; Max 500% + ;; Adjust scroll to zoom around cursor + scroll-x (:scroll-x state) + scroll-y (:scroll-y state) + ;; Calculate new scroll to keep cursor position stable + pan-adjust (- 1 (/ new-zoom current-zoom)) + new-scroll-x (+ scroll-x (* (- cursor-x scroll-x) pan-adjust)) + new-scroll-y (+ scroll-y (* (- cursor-y scroll-y) pan-adjust))] + (assoc state + :zoom new-zoom + :scroll-x new-scroll-x + :scroll-y new-scroll-y)) + ;; Normal wheel = vertical scroll + (update state :scroll-y + delta-y))) :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) + (let [{:keys [x y button]} value + screen-w (:width state) + screen-h (:height state) + cmd-visible? (:cmd-visible state) + ai-visible? (:ai-visible state) + tree-visible? (:tree-visible state) + scrub-mode? (:scrub-mode state) + ;; Calculate panel regions + cmd-panel-top (if cmd-visible? (- screen-h cmd-panel-h) screen-h) + ai-panel-left (- screen-w ai-panel-w) + ;; Determine which region was clicked + clicked-in-cmd-panel? (and cmd-visible? (>= y cmd-panel-top)) + clicked-in-ai-panel? (and ai-visible? (>= x ai-panel-left)) + clicked-in-file-tree? (and tree-visible? (< x file-tree-w)) + ;; Dynamic editor X offset + editor-x (calc-editor-x tree-visible?)] + (cond + ;; Middle button = start panning + (= button 1) + (assoc state :panning? true :pan-start {:x x :y y}) + + ;; Scrub mode + hovering over a number = start scrubbing + (and scrub-mode? (= button 0) (:hover-literal state) + (= :number (:type (:hover-literal state)))) + (let [lit (:hover-literal state)] + (js/console.log "Start scrubbing:" (:text lit) "value:" (:value lit)) + (assoc state + :scrubbing? true + :scrub-literal lit + :scrub-start-x x + :scrub-start-val (:value lit))) + + ;; Clicked in command panel + clicked-in-cmd-panel? + (let [char-w (* font-size 0.6) + text (:cmd-text state) + text-len (count text) + cmd-text-x 60 + col (-> (/ (- x cmd-text-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)))) + (min text-len))] + (assoc state :focus :command-panel :cmd-cursor col :caret-visible true)) + + ;; Clicked in AI panel - focus AI panel + clicked-in-ai-panel? + (assoc state :focus :ai-panel) + + ;; Clicked in file tree - handle file selection + clicked-in-file-tree? + (let [tree-files (:tree-files state) + ;; Calculate which file was clicked (y position) + file-y-start 40 ;; Below "FILES" title + file-idx (Math/floor (/ (- y file-y-start) line-h))] + (if (and (>= file-idx 0) (< file-idx (count tree-files))) + (let [selected-file (nth tree-files file-idx)] + (js/console.log "Selected file:" (:name selected-file)) + (assoc state :tree-selected (:path selected-file))) + state)) + + ;; Clicked in editor area + :else + (let [adj-y (+ y (:scroll-y state)) + ;; Adjust gutter position for file tree + gutter-start (if tree-visible? (+ 50 file-tree-w) 50)] + ;; Check if click is in gutter area (for fold toggle) + (if (and (>= x gutter-start) (< x (+ gutter-start gutter-w))) + ;; Gutter click - toggle fold + (let [visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) + logical-line (get @!line-mapping visual-line visual-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)))) + (assoc state :focus :editor))) + ;; Normal click - cursor placement + (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 editor-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 :focus :editor)))))) :mousemove - (if (:dragging? state) + (cond + ;; Currently scrubbing a number - update value + (:scrubbing? state) + (let [{:keys [x]} value + start-x (:scrub-start-x state) + start-val (:scrub-start-val state) + literal (:scrub-literal state) + ;; Calculate new value: 1 pixel = 0.1 change + delta (/ (- x start-x) 10) + new-val (+ start-val delta) + ;; Round to reasonable precision + rounded-val (/ (Math/round (* new-val 100)) 100) + ;; Update source code + line-idx (:line literal) + col (:col literal) + end-col (:end-col literal) + line-text (get @!lines line-idx "") + new-text-val (str rounded-val) + new-line (str (subs line-text 0 col) + new-text-val + (subs line-text end-col)) + new-lines (assoc @!lines line-idx new-line)] + (reset! !lines new-lines) + (reset! !line-lengths (mapv count new-lines)) + ;; Update the literal's end col based on new text length + (assoc state + :scrub-literal (assoc literal + :value rounded-val + :text new-text-val + :end-col (+ col (count new-text-val))))) + + ;; Panning with middle mouse + (:panning? state) + (let [{:keys [x y]} value + pan-start (:pan-start state) + dx (- x (:x pan-start)) + dy (- y (:y pan-start))] + (-> state + (update :scroll-x - dx) + (update :scroll-y - dy) + (assoc :pan-start {:x x :y y}))) + + ;; Selection drag + (:dragging? state) (let [{:keys [x y]} value adj-y (+ y (:scroll-y state)) + tree-visible? (:tree-visible state) + editor-x (calc-editor-x tree-visible?) ;; 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) + col (-> (/ (- x editor-x) char-w) (Math/round) (max 0) (min line-len)) pos {:line logical-line :col col}] (assoc state :sel-end pos)) - state) + + ;; Scrub mode hover detection + (:scrub-mode state) + (let [{:keys [x y]} value + adj-y (+ y (:scroll-y state)) + tree-visible? (:tree-visible state) + editor-x (calc-editor-x tree-visible?) + char-w (* font-size 0.6) + ;; Find which character position cursor is at + visual-line (max 0 (Math/floor (/ (- adj-y layout-y) line-h))) + logical-line (get @!line-mapping visual-line visual-line) + col (Math/floor (/ (- x editor-x) char-w)) + ;; Find literal at this position + all-lits (:all-literals state) + hovered (first (filter (fn [lit] + (and (= (:line lit) logical-line) + (>= col (:col lit)) + (< col (:end-col lit)))) + all-lits))] + (assoc state :hover-literal hovered)) + + :else state) :mouseup - (assoc state :dragging? false) + (if (:scrubbing? state) + ;; End scrubbing and refresh literals list + (let [new-literals (when find-literals-fn + (find-literals-fn @!lines @!line-lengths))] + (js/console.log "End scrubbing, refreshing literals:" (count new-literals)) + (assoc state + :scrubbing? false + :scrub-literal nil + :scrub-start-x nil + :scrub-start-val nil + :all-literals (or new-literals []) + :dragging? false + :panning? false + :pan-start nil)) + ;; Normal mouse up + (assoc state :dragging? false :panning? false :pan-start nil)) :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) + (if (= (:focus state) :command-panel) + ;; Command panel input + (let [text (:cmd-text state) + cursor (:cmd-cursor state) + before (subs text 0 cursor) + after (subs text cursor) + new-text (str before value after)] (assoc state - :sel-start {:line line-idx :col (inc col)} - :sel-end {:line line-idx :col (inc col)} - :desired-col (inc col) + :cmd-text new-text + :cmd-cursor (inc cursor) :caret-visible true)) - state) + ;; Editor 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) + (if (= (:focus state) :command-panel) + ;; Command panel backspace + (let [text (:cmd-text state) + cursor (:cmd-cursor state)] + (if (> cursor 0) + (let [before (subs text 0 (dec cursor)) + after (subs text cursor)] (assoc state - :sel-start {:line (dec line-idx) :col prev-len} - :sel-end {:line (dec line-idx) :col prev-len} - :desired-col prev-len + :cmd-text (str before after) + :cmd-cursor (dec cursor) :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) + state)) + ;; Editor 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)] @@ -547,98 +859,138 @@ 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) + (if (= (:focus state) :command-panel) + ;; Command panel Enter - submit AI request + (let [cmd-text (:cmd-text state)] + (if (seq cmd-text) + (do + (js/console.log "AI Request submitted:" cmd-text) + ;; Trigger AI request with callback + (when ai-request-fn + (let [code-context (str/join "\n" @!lines)] + (ai-request-fn cmd-text code-context))) + ;; Show AI panel with loading state, clear command + (assoc state + :cmd-text "" + :cmd-cursor 0 + :cmd-visible false + :focus :editor + :ai-visible true + :ai-loading true + :ai-error nil)) + ;; No text - just close panel + (assoc state + :cmd-text "" + :cmd-cursor 0 + :cmd-visible false + :focus :editor))) + ;; Editor Enter - new line + (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] + (if (= (:focus state) :command-panel) + ;; Command panel navigation + (let [text (:cmd-text state) + cursor (:cmd-cursor state) + text-len (count text) + new-cursor (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]) + "ArrowLeft" (max 0 (dec cursor)) + "ArrowRight" (min text-len (inc cursor)) + "Home" 0 + "End" text-len + cursor)] + (assoc state :cmd-cursor new-cursor :caret-visible true)) + ;; Editor navigation + (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] - "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] + ;; 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)) - "End" - [{:line line :col line-len} line-len] + ;; Add padding (one line worth) + padding line-h - [pos desired]) + new-scroll (cond + ;; Caret above viewport - scroll up + (< caret-y (+ viewport-top padding)) + (max 0 (- caret-y padding)) - ;; 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)) + ;; Caret below viewport - scroll down + (> (+ caret-y line-h) (- viewport-bottom padding)) + (+ (- caret-y (:height state)) line-h padding) - ;; Add padding (one line worth) - padding line-h + ;; Caret in view - don't scroll + :else + (:scroll-y state))] - 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) + (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)] @@ -718,43 +1070,495 @@ :expires-at (+ (js/Date.now) 2000)}))) state) + ;; === COMMAND PANEL EVENTS === + + :toggle-command-panel + (if (:cmd-visible state) + ;; Close panel, return focus to editor + (assoc state :cmd-visible false :focus :editor) + ;; Open panel, focus it + (assoc state :cmd-visible true :focus :command-panel :caret-visible true)) + + ;; === AI PANEL EVENTS === + + :toggle-ai-panel + (if (:ai-visible state) + ;; Close AI panel + (assoc state :ai-visible false) + ;; Open AI panel + (assoc state :ai-visible true)) + + :ai-request-start + (assoc state :ai-loading true :ai-error nil) + + :ai-request-success + (assoc state :ai-loading false :ai-response value :ai-visible true) + + :ai-request-error + (assoc state :ai-loading false :ai-error value) + + :copy-ai-response + (let [response (:ai-response state)] + (when (seq response) + ;; Copy to browser clipboard + (-> (js/navigator.clipboard.writeText response) + (.then #(js/console.log "AI response copied to clipboard")) + (.catch #(js/console.error "Failed to copy:" %))) + ;; Also store internally + (reset! !clipboard response) + (js/console.log "AI Response copied:" (count response) "chars")) + state) + + ;; === FILE TREE EVENTS === + + :toggle-file-tree + (update state :tree-visible not) + + :select-file + (assoc state :tree-selected value) + + ;; === ZOOM EVENTS === + :reset-zoom + (assoc state :zoom 1.0 :scroll-x 0 :scroll-y 0) + + :zoom-in + (update state :zoom #(min 5.0 (* % 1.1))) + + :zoom-out + (update state :zoom #(max 0.1 (* % 0.9))) + + ;; === LIVE VALUE INSPECTOR EVENTS === + :toggle-inspector + (let [currently-visible? (:inspector-visible state)] + (if currently-visible? + ;; Turn off inspector + (assoc state :inspector-visible false :inspected-values []) + ;; Turn on inspector - evaluate all values + (let [values (when inspect-values-fn + (inspect-values-fn @!lines @!line-lengths))] + (js/console.log "Inspector values:" (count values)) + (assoc state + :inspector-visible true + :inspected-values (or values []))))) + + :update-inspector-values + (assoc state :inspected-values value) + + ;; === DIRECT MANIPULATION EVENTS (Project 2) === + :toggle-scrub-mode + (let [currently-on? (:scrub-mode state)] + (if currently-on? + ;; Turn off scrub mode + (assoc state :scrub-mode false :all-literals [] :hover-literal nil) + ;; Turn on - find all literals + (let [literals (when find-literals-fn + (find-literals-fn @!lines @!line-lengths))] + (js/console.log "Scrub mode: found" (count literals) "literals") + (assoc state + :scrub-mode true + :all-literals (or literals []))))) + + :start-scrub + (let [{:keys [literal x]} value] + (assoc state + :scrubbing? true + :scrub-literal literal + :scrub-start-x x + :scrub-start-val (:value literal))) + + :scrub-update + (if (:scrubbing? state) + (let [{:keys [x]} value + start-x (:scrub-start-x state) + start-val (:scrub-start-val state) + literal (:scrub-literal state) + ;; Calculate new value: 1 pixel = 0.1 change + delta (/ (- x start-x) 10) + new-val (+ start-val delta) + ;; Round to reasonable precision + rounded-val (/ (Math/round (* new-val 100)) 100) + ;; Update source code + line-idx (:line literal) + col (:col literal) + end-col (:end-col literal) + line-text (get @!lines line-idx "") + new-text-val (str rounded-val) + new-line (str (subs line-text 0 col) + new-text-val + (subs line-text (inc end-col))) + new-lines (assoc @!lines line-idx new-line)] + (reset! !lines new-lines) + (reset! !line-lengths (mapv count new-lines)) + ;; Update the literal's end col based on new text length + (assoc state + :scrub-literal (assoc literal + :value rounded-val + :text new-text-val + :end-col (+ col (dec (count new-text-val)))))) + state) + + :end-scrub + (assoc state + :scrubbing? false + :scrub-literal nil + :scrub-start-x nil + :scrub-start-val nil) + + :hover-literal + (assoc state :hover-literal value) + + ;; === ESCAPE - Close all panels === + + :escape + (cond + ;; If command panel is open, close it first + (:cmd-visible state) + (assoc state :cmd-visible false :focus :editor) + ;; If AI panel is open, close it + (:ai-visible state) + (assoc state :ai-visible false) + ;; Otherwise, clear selection + :else + (assoc state :sel-start nil :sel-end nil)) + state)))) nil)) - ;; 2. TEXT CONTENT UPDATER (re-tokenize and update GPU when lines OR fold state changes) + ;; 2a. EDITOR TEXT UPDATER (re-tokenize when lines, fold state, or tree visibility changes) (->> (m/latest vector (m/watch !lines) - (m/eduction (map :folded-lines) (m/watch !state))) + (m/eduction (map (fn [s] {:folded-lines (:folded-lines s) + :tree-visible (:tree-visible s)})) + (m/watch !state))) (m/eduction (dedupe)) (m/reduce - (fn [_ [new-lines folded-lines]] + (fn [_ [new-lines {:keys [folded-lines tree-visible]}]] (let [new-line-lengths (mapv count new-lines) ;; Detect fold regions from parse tree fold-regions (or (detect-folds-fn new-lines new-line-lengths) []) _ (reset! !fold-regions fold-regions) _ (reset! !line-lengths new-line-lengths) tokenized-lines (mapv tokenize-fn new-lines) + ;; Calculate dynamic layout-x based on tree visibility + effective-layout-x (calc-editor-x tree-visible) ;; Layout with folding - returns {:render-ops :line-mapping} - layout-result (layout-fn tokenized-lines layout-x layout-y font-size + layout-result (layout-fn tokenized-lines effective-layout-x layout-y font-size fold-regions folded-lines) render-ops (:render-ops layout-result)] (reset! !line-mapping (:line-mapping layout-result)) + ;; Cache editor render ops + (reset! !editor-render-ops render-ops)) + nil) + nil)) + + ;; 2b. COMBINED TEXT UPDATER (combines editor + all panel text) + ;; Updates GPU when editor ops change OR when any panel state changes + (->> (m/latest vector + (m/watch !editor-render-ops) + (m/eduction (map (fn [s] {:cmd-visible (:cmd-visible s) + :cmd-text (:cmd-text s) + :scroll-y (:scroll-y s) + :height (:height s) + :width (:width s) + :ai-visible (:ai-visible s) + :ai-loading (:ai-loading s) + :ai-response (:ai-response s) + :status-visible (:status-visible s) + :sel-start (:sel-start s) + :focus (:focus s) + :tree-visible (:tree-visible s) + :tree-files (:tree-files s) + :tree-selected (:tree-selected s) + :zoom (:zoom s) + :inspector-visible (:inspector-visible s) + :inspected-values (:inspected-values s) + :scrub-mode (:scrub-mode s)})) + (m/watch !state))) + (m/eduction (dedupe)) + (m/reduce + (fn [_ [editor-ops {:keys [cmd-visible cmd-text scroll-y height width + ai-visible ai-loading ai-response + status-visible sel-start focus tree-visible + tree-files tree-selected zoom + inspector-visible inspected-values scrub-mode]}]] + (let [;; === COMMAND PANEL TEXT === + cmd-panel-y (+ scroll-y (- height cmd-panel-h)) + cmd-text-y (+ cmd-panel-y 12 font-size) + + cmd-text-token (when cmd-visible + (if (seq cmd-text) + [{:text cmd-text + :type :text + :from 0 + :to (count cmd-text) + :x 60 + :y cmd-text-y + :size font-size + :r 0.9 :g 0.9 :b 0.9 :a 1.0}] + [{: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}])) + + cmd-prompt-token (when cmd-visible + [{:text "> " + :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}]) + + ;; === AI PANEL TEXT === + ai-panel-x (- width ai-panel-w) + ai-title-token (when ai-visible + [{:text "AI Response" + :type :macro + :from 0 + :to 11 + :x (+ ai-panel-x 16) + :y (+ scroll-y 24) + :size font-size + :r 0.6 :g 0.7 :b 1.0 :a 1.0}]) + + ;; Split AI response into lines and render + ai-response-lines (when (and ai-visible (seq ai-response)) + (let [lines (str/split-lines ai-response) + max-chars 45 ;; Approximate chars per line + wrapped-lines (mapcat (fn [line] + (if (<= (count line) max-chars) + [line] + ;; Simple word wrap + (loop [remaining line + result []] + (if (<= (count remaining) max-chars) + (conj result remaining) + (let [break-at (or (str/last-index-of (subs remaining 0 max-chars) " ") + max-chars)] + (recur (subs remaining (inc break-at)) + (conj result (subs remaining 0 break-at)))))))) + lines)] + (map-indexed + (fn [idx line] + [{:text line + :type :text + :from 0 + :to (count line) + :x (+ ai-panel-x 16) + :y (+ scroll-y 50 (* idx line-h)) + :size (- font-size 2) ;; Slightly smaller + :r 0.85 :g 0.85 :b 0.85 :a 1.0}]) + (take 30 wrapped-lines)))) ;; Limit to 30 visible lines + + ai-loading-text (when (and ai-visible ai-loading) + [{:text "Thinking..." + :type :comment + :from 0 + :to 11 + :x (+ ai-panel-x 16) + :y (+ scroll-y 50) + :size font-size + :r 0.5 :g 0.6 :b 0.9 :a 0.8}]) + + ;; === STATUS BAR TEXT === + status-y (+ scroll-y (- height status-bar-h + (if cmd-visible cmd-panel-h 0))) + line-col-text (if sel-start + (str "Ln " (inc (:line sel-start)) ", Col " (inc (:col sel-start))) + "Ln 1, Col 1") + focus-text (case focus + :editor "EDIT" + :command-panel "CMD" + :ai-panel "AI" + "") + + status-line-col-token (when status-visible + [{:text line-col-text + :type :text + :from 0 + :to (count line-col-text) + :x 12 + :y (+ status-y 16) + :size (- font-size 2) + :r 0.7 :g 0.7 :b 0.7 :a 1.0}]) + + status-focus-token (when status-visible + [{:text focus-text + :type :keyword + :from 0 + :to (count focus-text) + :x (- width 60) + :y (+ status-y 16) + :size (- font-size 2) + :r 0.5 :g 0.8 :b 0.5 :a 1.0}]) + + ;; Zoom indicator in status bar + zoom-text (str (Math/round (* (or zoom 1.0) 100)) "%") + status-zoom-token (when status-visible + [{:text zoom-text + :type :number + :from 0 + :to (count zoom-text) + :x (- width 130) + :y (+ status-y 16) + :size (- font-size 2) + :r 0.8 :g 0.7 :b 0.4 :a 1.0}]) + + ;; Inspector indicator in status bar + status-inspector-token (when (and status-visible inspector-visible) + [{:text "INSPECT" + :type :macro + :from 0 + :to 7 + :x (- width 210) + :y (+ status-y 16) + :size (- font-size 2) + :r 0.4 :g 0.9 :b 0.6 :a 1.0}]) + + ;; SCRUB mode indicator in status bar + status-scrub-token (when (and status-visible scrub-mode) + [{:text "SCRUB" + :type :macro + :from 0 + :to 5 + :x (- width 280) + :y (+ status-y 16) + :size (- font-size 2) + :r 0.9 :g 0.7 :b 0.3 :a 1.0}]) + + ;; === FILE TREE TEXT === + tree-title-token (when tree-visible + [{:text "FILES" + :type :macro + :from 0 + :to 5 + :x 12 + :y (+ scroll-y 24) + :size (- font-size 2) + :r 0.6 :g 0.7 :b 1.0 :a 1.0}]) + + ;; File tree items + tree-file-tokens (when (and tree-visible (seq tree-files)) + (map-indexed + (fn [idx file] + (let [is-selected? (= (:path file) tree-selected) + file-y (+ scroll-y 44 (* idx line-h))] + [{:text (:name file) + :type (if is-selected? :keyword :text) + :from 0 + :to (count (:name file)) + :x 16 + :y file-y + :size (- font-size 2) + :r (if is-selected? 0.4 0.8) + :g (if is-selected? 0.8 0.8) + :b (if is-selected? 0.9 0.8) + :a 1.0}])) + tree-files)) + + ;; === LIVE VALUE INSPECTOR ANNOTATIONS === + tree-visible-for-layout tree-visible + inspector-layout-x (if tree-visible-for-layout + (+ layout-x file-tree-w) + layout-x) + inspector-tokens (when (and inspector-visible (seq inspected-values)) + (->> inspected-values + (map (fn [{:keys [line end-col display value-type]}] + (let [;; Position annotation after the end of the expression + char-w (* font-size 0.6) + anno-x (+ inspector-layout-x (* (+ end-col 3) char-w)) + anno-y (+ layout-y (* line line-h)) + ;; Color based on value type + [r g b] (case value-type + :number [0.6 0.9 0.6] + :string [0.9 0.7 0.5] + :keyword [0.5 0.8 0.9] + :symbol [0.8 0.8 0.6] + :boolean [0.8 0.6 0.9] + :nil [0.5 0.5 0.5] + :vector [0.6 0.8 0.8] + :map [0.8 0.8 0.6] + :function [0.7 0.6 0.9] + :error [0.9 0.4 0.4] + [0.7 0.7 0.7]) + ;; Truncate long values + display-text (if (> (count display) 40) + (str (subs display 0 37) "...") + display)] + [{:text (str "→ " display-text) + :type :comment + :from 0 + :to (+ 2 (count display-text)) + :x anno-x + :y anno-y + :size (- font-size 2) + :r r :g g :b b :a 0.85}]))) + (apply concat) + vec)) + + ;; Combine all text ops + render-ops (vec (concat editor-ops + (when cmd-prompt-token [cmd-prompt-token]) + (when cmd-text-token [cmd-text-token]) + (when ai-title-token [ai-title-token]) + (when ai-loading-text [ai-loading-text]) + (or ai-response-lines []) + (when status-line-col-token [status-line-col-token]) + (when status-inspector-token [status-inspector-token]) + (when status-scrub-token [status-scrub-token]) + (when status-zoom-token [status-zoom-token]) + (when status-focus-token [status-focus-token]) + (when tree-title-token [tree-title-token]) + (or tree-file-tokens []) + (or inspector-tokens [])))] (reset! !text-geo (editor/update-text-data device (:text geometry) render-ops atlas font-size))) nil) nil)) - ;; 3. SELECTION & CARET GEOMETRY UPDATER (with bracket matching + fold indicators + eval results) + ;; 3. SELECTION & CARET GEOMETRY UPDATER (with bracket matching + fold indicators + eval results + all panels) (->> (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)})) + :eval-result (:eval-result s) + :cmd-visible (:cmd-visible s) + :cmd-text (:cmd-text s) + :cmd-cursor (:cmd-cursor s) + :focus (:focus s) + :width (:width s) + :height (:height s) + ;; AI panel + :ai-visible (:ai-visible s) + :ai-loading (:ai-loading s) + :ai-response (:ai-response s) + ;; Status bar + :status-visible (:status-visible s) + ;; File tree + :tree-visible (:tree-visible s) + ;; Direct manipulation (scrub mode) + :scrub-mode (:scrub-mode s) + :hover-literal (:hover-literal s) + :scrubbing? (:scrubbing? s) + :scrub-literal (:scrub-literal s)})) (dedupe)) (m/reduce - (fn [_ {:keys [sel-start sel-end caret-visible folded-lines eval-result]}] + (fn [_ {:keys [sel-start sel-end caret-visible folded-lines eval-result + cmd-visible cmd-text cmd-cursor focus width height + ai-visible ai-loading ai-response + status-visible tree-visible + scrub-mode hover-literal scrubbing? scrub-literal]}] (let [line-mapping @!line-mapping + ;; Dynamic layout-x based on tree visibility + effective-layout-x (calc-editor-x tree-visible) + ;; Gutter position shifts with tree + gutter-base-x (if tree-visible (+ 50 file-tree-w) 50) ;; Create logical->visual line mapping (inverse) logical->visual (reduce-kv (fn [m visual-idx logical-idx] (assoc m logical-idx visual-idx)) @@ -774,7 +1578,7 @@ (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)) + x (+ gutter-base-x (/ (- 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 @@ -791,21 +1595,22 @@ 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)) + {:x (+ effective-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 caret rect at visual position (only for editor focus) + editor-caret-rect (when (and sel-start caret-visible (not has-selection?) + (= focus :editor)) + (when-let [visual-y (logical-line->visual-y (:line sel-start))] + (let [char-w (* font-size 0.6)] + {:x (+ effective-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)) @@ -821,7 +1626,7 @@ 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)) + {:x (+ effective-layout-x (* col-start char-w)) :y visual-y :w (* width-chars char-w) :h line-h @@ -836,7 +1641,7 @@ ;; 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-x (+ effective-layout-x (* (+ line-len 2) char-w)) result-w (* (count (:text eval-result)) char-w)] {:x result-x :y visual-y @@ -847,12 +1652,122 @@ :g (if (str/starts-with? (:text eval-result) "=>") 0.3 0.1) :b 0.1 :a 0.8}))))) + + ;; === COMMAND PANEL RECTS === + ;; Command panel is rendered at fixed screen position (not scrolled) + ;; We need to account for scroll-y to position it correctly + scroll-y (:scroll-y @!state) + cmd-panel-y (+ scroll-y (- height cmd-panel-h)) ;; Fixed to bottom of viewport + + ;; Command panel background + cmd-bg-rect (when cmd-visible + {:x 0 + :y cmd-panel-y + :w width + :h cmd-panel-h + :r 0.15 :g 0.15 :b 0.2 :a 1.0}) + + ;; Command panel caret (only when focused) + cmd-caret-rect (when (and cmd-visible caret-visible (= focus :command-panel)) + (let [char-w (* font-size 0.6) + cmd-text-x 60 + caret-x (+ cmd-text-x (* cmd-cursor char-w)) + caret-y (+ cmd-panel-y 8)] ;; Vertically center in panel + {:x caret-x + :y caret-y + :w 2 + :h (- cmd-panel-h 16) + :r 0.9 :g 0.9 :b 0.9 :a 1.0})) + + ;; === AI PANEL RECTS === + ;; AI panel on right side of screen + ai-panel-x (- width ai-panel-w) + ai-panel-top-y scroll-y ;; Start from top of viewport + + ;; AI panel background + ai-bg-rect (when ai-visible + {:x (+ scroll-y ai-panel-x) ;; Adjust for any horizontal scroll + :y scroll-y + :w ai-panel-w + :h height + :r 0.1 :g 0.1 :b 0.15 :a 0.95}) + + ;; AI loading indicator (pulsing rectangle) + ai-loading-rect (when (and ai-visible ai-loading) + {:x (+ ai-panel-x 20) + :y (+ scroll-y 20) + :w (- ai-panel-w 40) + :h 4 + :r 0.3 :g 0.5 :b 1.0 :a 0.8}) + + ;; === STATUS BAR RECTS === + ;; Status bar at very bottom (below command panel if visible) + status-y (+ scroll-y (- height status-bar-h + (if cmd-visible cmd-panel-h 0))) + + status-bg-rect (when status-visible + {:x 0 + :y status-y + :w width + :h status-bar-h + :r 0.12 :g 0.12 :b 0.18 :a 1.0}) + + ;; === FILE TREE RECTS === + ;; File tree on left side + tree-bg-rect (when tree-visible + {:x 0 + :y scroll-y + :w file-tree-w + :h height + :r 0.08 :g 0.08 :b 0.12 :a 0.98}) + + ;; === SCRUB MODE RECTS (Project 2) === + ;; Hover highlight for literals when scrub mode is active + hover-lit-rect (when (and scrub-mode hover-literal (not scrubbing?)) + (when-let [visual-y (logical-line->visual-y (:line hover-literal))] + (let [char-w (* font-size 0.6) + lit-x (+ effective-layout-x (* (:col hover-literal) char-w)) + lit-w (* (- (:end-col hover-literal) (:col hover-literal)) char-w) + ;; Different colors for different literal types + [r g b] (case (:type hover-literal) + :number [0.3 0.7 0.3] ;; Green for numbers + :color [0.7 0.3 0.6] ;; Purple for colors + :vector-2d [0.3 0.6 0.7] ;; Cyan for 2D vectors + [0.5 0.5 0.5])] ;; Gray default + {:x lit-x + :y visual-y + :w lit-w + :h line-h + :r r :g g :b b :a 0.4}))) + + ;; Active scrubbing highlight (brighter) + scrub-active-rect (when (and scrubbing? scrub-literal) + (when-let [visual-y (logical-line->visual-y (:line scrub-literal))] + (let [char-w (* font-size 0.6) + lit-x (+ effective-layout-x (* (:col scrub-literal) char-w)) + lit-w (* (- (:end-col scrub-literal) (:col scrub-literal)) char-w)] + {:x lit-x + :y visual-y + :w lit-w + :h line-h + :r 0.4 :g 0.9 :b 0.4 :a 0.6}))) + ;; Combine all rects rects (vec (concat fold-rects (or bracket-rects []) (or selection-rects []) - (if caret-rect [caret-rect] []) - (if eval-rect [eval-rect] [])))] + (if editor-caret-rect [editor-caret-rect] []) + (if eval-rect [eval-rect] []) + ;; Scrub mode highlights + (if hover-lit-rect [hover-lit-rect] []) + (if scrub-active-rect [scrub-active-rect] []) + ;; Panel backgrounds (drawn first, behind content) + (if tree-bg-rect [tree-bg-rect] []) + (if ai-bg-rect [ai-bg-rect] []) + (if ai-loading-rect [ai-loading-rect] []) + (if status-bg-rect [status-bg-rect] []) + (if cmd-bg-rect [cmd-bg-rect] []) + (if cmd-caret-rect [cmd-caret-rect] [])))] (reset! !rect-sys (editor/update-rects device (:rect geometry) rects))) nil) nil)) @@ -860,12 +1775,16 @@ ;; 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)) + (let [scroll-x (:scroll-x state 0) + scroll-y (:scroll-y state 0) + zoom (:zoom state 1.0)] + (editor/draw-frame! device ctx + @!text-geo + @!rect-sys + (:camera-floats (:pipelines geometry)) + (:pass-descriptor (:pipelines geometry)) + (- scroll-x) (- scroll-y) zoom + (:width state) (:height state))) nil) nil (m/sample (fn [s _t] s) (m/watch !state) >raf))))) diff --git a/src/app/electric_flow.cljc b/src/app/electric_flow.cljc index 45df91f..535fe79 100644 --- a/src/app/electric_flow.cljc +++ b/src/app/electric_flow.cljc @@ -2,6 +2,8 @@ (:require [clojure.string :as str] [hyperfiddle.electric3 :as e] [hyperfiddle.electric-dom3 :as dom] + #?@(:clj [[clj-http.client :as http] + [clojure.data.json :as json]]) #?@(:cljs [[app.client.webgpu.editor :as editor] [app.client.webgpu.loop :as loop] [global-flow :refer [await-promise]] @@ -9,9 +11,66 @@ ["@nextjournal/lezer-clojure" :as clj-parser] [sci.core :as sci]]))) -(def source-code - #?(:clj (slurp "src/app/electric_flow.cljc") - :cljs nil)) +(def source-code + #?(:clj (slurp "src/app/electric_flow.cljc") + :cljs nil)) + +;; === AI Integration (Server-side) === + +#?(:clj + (defn call-claude-api + "Call Claude API with a prompt and optional code context. + Returns {:result text} or {:error message}. + Requires ANTHROPIC_API_KEY environment variable." + [user-prompt code-context] + (let [api-key (System/getenv "ANTHROPIC_API_KEY")] + (if (str/blank? api-key) + ;; Return mock response when no API key is configured + {:result (str "Mock AI Response\n\n" + "You asked: " user-prompt "\n\n" + "Code context: " (count code-context) " characters\n\n" + "To enable real AI responses, set ANTHROPIC_API_KEY environment variable.\n" + "Example: export ANTHROPIC_API_KEY=sk-ant-...")} + ;; Make real API call + (try + (let [messages [{:role "user" + :content (str "Context (code I'm working on):\n```\n" + (subs code-context 0 (min 4000 (count code-context))) + "\n```\n\nTask: " user-prompt)}] + response (http/post "https://api.anthropic.com/v1/messages" + {:headers {"x-api-key" api-key + "anthropic-version" "2023-06-01" + "content-type" "application/json"} + :body (json/write-str + {:model "claude-sonnet-4-20250514" + :max_tokens 1024 + :messages messages}) + :as :json})] + (if (= 200 (:status response)) + (let [content (-> response :body :content first :text)] + {:result content}) + {:error (str "API error: " (:status response))})) + (catch Exception e + {:error (str "Request failed: " (.getMessage e))})))))) + +#?(:clj + (defn call-claude-api-mock + "Mock version for testing without API key" + [user-prompt code-context] + {:result (str "AI Response (Mock Mode)\n" + "─────────────────────────\n\n" + "Task: " user-prompt "\n\n" + "Analysis:\n" + "Based on the " (count (str/split-lines code-context)) " lines of code provided, " + "here are some suggestions:\n\n" + "1. The code appears to be a Clojure/ClojureScript application\n" + "2. Consider adding error handling\n" + "3. Documentation could be improved\n\n" + "Note: Set ANTHROPIC_API_KEY for real AI responses.")})) + +;; Client-side placeholder for AI functions +#?(:cljs (defn call-claude-api [_ _] {:error "AI calls must go through server"})) +#?(:cljs (defn call-claude-api-mock [_ _] {:error "AI calls must go through server"})) #?(:clj (defn init-lezer-parser! [] nil)) #?(:clj (defn find-matching-bracket [_ _ _] nil)) @@ -20,6 +79,10 @@ #?(:clj (defn sci-eval [_] {:error "SCI only available in browser"})) #?(:clj (defn sci-eval-form [_] "SCI only available in browser")) #?(:clj (defn find-form-at-cursor [_ _ _] nil)) +#?(:clj (defn inspect-all-values [_ _] [])) +#?(:clj (defn find-all-top-level-forms [_ _] [])) +#?(:clj (defn find-all-literals [_ _] [])) +#?(:clj (defn find-literal-at-position [_ _] nil)) #?(:cljs (do @@ -177,6 +240,185 @@ :from from :to to}))))) + ;; === LIVE VALUE INSPECTOR (Project 1) === + + (defn find-all-top-level-forms + "Parse code and find all top-level forms with their positions. + Returns [{:form-str :line :col :end-line :end-col :type}]" + [lines line-lengths] + (when (and @lezer-parser (seq lines)) + (let [full-text (str/join "\n" lines) + tree (.parse ^js @lezer-parser full-text) + forms (atom []) + root-depth (atom 0)] + ;; Find top-level forms (children of Program node) + (.. ^js tree + (iterate #js {:enter (fn [node] + (let [node-name (.-name ^js (.-type ^js node)) + from (.-from ^js node) + to (.-to ^js node) + ;; Track depth - we want immediate children of Program + depth @root-depth] + (when (= node-name "Program") + (reset! root-depth 0)) + (when (and (= depth 0) + (contains? #{"List" "Vector" "Map" "Set" "Number" "String" "Symbol" "Keyword"} node-name) + (not= node-name "Program")) + (let [form-str (.substring full-text from to) + start-pos (offset->line-col from line-lengths) + end-pos (offset->line-col (max 0 (dec to)) line-lengths)] + (swap! forms conj {:form-str form-str + :line (:line start-pos) + :col (:col start-pos) + :end-line (:line end-pos) + :end-col (:col end-pos) + :type node-name + :from from + :to to}))) + (swap! root-depth inc))) + :leave (fn [_] (swap! root-depth dec))})) + @forms))) + + (defn evaluate-form-safely + "Evaluate a form string and return result with value type info." + [form-str] + (try + (when-not @sci-ctx (init-sci!)) + (let [result (sci/eval-string* @sci-ctx form-str) + value-type (cond + (nil? result) :nil + (number? result) :number + (string? result) :string + (keyword? result) :keyword + (symbol? result) :symbol + (boolean? result) :boolean + (vector? result) :vector + (map? result) :map + (set? result) :set + (list? result) :list + (fn? result) :function + :else :other)] + {:result result + :value-type value-type + :display (if (fn? result) + "#" + (pr-str result))}) + (catch :default e + {:error (.-message e) + :value-type :error + :display (str "❌ " (.-message e))}))) + + (defn inspect-all-values + "Evaluate all top-level forms and return their values with positions. + Returns [{:line :col :end-line :end-col :display :value-type :result}]" + [lines line-lengths] + (let [forms (find-all-top-level-forms lines line-lengths)] + (->> forms + (map (fn [{:keys [form-str line col end-line end-col type]}] + (let [eval-result (evaluate-form-safely form-str)] + (merge {:line line + :col col + :end-line end-line + :end-col end-col + :form-type type} + eval-result)))) + (vec)))) + + ;; === DIRECT MANIPULATION LITERALS (Project 2) === + + (defn find-all-literals + "Find all manipulable literals in the code with their positions. + Returns [{:type :number/:string/:keyword/:vector + :value (parsed value) + :text (original text) + :line :col :end-line :end-col :from :to}]" + [lines line-lengths] + (when (and @lezer-parser (seq lines)) + (let [full-text (str/join "\n" lines) + tree (.parse ^js @lezer-parser full-text) + literals (atom [])] + (.. ^js tree + (iterate #js {:enter (fn [node] + (let [node-name (.-name ^js (.-type ^js node)) + from (.-from ^js node) + to (.-to ^js node) + text (.substring full-text from to)] + ;; Detect number literals + (when (= node-name "Number") + (let [start-pos (offset->line-col from line-lengths) + ;; end-col is exclusive (one past the last char) for range checking + end-pos (offset->line-col to line-lengths)] + (swap! literals conj + {:type :number + :value (js/parseFloat text) + :text text + :line (:line start-pos) + :col (:col start-pos) + :end-line (:line end-pos) + :end-col (:col end-pos) + :from from + :to to}))) + ;; Detect string literals (check for color patterns) + (when (= node-name "String") + (let [start-pos (offset->line-col from line-lengths) + ;; end-col is exclusive for range checking + end-pos (offset->line-col to line-lengths) + ;; Check if string looks like hex color + inner-text (subs text 1 (dec (count text))) + is-color? (re-matches #"#[0-9A-Fa-f]{3,8}" inner-text)] + (swap! literals conj + {:type (if is-color? :color :string) + :value inner-text + :text text + :line (:line start-pos) + :col (:col start-pos) + :end-line (:line end-pos) + :end-col (:col end-pos) + :from from + :to to}))) + ;; Detect 2D vectors [x y] as potential coordinates + (when (= node-name "Vector") + (let [start-pos (offset->line-col from line-lengths) + ;; end-col is exclusive for range checking + end-pos (offset->line-col to line-lengths) + ;; Try to parse as [num num] + parsed (try + (let [result (cljs.reader/read-string text)] + (when (and (vector? result) + (= 2 (count result)) + (every? number? result)) + result)) + (catch :default _ nil))] + (when parsed + (swap! literals conj + {:type :vector-2d + :value parsed + :text text + :line (:line start-pos) + :col (:col start-pos) + :end-line (:line end-pos) + :end-col (:col end-pos) + :from from + :to to}))))))})) + @literals))) + + (defn find-literal-at-position + "Find the literal at or containing the given position. + Returns the literal or nil." + [pos literals] + (first (filter (fn [{:keys [line col end-line end-col]}] + (let [pos-line (:line pos) + pos-col (:col pos)] + (and (>= pos-line line) + (<= pos-line end-line) + (if (= pos-line line) + (>= pos-col col) + true) + (if (= pos-line end-line) + (<= pos-col end-col) + true)))) + literals))) + (defn find-matching-bracket "Given cursor position and document, find matching bracket if cursor is on one. Returns {:open {:line :col} :close {:line :col}} or nil" @@ -366,27 +608,56 @@ :rect (editor/update-rects device (:rect-sys pipelines) []) :pipelines pipelines})) +;; Atom to hold pending AI request (client-side) +#?(:cljs (defonce !ai-request (atom nil))) + +(e/defn ProcessAIRequest [request] + "Electric function to process AI request on server and return response." + (e/server + (when request + (let [{:keys [prompt context]} request + response (call-claude-api prompt context)] + response)))) + (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)] - + pipelines (editor/create-editor-state resources) + + ;; AI request callback - stores request for Electric to process + ai-request-fn (fn [prompt context] + (reset! !ai-request {:prompt prompt + :context context + :timestamp (js/Date.now)}))] + + ;; Watch for AI requests and process them + (let [ai-req (e/watch !ai-request)] + (when ai-req + (let [response (ProcessAIRequest ai-req)] + ;; Dispatch response back to loop + (when response + (if (:result response) + (loop/dispatch-external-event! :ai-request-success (:result response)) + (loop/dispatch-external-event! :ai-request-error (:error response))) + ;; Clear the request + (reset! !ai-request nil))))) + (let [lines (str/split-lines file-content) tokenized-lines (mapv tokenize-line lines) ;; Layout constants (must match loop.cljs) @@ -398,16 +669,19 @@ ;; 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! + ;; Pass all functions to start-loop! with AI callback, inspector, and literals (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)))))))))))))) + find-form-at-cursor sci-eval-form atlas + {:ai-request-fn ai-request-fn + :inspect-values-fn inspect-all-values + :find-literals-fn find-all-literals}))] + (e/input loop-flow)))))))))))))))