diff --git a/extensions/vi-mode/commands.lisp b/extensions/vi-mode/commands.lisp index f8d8e2005..dcf4dafd8 100644 --- a/extensions/vi-mode/commands.lisp +++ b/extensions/vi-mode/commands.lisp @@ -198,16 +198,19 @@ (define-command vi-forward-char (&optional (n 1)) (:universal) (let* ((p (current-point)) - (max-offset (- (length (line-string p)) - (point-charpos p)))) + (max-offset (with-point ((e p)) + (visual-line-end e) + (count-characters p e)))) (character-offset p (min n max-offset)))) (define-command vi-backward-char (&optional (n 1)) (:universal) (let ((p (current-point))) - (dotimes (_ n) - (if (bolp p) - (return) - (character-offset p -1))))) + (with-point ((bol p)) + (visual-line-beginning bol) + (dotimes (_ n) + (if (point<= p bol) + (return) + (character-offset p -1)))))) (define-motion vi-next-line (&optional (n 1)) (:universal) (:type :line) @@ -309,7 +312,7 @@ (define-command vi-move-to-beginning-of-line () () (with-point ((start (current-point))) - (line-start start) + (visual-line-beginning start) (or (text-property-at (current-point) :field -1) (previous-single-property-change (current-point) :field @@ -318,7 +321,7 @@ (define-command vi-move-to-end-of-line (&optional (n 1)) (:universal) (vi-line n) - (line-end (current-point))) + (visual-line-end (current-point))) (define-command vi-move-to-last-nonblank () () (vi-move-to-end-of-line) diff --git a/extensions/vi-mode/commands/utils.lisp b/extensions/vi-mode/commands/utils.lisp index fa883655e..94f31aa4f 100644 --- a/extensions/vi-mode/commands/utils.lisp +++ b/extensions/vi-mode/commands/utils.lisp @@ -61,7 +61,10 @@ (1- len))))) (defun fall-within-line (point) - (when (eolp point) + "Clamps the given point to the character before the newline at the end of the line it is in." + ;; a buffer-line end whose newline is hidden by a fold is mid-visual-line, so we dont clamp. + (when (and (eolp point) + (not (invisible-overlay-covering point))) (line-end point) (unless (bolp point) (character-offset point *cursor-offset*)))) diff --git a/lem-tests.asd b/lem-tests.asd index c249c2bea..1c4857789 100644 --- a/lem-tests.asd +++ b/lem-tests.asd @@ -79,6 +79,7 @@ (:file "filer") (:file "listener-mode") (:file "interface") - (:file "display-cache")) + (:file "display-cache") + (:file "visual-line")) :perform (test-op (o c) (symbol-call :rove :run c))) diff --git a/lem.asd b/lem.asd index 1ddff52c0..3bcd3daf2 100644 --- a/lem.asd +++ b/lem.asd @@ -149,6 +149,13 @@ (:file "color-theme") + (:module "display" + :serial t + :components ((:file "base") + (:file "char-type") + (:file "logical-line") + (:file "physical-line"))) + (:module "commands" :serial t :components ((:file "move") @@ -168,13 +175,6 @@ (:file "frame") #+sbcl (:file "sprof"))) - (:module "display" - :serial t - :components ((:file "base") - (:file "char-type") - (:file "logical-line") - (:file "physical-line"))) - (:file "external-packages") (:module "ext" diff --git a/src/attribute.lisp b/src/attribute.lisp index fd2197bf0..66241f38c 100644 --- a/src/attribute.lisp +++ b/src/attribute.lisp @@ -192,6 +192,9 @@ (:light :foreground nil :background "#eedc82") (:dark :foreground nil :background "blue")) +(define-attribute fold-attribute + (t :foreground :base04)) + (define-attribute modeline (t :bold t :background "#404040" :foreground "white")) diff --git a/src/commands/move.lisp b/src/commands/move.lisp index 35011dadd..273a7158e 100644 --- a/src/commands/move.lisp +++ b/src/commands/move.lisp @@ -12,6 +12,8 @@ :move-to-beginning-of-logical-line :move-to-end-of-line :move-to-end-of-logical-line + :visual-line-beginning + :visual-line-end :next-page :previous-page :next-page-char @@ -45,6 +47,134 @@ (define-key *global-keymap* "C-x [" 'previous-page-char) (define-key *global-keymap* "M-g" 'goto-line) +(defvar *cursor-positions-before-command* + (make-hash-table :test 'eq) + "Maps each cursor point to its absolute buffer position before the running command, so +`run-overlay-cursor-motion-hooks' can tell which direction it moved.") + +(defvar *cursor-overlays-before-command* + (make-hash-table :test 'eq) + "Maps each cursor point to the cursor-hook overlays it occupied before the running command, +so `run-overlay-cursor-motion-hooks' can tell which overlays it entered and which it left.") + +(add-hook *pre-command-hook* 'snapshot-cursor-state) +(add-hook *post-command-hook* 'run-overlay-cursor-motion-hooks) + +(defun snapshot-cursor-state () + "Snapshot every cursor's position and the cursor-hook overlays it occupies, so that after the +command `run-overlay-cursor-motion-hooks' can tell which overlays each cursor entered and left, +and in which direction." + (clrhash *cursor-positions-before-command*) + (clrhash *cursor-overlays-before-command*) + (dolist (point (buffer-cursors (current-buffer))) + (setf (gethash point *cursor-positions-before-command*) + (position-at-point point)) + (setf (gethash point *cursor-overlays-before-command*) + (overlays-with-cursor-hooks-covering point)))) + +(defun run-overlay-cursor-enter-functions (point direction) + "Run the :cursor-enter-functions of every cursor-hook overlay POINT has entered since the +command, each called as (FUNCTION point overlay direction). repeats while a handler +repositions POINT so that overlays it is then pushed into also fire." + (let ((seen (copy-list (gethash point *cursor-overlays-before-command*))) + (visited (list (position-at-point point)))) + (loop + (let ((entered (set-difference (overlays-with-cursor-hooks-covering point) seen))) + (when (null entered) + (return)) + (let ((before-pass (position-at-point point))) + (dolist (overlay entered) + (push overlay seen) + (dolist (function (overlay-get overlay :cursor-enter-functions)) + (funcall function point overlay direction))) + (let ((after-pass (position-at-point point))) + ;; stop once a pass leaves POINT put, or revisits a position. the latter guards + ;; against two overlays snapping a cursor back and forth forever. + ;; this is tricky. this is definitely not the best way to do things but it works for now. + ;; the nature of overlay hooks itself is tricky anyway. + (when (or (= before-pass after-pass) + (member after-pass visited)) + (return)) + (push after-pass visited))))))) + +(defun run-overlay-cursor-leave-functions (point direction) + "Run the :cursor-leave-functions of every cursor-hook overlay POINT was in before the +command, each called as (FUNCTION point overlay direction)." + (let ((left (set-difference (gethash point *cursor-overlays-before-command*) + (overlays-with-cursor-hooks-covering point)))) + (dolist (overlay left) + (dolist (function (overlay-get overlay :cursor-leave-functions)) + (funcall function point overlay direction))))) + +(defun cursor-move-direction-overlay (point) + "The direction POINT moved during the command, :forward or :backward (:forward when its prior +position is unknown or unchanged)." + (let ((before (gethash point *cursor-positions-before-command*))) + (if (and before (< (position-at-point point) before)) + :backward + :forward))) + +(defun run-overlay-cursor-motion-hooks () + (dolist (point (buffer-cursors (current-buffer))) + (let ((direction (cursor-move-direction-overlay point))) + (run-overlay-cursor-enter-functions point direction) + (run-overlay-cursor-leave-functions point direction)))) + +(defun visual-line-end (point) + "Move POINT to the end of its visual line. returns POINT." + (line-end point) + (loop :until (last-line-p point) + :while (invisible-overlay-covering point) + :do (line-offset point 1) + (line-end point)) + point) + +(defun visual-line-beginning (point) + "Move POINT to the start of its visual line. returns POINT." + (line-start point) + (loop :while (line-continuation-p point) + :do (line-offset point -1) + (line-start point)) + point) + +(defun line-continuation-to-skip (point) + (let ((overlay (line-continuation-p point))) + (and overlay + (not (overlay-get overlay :cursor-leave-functions)) + overlay))) + +(defun skip-invisible-lines (point direction) + "Move POINT past any fully invisible lines in DIRECTION (1 or -1). +returns POINT on success, or NIL if a buffer boundary is reached." + (loop :while (line-continuation-to-skip point) + :do (unless (line-offset point direction) + (return-from skip-invisible-lines nil))) + point) + +(defun move-to-next-visible-virtual-line (point n) + "Like `move-to-next-virtual-line' but skips fully invisible lines." + (let ((dir (if (plusp n) 1 -1)) + (steps (abs n))) + (loop :repeat steps + :do (unless (move-to-next-virtual-line point dir) + (return-from move-to-next-visible-virtual-line nil)) + (when (line-continuation-to-skip point) + (unless (skip-invisible-lines point dir) + (return-from move-to-next-visible-virtual-line nil)))) + point)) + +(defun visible-line-offset (point n) + "Like `line-offset' but skips fully invisible lines." + (let ((dir (if (plusp n) 1 -1)) + (steps (abs n))) + (loop :repeat steps + :do (unless (line-offset point dir) + (return-from visible-line-offset nil)) + (when (line-continuation-to-skip point) + (unless (skip-invisible-lines point dir) + (return-from visible-line-offset nil)))) + point)) + (defun next-line-aux (n point-column-fn forward-line-fn @@ -67,14 +197,14 @@ "Move the cursor to next line." (next-line-aux n #'point-virtual-line-column - #'move-to-next-virtual-line + #'move-to-next-visible-virtual-line #'move-to-virtual-line-column)) (define-command (next-logical-line (:advice-classes movable-advice)) (&optional n) (:universal) "Move the cursor to the next logical line." (next-line-aux n #'point-column - #'line-offset + #'visible-line-offset #'move-to-column)) (define-command (previous-line (:advice-classes movable-advice)) (&optional (n 1)) (:universal) diff --git a/src/display/logical-line.lisp b/src/display/logical-line.lisp index 7545c6f50..b2a043e0c 100644 --- a/src/display/logical-line.lisp +++ b/src/display/logical-line.lisp @@ -2,9 +2,17 @@ (defvar *active-modes*) +(defstruct virtual-item + "a display-only string fragment injected at a character position within a logical line." + ;; 0-based position in the line's string where this fragment is inserted + charpos + string + attribute) + (defstruct logical-line string attributes + virtual-items left-content end-of-line-cursor-attribute extend-to-end @@ -77,75 +85,340 @@ over-attribute)))) (lem/buffer/line:normalization-elements merged-attributes))) +(defun splice-string (string attributes ov-start ov-end replacement replacement-attr) + "replace [OV-START, OV-END) in STRING with REPLACEMENT, adjusting ATTRIBUTES." + (let* ((rep-len (length replacement)) + (delta (- rep-len (- ov-end ov-start))) + (new-string (str:concat (subseq string 0 ov-start) + replacement + (subseq string ov-end))) + (pruned (lem/buffer/line:remove-elements attributes ov-start ov-end)) + (shifted (if (zerop delta) + pruned + (loop :for (start end attr) :in pruned + :collect (if (>= start ov-end) + (list (+ start delta) (+ end delta) attr) + (list start end attr))))) + (final (if (and replacement-attr (plusp rep-len)) + (lem/buffer/line:put-elements shifted ov-start (+ ov-start rep-len) replacement-attr) + shifted))) + (values new-string final))) + +(defun adjust-charpos-for-splices (charpos splice-ops) + "Map a raw-string CHARPOS to its position after SPLICE-OPS are applied. +SPLICE-OPS is a list of (START END REPLACEMENT . _) covering disjoint ranges, as collected +in `create-logical-line'. used to keep virtual-item markers anchored when several folds on one +visual line each splice text out." + (+ charpos + (loop :for (start end replacement) :in splice-ops + :sum (cond ((<= charpos start) + 0) + ((>= charpos end) + (- (length replacement) (- end start))) + (t + (- start charpos)))))) + +(defun line-fully-invisible-p (point overlays) + "T if an :invisible overlay spans POINT's line without either endpoint on it." + (loop :for overlay :in overlays + :thereis (and (overlay-get overlay :invisible) + (not (same-line-p (overlay-start overlay) point)) + (not (same-line-p (overlay-end overlay) point))))) + +(defun invisible-overlay-covering (point &optional (overlays (buffer-overlays (point-buffer point)))) + "Return the :invisible overlay covering POINT." + (loop :for overlay :in overlays + :thereis (and (overlay-get overlay :invisible) + (point<= (overlay-start overlay) point) + (point< point (overlay-end overlay)) + overlay))) + +(defun move-point-out-of-overlay (point overlay direction) + "Move POINT to the nearest edge of OVERLAY in DIRECTION (:forward or :backward), so it does not +rest inside. usable directly as a :cursor-enter-functions handler; `fold-region' installs it by +default so a cursor never appears stuck on the hidden text of a fold." + (if (eq direction :backward) + (progn + (move-point point (overlay-start overlay)) + ;; step onto the last visible position before the overlay, unless that is the buffer + ;; start, where there is nowhere further to go. + (character-offset point -1)) + (move-point point (overlay-end overlay)))) + +(defun reveal-overlay-on-cursor-enter (point overlay direction) + (overlay-put overlay :invisible nil) + (overlay-put overlay :show-virtual-text nil)) + +(defun hide-overlay-on-cursor-leave (point overlay direction) + (overlay-put overlay :invisible t) + (overlay-put overlay :show-virtual-text t)) + +(defun overlay-show-virtual-text-p (overlay) + "Whether OVERLAY's :before-string/:after-string should render. defaults to T." + (let ((value (getf (overlay-plist overlay) :show-virtual-text :unset))) + (if (eq value :unset) t value))) + +(defun overlay-has-cursor-hooks-p (overlay) + (or (overlay-get overlay :cursor-enter-functions) + (overlay-get overlay :cursor-leave-functions))) + +(defun overlays-with-cursor-hooks-covering (point) + "The overlays covering POINT that take part in cursor enter/leave tracking." + (loop :for overlay :in (buffer-overlays (point-buffer point)) + :when (and (overlay-has-cursor-hooks-p overlay) + (point<= (overlay-start overlay) point) + (point< point (overlay-end overlay))) + :collect overlay)) + +;; but reveal behavior isnt relevant for line folding +(defun place-region-placeholder-overlay (start end &key (placeholder "...") (cursor-behavior :move-out) (is-line-fold t)) + "Hide the lines of the region [START, END), leaving START's line visible with a fold marker. +returns the fold overlay. CURSOR-BEHAVIOR decides how the cursor is kept off the hidden text: +- :move-out :: move the cursor to the nearest visible edge (see `move-point-out-of-overlay'). +- :reveal :: open the fold while the cursor is inside it and close it again on leave (see + `reveal-overlay-on-cursor-enter' / `hide-overlay-on-cursor-leave'). +- nil :: install nothing; the cursor may rest on the hidden text. +callers can also set the overlay's :cursor-enter-functions / :cursor-leave-functions directly." + (with-point ((s start) + (e end)) + (when is-line-fold + (line-end s) + ;; dont hide the newline that terminates the folded region's last line, or the line after + ;; the fold gets merged onto the header's visual line. + (when (start-line-p e) + (character-offset e -1))) + (let ((overlay (make-overlay s e 'fold-attribute))) + (overlay-put overlay :invisible t) + (overlay-put overlay :fold t) + (overlay-put overlay :before-string (list placeholder 'fold-attribute)) + (ecase cursor-behavior + (:move-out + (overlay-put overlay :cursor-enter-functions (list 'move-point-out-of-overlay))) + (:reveal + (overlay-put overlay :cursor-enter-functions (list 'reveal-overlay-on-cursor-enter)) + (overlay-put overlay :cursor-leave-functions (list 'hide-overlay-on-cursor-leave))) + ((nil))) + overlay))) + +(defun line-continuation-p (point) + "Whether POINT's line continues a previous visual line. meaning the newline preceding it is +hidden by an :invisible overlay, so the line is not a visual line of its own. +a folded region may hide arbitrary character ranges, including the newlines that join several +buffer lines into one displayed line." + (and (not (first-line-p point)) + (with-point ((p point)) + (line-start p) + (character-offset p -1) + (invisible-overlay-covering p)))) + +(defun collect-visual-line-string-and-attributes (vstart vend) + (with-point ((p vstart)) + (let ((out (make-string-output-stream)) + (attributes) + (base 0)) + (loop + (destructuring-bind (string . attrs) (get-string-and-attributes-at-point p) + (write-string string out) + (loop :for (s e attr) :in attrs + :do (push (list (+ base s) (+ base e) attr) attributes)) + (incf base (length string)) + (when (same-line-p p vend) + (return)) + (write-char #\newline out) + (incf base) + (line-offset p 1))) + (values (get-output-stream-string out) + (nreverse attributes))))) + (defun create-logical-line (point overlays active-modes) - (flet ((overlay-start-charpos (overlay point) - (if (same-line-p point (overlay-start overlay)) - (point-charpos (overlay-start overlay)) - 0)) - (overlay-end-charpos (overlay point) - (when (same-line-p point (overlay-end overlay)) - (point-charpos (overlay-end overlay))))) - (let* ((end-of-line-cursor-attribute nil) - (extend-to-end-attribute nil) - (line-end-overlay nil) - (left-content - (compute-left-display-area-content active-modes - (point-buffer point) - point)) - (tab-width (variable-value 'tab-width :default point))) - (destructuring-bind (string . attributes) - (get-string-and-attributes-at-point point) - (loop :for overlay :in overlays - :when (overlay-within-point-p overlay point) - :do (cond ((typep overlay 'line-endings-overlay) - (when (same-line-p (overlay-end overlay) point) - (setf line-end-overlay overlay))) - ((typep overlay 'line-overlay) - (let ((attribute (overlay-attribute overlay))) - (setf attributes - (overlay-attributes attributes - 0 - (length string) - attribute)) - (setf extend-to-end-attribute attribute))) - ((typep overlay 'cursor-overlay) - (let* ((overlay-start-charpos (overlay-start-charpos overlay point)) - (overlay-end-charpos (1+ overlay-start-charpos)) - (overlay-attribute (overlay-attribute overlay))) - (unless (cursor-overlay-fake-p overlay) - (set-cursor-attribute overlay-attribute)) - (if (<= (length string) overlay-start-charpos) - (setf end-of-line-cursor-attribute overlay-attribute) + "Build a logical-line for the visual line starting at POINT, joining any following buffer lines +whose preceding newline is hidden by an :invisible overlay. a single displayed line may contain +several folds that each hide arbitrary character ranges across multiple buffer lines." + (let ((invisible-overlays + (remove-if-not (lambda (ov) (overlay-get ov :invisible)) overlays))) + (with-point ((vstart point) + (vend point)) + (line-start vstart) + (line-end vend) + ;; extend VEND across every newline hidden by an invisible overlay so the + ;; visual line reaches the next *visible* newline (or the buffer end). + (loop :until (last-line-p vend) + :while (invisible-overlay-covering vend invisible-overlays) + :do (line-offset vend 1) + (line-end vend)) + (let ((overlays (remove-if-not + (lambda (ov) + (and (point<= (overlay-start ov) vend) + (point<= vstart (overlay-end ov)))) + overlays))) + (flet ((overlay-start-charpos (overlay) + ;; column where the overlay starts on this visual line, clamped to + ;; 0 when it begins before VSTART. + (let ((s (overlay-start overlay))) + (if (point<= vstart s) + (count-characters vstart s) + 0))) + (overlay-end-charpos (overlay) + ;; column where the overlay ends, or NIL when it extends past VEND. + (let ((e (overlay-end overlay))) + (when (point<= e vend) + (count-characters vstart e)))) + (start-in-line-p (overlay) + ;; true when the overlay's start falls within this visual line. + (point<= vstart (overlay-start overlay))) + (end-in-line-p (overlay) + ;; true when the overlay's end falls within this visual line. + (point<= (overlay-end overlay) vend))) + (let* ((end-of-line-cursor-attribute nil) + (extend-to-end-attribute nil) + (line-end-overlay nil) + (virtual-items) + (splice-ops) + (left-content + (compute-left-display-area-content active-modes + (point-buffer point) + point)) + (tab-width (variable-value 'tab-width :default point))) + (multiple-value-bind (string attributes) + (collect-visual-line-string-and-attributes vstart vend) + ;; collect string-splice operations from :invisible/:display overlays. + (loop :for overlay :in overlays + :for invisible := (overlay-get overlay :invisible) + :for display := (overlay-get overlay :display) + :do (when (or invisible display) + (let* ((ov-start (overlay-start-charpos overlay)) + (ov-end (or (overlay-end-charpos overlay) + (length string))) + (replacement + (cond + (display + (let ((d (alexandria:ensure-list display))) + (if (stringp (first d)) (first d) ""))) + ((eq invisible :ellipsis) "...") + (t ""))) + (repl-attr + (when (listp display) (second display)))) + (when (< ov-start ov-end) + (push (list ov-start ov-end replacement repl-attr) splice-ops))))) + ;; apply splices right-to-left for position stability + (when splice-ops + (dolist (op (sort (copy-list splice-ops) #'> :key #'first)) + (destructuring-bind (ov-start ov-end replacement repl-attr) op + (setf (values string attributes) + (splice-string string + attributes + ov-start + ov-end + replacement + repl-attr))))) + ;; process all overlays for attributes (virtual text handled separately below). + (loop :for overlay :in overlays + :do (cond + ((typep overlay 'line-endings-overlay) + (when (end-in-line-p overlay) + (setf line-end-overlay overlay))) + ((typep overlay 'line-overlay) + (let ((attribute (overlay-attribute overlay))) + (setf attributes + (overlay-attributes attributes + 0 + (length string) + attribute)) + (setf extend-to-end-attribute attribute))) + ((typep overlay 'cursor-overlay) + ;; remap the cursor into the spliced string so it lands on the right + ;; column past any folds on this visual line. + (let* ((ov-start (adjust-charpos-for-splices + (overlay-start-charpos overlay) splice-ops)) + (ov-end (1+ ov-start)) + (ov-attr (overlay-attribute overlay))) + (unless (cursor-overlay-fake-p overlay) + (set-cursor-attribute ov-attr)) + (if (<= (length string) ov-start) + (setf end-of-line-cursor-attribute ov-attr) + (setf attributes + (overlay-attributes attributes ov-start ov-end ov-attr))))) + (t + (let ((ov-start (adjust-charpos-for-splices + (overlay-start-charpos overlay) splice-ops)) + (ov-end (let ((e (overlay-end-charpos overlay))) + (when e + (adjust-charpos-for-splices e splice-ops)))) + (ov-attr (overlay-attribute overlay)) + (invisible (overlay-get overlay :invisible)) + (display (overlay-get overlay :display))) + ;; plain attribute (only when not replaced by invisible/display) + (when (and ov-attr (not invisible) (not display)) + (unless ov-end + (setf extend-to-end-attribute ov-attr)) (setf attributes - (overlay-attributes - attributes - overlay-start-charpos - overlay-end-charpos - overlay-attribute))))) - (t - (let ((overlay-start-charpos (overlay-start-charpos overlay point)) - (overlay-end-charpos (overlay-end-charpos overlay point)) - (overlay-attribute (overlay-attribute overlay))) - (unless overlay-end-charpos - (setf extend-to-end-attribute - (overlay-attribute overlay))) - (setf attributes - (overlay-attributes - attributes - overlay-start-charpos - (or overlay-end-charpos (length string)) - overlay-attribute)))))) - (setf (values string attributes) (expand-tab string attributes tab-width)) - (let ((charpos (point-charpos point))) - (when (< 0 charpos) - (psetf string (subseq string charpos) - attributes (lem/buffer/line:subseq-elements attributes charpos (length string))))) - (make-logical-line :string string - :attributes attributes - :left-content left-content - :extend-to-end extend-to-end-attribute - :end-of-line-cursor-attribute end-of-line-cursor-attribute - :line-end-overlay line-end-overlay))))) + (overlay-attributes attributes + ov-start + (or ov-end (length string)) + ov-attr))))))) + ;; virtual text from :before-string/:after-string overlays. emit each overlay's + ;; :before then :after, visiting overlays in (end, start) order: at any shared + ;; charpos an overlay closing there (smaller end) is emitted before one opening + ;; there, so trailing :after-strings precede leading :before-strings, but a + ;; zero-length overlay's own pair stays adjacent. a final stable sort by charpos + ;; groups them without affecting this order. + (loop :for overlay :in (stable-sort + (loop :for overlay :in overlays + :when (and (overlay-show-virtual-text-p overlay) + (or (overlay-get overlay :before-string) + (overlay-get overlay :after-string))) + :collect overlay) + (lambda (a b) + (let ((a-end (or (overlay-end-charpos a) (length string))) + (b-end (or (overlay-end-charpos b) (length string)))) + (if (= a-end b-end) + (< (overlay-start-charpos a) + (overlay-start-charpos b)) + (< a-end b-end))))) + :for before-str := (overlay-get overlay :before-string) + :for after-str := (overlay-get overlay :after-string) + :do (when (and before-str (start-in-line-p overlay)) + (let ((bs (alexandria:ensure-list before-str))) + (push (make-virtual-item :charpos (overlay-start-charpos overlay) + :string (first bs) + :attribute (second bs)) + virtual-items))) + (when (and after-str (end-in-line-p overlay)) + (let ((as (alexandria:ensure-list after-str))) + (push (make-virtual-item :charpos (or (overlay-end-charpos overlay) + (length string)) + :string (first as) + :attribute (second as)) + virtual-items)))) + ;; markers were positioned in raw coordinates; remap them into the + ;; spliced string so several folds on one visual line stay anchored. + (dolist (vi virtual-items) + (setf (virtual-item-charpos vi) + (adjust-charpos-for-splices (virtual-item-charpos vi) splice-ops))) + (setf virtual-items + (stable-sort (nreverse virtual-items) #'< :key #'virtual-item-charpos)) + (setf (values string attributes) (expand-tab string attributes tab-width)) + (let ((charpos (point-charpos point))) + (when (< 0 charpos) + (psetf string (subseq string charpos) + attributes (lem/buffer/line:subseq-elements + attributes charpos (length string))) + ;; adjust virtual-item positions for the charpos clip (order preserved) + (setf virtual-items + (loop :for vi :in virtual-items + :when (>= (virtual-item-charpos vi) charpos) + :collect (make-virtual-item + :charpos (- (virtual-item-charpos vi) charpos) + :string (virtual-item-string vi) + :attribute (virtual-item-attribute vi)))))) + (make-logical-line + :string string + :attributes attributes + :virtual-items virtual-items + :left-content left-content + :extend-to-end extend-to-end-attribute + :end-of-line-cursor-attribute end-of-line-cursor-attribute + :line-end-overlay line-end-overlay)))))))) (defstruct string-with-attribute-item string @@ -191,41 +464,95 @@ (defmethod item-attribute ((item extend-to-eol-item)) nil) +(defun add-or-merge-item (item items) + "add ITEM to the front of ITEMS, or merge it into the previous +string-with-attribute-item when both carry the same attribute. returns the updated list." + (let ((last-item (first items))) + (if (and (string-with-attribute-item-p last-item) + (string-with-attribute-item-p item) + (equal (string-with-attribute-item-attribute last-item) + (string-with-attribute-item-attribute item))) + (progn + (setf (string-with-attribute-item-string last-item) + (str:concat (string-with-attribute-item-string last-item) + (string-with-attribute-item-string item))) + items) + (cons item items)))) + (defun compute-items-from-string-and-attributes (string attributes) (handler-case (let ((items '())) - (flet ((add (item) - (if (null items) - (push item items) - (let ((last-item (first items))) - (if (and (string-with-attribute-item-p last-item) - (string-with-attribute-item-p item) - (equal (string-with-attribute-item-attribute last-item) - (string-with-attribute-item-attribute item))) - (setf (string-with-attribute-item-string (first items)) - (str:concat (string-with-attribute-item-string last-item) - (string-with-attribute-item-string item))) - (push item items)))))) - (loop :for last-pos := 0 :then end - :for (start end attribute) :in attributes - :do (unless (= last-pos start) - (add (make-string-with-attribute-item :string (subseq string last-pos start)))) - (add (if (cursor-attribute-p attribute) - (make-cursor-item :string (subseq string start end) :attribute attribute) - (make-string-with-attribute-item - :string (subseq string start end) - :attribute attribute))) - :finally (push (make-string-with-attribute-item :string (subseq string last-pos)) - items))) + (loop :for last-pos := 0 :then end + :for (start end attribute) :in attributes + :do (unless (= last-pos start) + (setf items (add-or-merge-item + (make-string-with-attribute-item :string (subseq string last-pos start)) + items))) + (setf items (add-or-merge-item + (if (cursor-attribute-p attribute) + (make-cursor-item :string (subseq string start end) :attribute attribute) + (make-string-with-attribute-item + :string (subseq string start end) + :attribute attribute)) + items)) + :finally (push (make-string-with-attribute-item :string (subseq string last-pos)) + items)) items) (error (e) (log:error e string attributes) nil))) +(defun inject-virtual-items (string attributes virtual-items) + "produce items from STRING and ATTRIBUTES, injecting VIRTUAL-ITEMS at their charposes. +VIRTUAL-ITEMS arrive in draw order (from `create-logical-line')." + (let* (;; all positions where we may need to split: attribute boundaries, virtual charposes. + (positions + (sort (remove-duplicates + (nconc (list 0 (length string)) + (mapcar #'virtual-item-charpos virtual-items) + (mapcan (lambda (span) (list (first span) (second span))) + attributes))) + #'<)) + ;; VIRTUAL-ITEMS are already sorted by charpos in draw order + (pending virtual-items) + (items)) + (flet ((add-virtuals-at (pos) + (loop :while (and pending (= (virtual-item-charpos (first pending)) pos)) + :do (let ((vi (pop pending))) + (setf items (add-or-merge-item + (make-string-with-attribute-item + :string (virtual-item-string vi) + :attribute (virtual-item-attribute vi)) + items)))))) + ;; walk segments between break positions, injecting virtual items at each boundary + (loop :for (pos . rest) :on positions + :while rest + :for next-pos := (first rest) + :do (add-virtuals-at pos) + (unless (= pos next-pos) + (let* ((seg (subseq string pos next-pos)) + (attr (loop :for (start end attribute) :in attributes + :when (and (<= start pos) (>= end next-pos)) + :return attribute))) + (unless (string= seg "") + (setf items (add-or-merge-item + (if (cursor-attribute-p attr) + (make-cursor-item :string seg :attribute attr) + (make-string-with-attribute-item :string seg + :attribute attr)) + items)))))) + ;; virtual items at the very end of the string + (add-virtuals-at (length string))) + items)) + (defun compute-items-from-logical-line (logical-line) (let ((items - (compute-items-from-string-and-attributes (logical-line-string logical-line) - (logical-line-attributes logical-line)))) + (if (logical-line-virtual-items logical-line) + (inject-virtual-items (logical-line-string logical-line) + (logical-line-attributes logical-line) + (logical-line-virtual-items logical-line)) + (compute-items-from-string-and-attributes (logical-line-string logical-line) + (logical-line-attributes logical-line))))) (alexandria:when-let (attribute (logical-line-extend-to-end logical-line)) (push (make-extend-to-eol-item :color (attribute-background-color attribute)) @@ -290,9 +617,13 @@ (active-modes (get-active-modes-class-instance (window-buffer window))) (*active-modes* active-modes)) (loop :for logical-line := (create-logical-line point overlays active-modes) - :do (funcall function logical-line) - (unless (line-offset point 1) - (return)))))) + :do (when logical-line + (funcall function logical-line)) + (loop + (unless (line-offset point 1) + (return-from call-do-logical-line)) + (unless (line-continuation-p point) + (return))))))) (defmacro do-logical-line ((logical-line window) &body body) `(call-do-logical-line ,window (lambda (,logical-line) ,@body))) diff --git a/src/display/physical-line.lisp b/src/display/physical-line.lisp index add39e7a5..9fb31f600 100644 --- a/src/display/physical-line.lisp +++ b/src/display/physical-line.lisp @@ -536,6 +536,7 @@ over the top-level spine and tolerant of improper (dotted) lists." (logical-line-end-of-line-cursor-attribute logical-line) (logical-line-extend-to-end logical-line) (logical-line-line-end-overlay logical-line) + (logical-line-virtual-items logical-line) scroll-start left-side-width)) diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index 573a6bd11..e8d94bf80 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -6,6 +6,9 @@ :idle-function :beginning-of-defun-function :end-of-defun-function + :fold-region-function + :fold-toggle-at-point + :unfold-all :comment-region :uncomment-region :comment-or-uncomment-region @@ -60,6 +63,13 @@ (define-editor-variable indent-size 2) (define-editor-variable root-uri-patterns '()) (define-editor-variable detective-search nil) +(define-editor-variable enable-tab-fold + t + "When T, the Tab key attempts to fold/unfold defuns and falls back to +`indent-line-and-complete-symbol', otherwise it just invokes the latter.") +(define-editor-variable fold-region-function + 'fold-region-default + "Function of one point returning (values start end) for the foldable region at point, or NIL.") (defun prompt-for-symbol (prompt history-name) (prompt-for-string prompt :history-symbol history-name)) @@ -86,7 +96,7 @@ (define-key *language-mode-keymap* "C-M-a" 'beginning-of-defun) (define-key *language-mode-keymap* "C-M-e" 'end-of-defun) -(define-key *language-mode-keymap* "Tab" 'indent-line-and-complete-symbol) +(define-key *language-mode-keymap* "Tab" 'fold-or-indent-or-complete) (define-key *global-keymap* "C-j" 'newline-and-indent) (define-key *global-keymap* "M-j" 'newline-and-indent) (define-key *language-mode-keymap* "C-M-\\" 'indent-region) @@ -115,6 +125,59 @@ (funcall fn (current-point) n) (beginning-of-defun-1 (- n))))) +(defun fold-region-default (point) + "Return (values start end) spanning the defun at POINT, or NIL." + (let ((defun-begin (variable-value 'beginning-of-defun-function :buffer point)) + (defun-end (variable-value 'end-of-defun-function :buffer point))) + (when (and defun-begin defun-end) + (let ((start (copy-point point :temporary)) + (end (copy-point point :temporary))) + (funcall defun-end end 1) + (move-point start end) + (funcall defun-begin start 1) + (when (point< start end) + (values start end)))))) + +(defun fold-overlay-at (point) + "The fold overlay whose header line is POINT's line, or NIL." + (find-if + (lambda (overlay) + (and (overlay-get overlay :fold) + (same-line-p (overlay-start overlay) point))) + (buffer-overlays (point-buffer point)))) + +(defun fold-defun-at (point) + "Fold the defun at POINT. Returns T when something was folded." + (let ((fn (variable-value 'fold-region-function :default point))) + (multiple-value-bind (start end) (funcall fn point) + (when (and start end + (not (same-line-p start end)) + (same-line-p start point)) + (let ((overlay (place-region-placeholder-overlay start end :is-line-fold t))) + (move-point point start) + (overlay-put overlay :fold t) + overlay))))) + +(defun fold-toggle-at-point (&optional (point (current-point))) + "Toggle the fold at POINT. returns T when a fold was added or removed, and NIL when there was +nothing to fold." + (let ((fold (fold-overlay-at point))) + (cond (fold (delete-overlay fold) t) + ((fold-defun-at point) t) + (t nil)))) + +(define-command fold-or-indent-or-complete () () + "Fold or unfold the defun at point. otherwise indent and complete the symbol." + (unless (and (variable-value 'enable-tab-fold) + (fold-toggle-at-point)) + (indent-line-and-complete-symbol))) + +(define-command unfold-all () () + "Remove every fold in the current buffer." + (dolist (overlay (copy-list (buffer-overlays))) + (when (overlay-get overlay :fold) + (delete-overlay overlay)))) + (define-command (indent (:advice-classes editable-advice)) (&optional (n 1)) (:universal) (if (variable-value 'calc-indent-function) (indent-line (current-point)) diff --git a/src/internal-packages.lisp b/src/internal-packages.lisp index 6dfa60cc2..8a6e8b04e 100644 --- a/src/internal-packages.lisp +++ b/src/internal-packages.lisp @@ -134,6 +134,7 @@ :define-attribute :cursor :region + :fold-attribute :modeline :modeline-inactive :truncate-attribute @@ -580,7 +581,9 @@ :overlay-put :overlay-get :clear-overlays - :point-overlays) + :point-overlays + :buffer-overlays + :overlay-within-point-p) ;; streams.lisp (:export :buffer-input-stream @@ -659,7 +662,14 @@ :compute-wrap-left-area-content) ;; display/logical-line.lisp (:export - :make-region-overlays-using-global-mode) + :make-region-overlays-using-global-mode + :line-continuation-p + :invisible-overlay-covering + :move-point-out-of-overlay + :reveal-overlay-on-cursor-enter + :hide-overlay-on-cursor-leave + :overlays-with-cursor-hooks-covering + :place-region-placeholder-overlay) ;; interface.lisp (:export :with-implementation diff --git a/src/window/virtual-line.lisp b/src/window/virtual-line.lisp index 3d314759b..d5b4bc96d 100644 --- a/src/window/virtual-line.lisp +++ b/src/window/virtual-line.lisp @@ -95,9 +95,30 @@ next line because it is at the end of width." #'inc))) offset))) +(defun count-hidden-lines (start-point end-point) + "Number of hidden buffer lines between START-POINT and END-POINT." + (when (point< end-point start-point) + (rotatef start-point end-point)) + (with-point ((p start-point) + (goal end-point)) + (line-start p) + (line-start goal) + (loop :with count := 0 + :until (same-line-p p goal) + :do (unless (line-offset p 1) + (return count)) + (when (line-continuation-p p) + (incf count)) + :finally (return count)))) + (defun window-cursor-y-not-wrapping (window) - (count-lines (window-buffer-point window) - (window-view-point window))) + "Number of screen rows between the view point and the cursor. +excludes lines hidden by overlays with :invisible property because those lines dont occupy any +vertical space." + (let ((view-point (window-view-point window)) + (buffer-point (window-buffer-point window))) + (- (count-lines buffer-point view-point) + (count-hidden-lines view-point buffer-point)))) (defun window-cursor-y (window) (if (point< (window-buffer-point window) diff --git a/tests/visual-line.lisp b/tests/visual-line.lisp new file mode 100644 index 000000000..619915bb0 --- /dev/null +++ b/tests/visual-line.lisp @@ -0,0 +1,181 @@ +(defpackage :lem-tests/visual-line + (:use :cl :rove :lem) + (:import-from + :lem-tests/utilities + :with-testing-buffer + :make-text-buffer + :lines)) + +(in-package :lem-tests/visual-line) + +(defun point-at-line (buffer line-number) + "Temporary point at the start of LINE-NUMBER (0-based) of BUFFER." + (let ((point (copy-point (buffer-start-point buffer) :temporary))) + (line-offset point line-number) + (line-start point) + point)) + +(defun forward-char-command () + (lem-core::save-continue-flags + (lem-core:call-command (make-instance 'lem:forward-char) nil))) + +(defun backward-char-command () + (lem-core::save-continue-flags + (lem-core:call-command (make-instance 'lem:backward-char) nil))) + +(defun next-line-command () + (lem-core::save-continue-flags + (lem-core:call-command (make-instance 'lem:next-line) nil))) + +(deftest visual-line-navigation-across-folds + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer + (lines "AAAA" "BBBB" "CCCC" "DDDD" "EEEE" "FFFF"))) + (labels ((fold-lines (first last) + "Fold buffer lines FIRST..LAST (inclusive) into a single visual line." + (place-region-placeholder-overlay + (point-at-line buffer first) + (point-at-line buffer (1+ last)))) + (expect-visual-line (containing-line first-line last-line) + (testing (format + nil + "Buffer line ~D belongs to the visual line spanning lines ~D..~D" + containing-line + first-line + last-line) + (with-point ((p1 (point-at-line buffer containing-line)) + (p2 (point-at-line buffer containing-line))) + (line-offset p1 0 2) + (line-offset p2 0 2) + (ok (= (position-at-point (visual-line-beginning p1)) + (position-at-point (point-at-line buffer first-line)))) + (ok (= (position-at-point (visual-line-end p2)) + (position-at-point (line-end (point-at-line buffer last-line))))))))) + (fold-lines 0 1) + (fold-lines 3 4) + ;; folded lines collapse with others, unfolded lines stand alone. + (expect-visual-line 0 0 1) + (expect-visual-line 1 0 1) + (expect-visual-line 2 2 2) + (expect-visual-line 3 3 4) + (expect-visual-line 4 3 4) + (expect-visual-line 5 5 5))))) + +(defun hide-range (buffer start-charpos end-charpos &key (cursor-behavior nil)) + (with-point ((start (buffer-start-point buffer)) + (end (buffer-start-point buffer))) + (character-offset start start-charpos) + (character-offset end end-charpos) + (place-region-placeholder-overlay + start + end + :is-line-fold nil + :cursor-behavior cursor-behavior))) + +;; move-point-out-of-overlay (the :move-out handler) moves the cursor out of an invisible +;; overlay's span +(deftest cursor-moves-out-of-invisible-overlay + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer (lines "ABCDEFGH"))) + (lem-core::set-window-buffer buffer (current-window)) + ;; hides "EFG" + (let ((overlay (hide-range buffer 4 7 :cursor-behavior :move-out)) + (point (buffer-point buffer))) + (move-point point (buffer-start-point buffer)) + ;; ABC|DEFGH, just before the range + (character-offset point 3) + ;; ABCD|EFGH, enters the range, gets moved out + (forward-char-command) + (ok (null (invisible-overlay-covering point)) "not left on hidden text") + (ok (point= point (overlay-end overlay)) "pushed forward out of the span") + ;; entering from the far side throws the cursor out the other way + (backward-char-command) + (ok (null (invisible-overlay-covering point)) "not left on hidden text") + (ok (point< point (overlay-start overlay)) "pushed backward out of the span"))))) + +;; reveal-overlay-on-cursor-enter / hide-overlay-on-cursor-leave toggle :invisible as the cursor +;; enters and leaves. +(deftest cursor-reveals-and-hides-invisible-overlay + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer (lines "ABCDEFGH"))) + (lem-core::set-window-buffer buffer (current-window)) + (let ((overlay (hide-range buffer 4 7 :cursor-behavior :reveal)) + (point (buffer-point buffer))) + (ok (eq t (overlay-get overlay :invisible)) "starts hidden") + ;; step from just before the hidden range into it; entering must reveal it. + (move-point point (buffer-start-point buffer)) + ;; ABC|DEFGH, just before the range + (character-offset point 3) + ;; ABCD|EFGH, cursor enters the range, text gets revealed + (forward-char-command) + (ok (null (overlay-get overlay :invisible)) "revealed on enter") + ;; keep walking until the cursor passes the far edge. leaving must hide it again. + (loop :until (point<= (overlay-end overlay) point) + :do (forward-char-command)) + (ok (eq t (overlay-get overlay :invisible)) "hidden again on leave"))))) + +;; move-point-out-of-overlay also moves the cursor out of a :move-out fold whose hidden span +;; crosses a newline (an arbitrary line-collapsing fold), so forward-char/backward-char never get +;; stuck on the hidden text when stepping in from either side. +(deftest cursor-moves-out-of-newline-crossing-fold + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer + (lines "abc DEF ghi" "JKL mno PQR" "stu VWX yz"))) + (lem-core::set-window-buffer buffer (current-window)) + ;; hide "DEF ghi" + newline + "JKL " + (with-point ((start (point-at-line buffer 0)) + (end (point-at-line buffer 1))) + (character-offset start 4) + (character-offset end 4) + (let ((overlay (place-region-placeholder-overlay + start + end + :is-line-fold nil + :cursor-behavior :move-out)) + (point (buffer-point buffer))) + ;; "abc |DEF..." just before the span. entering it pushes out the forward edge. + (move-point point (point-at-line buffer 0)) + (character-offset point 3) + (forward-char-command) + (ok (null (invisible-overlay-covering point)) "not left on hidden text") + (ok (point= point (overlay-end overlay)) "pushed forward out of the span") + ;; entering from the far side throws the cursor out the other way + (backward-char-command) + (ok (null (invisible-overlay-covering point)) "not left on hidden text") + (ok (point< point (overlay-start overlay)) "pushed backward out of the span")))))) + +;; vertical motion steps clear of a :move-out fold (the default, e.g. a folded defun): next-line +;; from the fold header lands on the first visible line past the hidden lines, not inside them. +(deftest next-line-skips-move-out-fold + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer + (lines "AAAA" "BBBB" "CCCC" "DDDD" "EEEE" "FFFF"))) + (lem-core::set-window-buffer buffer (current-window)) + (place-region-placeholder-overlay (point-at-line buffer 1) + (point-at-line buffer 4)) + (let ((point (buffer-point buffer))) + (move-point point (point-at-line buffer 1)) + (next-line-command) + (ok (null (invisible-overlay-covering point)) "not left on a hidden line") + (ok (= (position-at-point point) + (position-at-point (point-at-line buffer 4))) + "next-line landed on the first visible line past the fold"))))) + +;; vertical motion enters a :reveal fold: next-line from the header lands on the first hidden line +;; and the post-command cursor-enter hook reveals it. +(deftest next-line-enters-and-reveals-reveal-fold + (lem-fake-interface:with-fake-interface () + (with-testing-buffer (buffer (make-text-buffer + (lines "AAAA" "BBBB" "CCCC" "DDDD" "EEEE" "FFFF"))) + (lem-core::set-window-buffer buffer (current-window)) + (let ((overlay (place-region-placeholder-overlay (point-at-line buffer 1) + (point-at-line buffer 4) + :cursor-behavior :reveal)) + (point (buffer-point buffer))) + (ok (eq t (overlay-get overlay :invisible)) "starts hidden") + (move-point point (point-at-line buffer 1)) + (next-line-command) + (ok (null (overlay-get overlay :invisible)) "revealed after entering on next-line") + (ok (= (position-at-point point) + (position-at-point (point-at-line buffer 2))) + "cursor landed inside the fold on the first hidden line"))))) \ No newline at end of file