From 36fb4a48d4e84f72dfab50d8c05c89d2c2a5f7e3 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Tue, 16 Jun 2026 22:09:38 +0300 Subject: [PATCH 01/10] support for invisibility and "virtual" items --- src/display/logical-line.lisp | 341 +++++++++++++++++++++++++-------- src/display/physical-line.lisp | 1 + 2 files changed, 257 insertions(+), 85 deletions(-) diff --git a/src/display/logical-line.lisp b/src/display/logical-line.lisp index 7545c6f50..7d3bd024a 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,7 +85,34 @@ 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 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 create-logical-line (point overlays active-modes) + "build a logical-line for POINT's line, or NIL if the line is entirely invisible." (flet ((overlay-start-charpos (overlay point) (if (same-line-p point (overlay-start overlay)) (point-charpos (overlay-start overlay)) @@ -85,67 +120,148 @@ (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))) + (let ((overlays (remove-if-not (lambda (ov) (overlay-within-point-p ov point)) + overlays))) + (when (line-fully-invisible-p point overlays) + (return-from create-logical-line nil)) + (let* ((end-of-line-cursor-attribute nil) + (extend-to-end-attribute nil) + (line-end-overlay nil) + (virtual-items) + (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) + ;; collect string-splice operations from :invisible/:display overlays. + (let ((splice-ops)) + (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 point)) + (ov-end (or (overlay-end-charpos overlay point) + (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 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 (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* ((ov-start (overlay-start-charpos overlay point)) + (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 (overlay-start-charpos overlay point)) + (ov-end (overlay-end-charpos overlay point)) + (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 - 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) - (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))))) + 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 (or (overlay-get overlay :before-string) + (overlay-get overlay :after-string)) + :collect overlay) + (lambda (a b) + (let ((a-end (or (overlay-end-charpos a point) (length string))) + (b-end (or (overlay-end-charpos b point) (length string)))) + (if (= a-end b-end) + (< (overlay-start-charpos a point) + (overlay-start-charpos b point)) + (< 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 (same-line-p (overlay-start overlay) point)) + (let ((bs (alexandria:ensure-list before-str))) + (push (make-virtual-item :charpos (overlay-start-charpos overlay point) + :string (first bs) + :attribute (second bs)) + virtual-items))) + (when (and after-str (same-line-p (overlay-end overlay) point)) + (let ((as (alexandria:ensure-list after-str))) + (push (make-virtual-item :charpos (or (overlay-end-charpos overlay point) + (length string)) + :string (first as) + :attribute (second as)) + virtual-items)))) + (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 +307,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,7 +460,8 @@ (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) + :do (when logical-line + (funcall function logical-line)) (unless (line-offset point 1) (return)))))) 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)) From 535ef07dfc2557cf2890e6c164a4726bbad21a77 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Fri, 19 Jun 2026 01:29:38 +0300 Subject: [PATCH 02/10] make previous-line/next-line skip hidden lines --- lem.asd | 14 ++++++------ src/commands/move.lisp | 44 ++++++++++++++++++++++++++++++++++++-- src/internal-packages.lisp | 7 ++++-- 3 files changed, 54 insertions(+), 11 deletions(-) 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/commands/move.lisp b/src/commands/move.lisp index 35011dadd..c7db8c360 100644 --- a/src/commands/move.lisp +++ b/src/commands/move.lisp @@ -45,6 +45,46 @@ (define-key *global-keymap* "C-x [" 'previous-page-char) (define-key *global-keymap* "M-g" 'goto-line) +(defun line-invisible-p (point) + "T if POINT's line is fully hidden by an :invisible overlay." + (let ((overlays (remove-if-not + (lambda (ov) + (overlay-within-point-p ov point)) + (buffer-overlays (point-buffer point))))) + (line-fully-invisible-p point overlays))) + +(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-invisible-p 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-invisible-p 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-invisible-p 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 +107,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/internal-packages.lisp b/src/internal-packages.lisp index 6dfa60cc2..92f4329b6 100644 --- a/src/internal-packages.lisp +++ b/src/internal-packages.lisp @@ -580,7 +580,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 +661,8 @@ :compute-wrap-left-area-content) ;; display/logical-line.lisp (:export - :make-region-overlays-using-global-mode) + :make-region-overlays-using-global-mode + :line-fully-invisible-p) ;; interface.lisp (:export :with-implementation From ec4179741674c9a01ddba1913337f89008b091f0 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Fri, 19 Jun 2026 23:33:40 +0300 Subject: [PATCH 03/10] add generic defun-folding and bind it to tab --- src/ext/language-mode.lisp | 70 +++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index 573a6bd11..f04652f3a 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 @@ -51,6 +54,8 @@ (define-editor-variable idle-function nil) (define-editor-variable beginning-of-defun-function nil) (define-editor-variable end-of-defun-function nil) +(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.") (define-editor-variable line-comment nil) (define-editor-variable insertion-line-comment nil) (define-editor-variable find-definitions-function nil) @@ -61,6 +66,9 @@ (define-editor-variable root-uri-patterns '()) (define-editor-variable detective-search nil) +(define-attribute fold-attribute + (t :foreground :base04)) + (defun prompt-for-symbol (prompt history-name) (prompt-for-string prompt :history-symbol history-name)) @@ -86,7 +94,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 +123,66 @@ (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-region (start end &optional (fold-marker "...")) + "hide the lines of the region [START, END), leaving START's line visible with a fold marker. +returns the fold overlay." + (with-point ((s start)) + (line-end s) + (let ((overlay (make-overlay s end 'fold-attribute))) + (overlay-put overlay :invisible t) + (overlay-put overlay :fold t) + (overlay-put overlay :before-string (list fold-marker 'fold-attribute)) + overlay))) + +(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))) + (fold-region start end) + (move-point point start) + t)))) + +(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 (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)) From 8570dbb8365ed6f800385a75caef17249ca4aacd Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Sat, 27 Jun 2026 12:16:51 +0300 Subject: [PATCH 04/10] improve invisiblity behavior --- extensions/vi-mode/commands.lisp | 19 +- extensions/vi-mode/commands/utils.lisp | 5 +- lem-tests.asd | 3 +- src/commands/move.lisp | 68 ++++- src/display/logical-line.lisp | 392 +++++++++++++++---------- src/ext/language-mode.lisp | 9 +- src/internal-packages.lisp | 3 +- 7 files changed, 326 insertions(+), 173 deletions(-) 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..4018243c1 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 its 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/src/commands/move.lisp b/src/commands/move.lisp index c7db8c360..385d99079 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,18 +47,64 @@ (define-key *global-keymap* "C-x [" 'previous-page-char) (define-key *global-keymap* "M-g" 'goto-line) -(defun line-invisible-p (point) - "T if POINT's line is fully hidden by an :invisible overlay." - (let ((overlays (remove-if-not - (lambda (ov) - (overlay-within-point-p ov point)) - (buffer-overlays (point-buffer point))))) - (line-fully-invisible-p point overlays))) +(defvar *cursor-positions-before-command* + (make-hash-table :test 'eq) + "maps each cursor point to its absolute buffer position before the running +command, so `snap-cursors-out-of-invisible' can tell which direction it moved.") + +(add-hook *pre-command-hook* 'record-cursor-positions) +(add-hook *post-command-hook* 'snap-cursors-out-of-invisible) + +(defun record-cursor-positions () + "snapshot every cursor's position so a later snap can pick the visible edge on the far side +of a fold the cursor moves into." + (clrhash *cursor-positions-before-command*) + (dolist (point (buffer-cursors (current-buffer))) + (setf (gethash point *cursor-positions-before-command*) + (position-at-point point)))) + +(defun snap-cursors-out-of-invisible () + "a fold collapses its range, so no cursor may rest within it or at its start. move any such +cursor to the nearest visible position in its direction of travel, so the cursor never appears +stuck on hidden text." + (dolist (point (buffer-cursors (current-buffer))) + (let ((overlay (invisible-overlay-covering point))) + (when overlay + (let ((forwardp (let ((before (gethash point *cursor-positions-before-command*))) + (or (null before) + (<= before (position-at-point (overlay-start overlay))))))) + (loop :for ov := (invisible-overlay-covering point) + :while ov + :do (if forwardp + (move-point point (overlay-end ov)) + (progn + (move-point point (overlay-start ov)) + ;; dont continue looping if we are at the beginning of the buffer. + ;; it could cause an endless loop. + (unless (character-offset point -1) + (return)))))))))) + +(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 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-invisible-p point) + (loop :while (line-continuation-p point) :do (unless (line-offset point direction) (return-from skip-invisible-lines nil))) point) @@ -68,7 +116,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." (loop :repeat steps :do (unless (move-to-next-virtual-line point dir) (return-from move-to-next-visible-virtual-line nil)) - (when (line-invisible-p point) + (when (line-continuation-p point) (unless (skip-invisible-lines point dir) (return-from move-to-next-visible-virtual-line nil)))) point)) @@ -80,7 +128,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." (loop :repeat steps :do (unless (line-offset point dir) (return-from visible-line-offset nil)) - (when (line-invisible-p point) + (when (line-continuation-p point) (unless (skip-invisible-lines point dir) (return-from visible-line-offset nil)))) point)) diff --git a/src/display/logical-line.lisp b/src/display/logical-line.lisp index 7d3bd024a..5b5f94d04 100644 --- a/src/display/logical-line.lisp +++ b/src/display/logical-line.lisp @@ -104,6 +104,20 @@ 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 @@ -111,157 +125,232 @@ (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 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) - "build a logical-line for POINT's line, or NIL if the line is entirely invisible." - (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 ((overlays (remove-if-not (lambda (ov) (overlay-within-point-p ov point)) - overlays))) - (when (line-fully-invisible-p point overlays) - (return-from create-logical-line nil)) - (let* ((end-of-line-cursor-attribute nil) - (extend-to-end-attribute nil) - (line-end-overlay nil) - (virtual-items) - (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) - ;; collect string-splice operations from :invisible/:display overlays. - (let ((splice-ops)) - (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 point)) - (ov-end (or (overlay-end-charpos overlay point) - (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 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 (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* ((ov-start (overlay-start-charpos overlay point)) - (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) + "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 ov-start ov-end ov-attr))))) - (t - (let ((ov-start (overlay-start-charpos overlay point)) - (ov-end (overlay-end-charpos overlay point)) - (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 - 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 (or (overlay-get overlay :before-string) - (overlay-get overlay :after-string)) - :collect overlay) - (lambda (a b) - (let ((a-end (or (overlay-end-charpos a point) (length string))) - (b-end (or (overlay-end-charpos b point) (length string)))) - (if (= a-end b-end) - (< (overlay-start-charpos a point) - (overlay-start-charpos b point)) - (< 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 (same-line-p (overlay-start overlay) point)) - (let ((bs (alexandria:ensure-list before-str))) - (push (make-virtual-item :charpos (overlay-start-charpos overlay point) - :string (first bs) - :attribute (second bs)) - virtual-items))) - (when (and after-str (same-line-p (overlay-end overlay) point)) - (let ((as (alexandria:ensure-list after-str))) - (push (make-virtual-item :charpos (or (overlay-end-charpos overlay point) - (length string)) - :string (first as) - :attribute (second as)) - virtual-items)))) - (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) + (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 + 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 (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 - (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)))))) + (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 @@ -462,8 +551,11 @@ VIRTUAL-ITEMS arrive in draw order (from `create-logical-line')." (loop :for logical-line := (create-logical-line point overlays active-modes) :do (when logical-line (funcall function logical-line)) - (unless (line-offset point 1) - (return)))))) + (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/ext/language-mode.lisp b/src/ext/language-mode.lisp index f04652f3a..0cca24512 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -139,9 +139,14 @@ (defun fold-region (start end &optional (fold-marker "...")) "hide the lines of the region [START, END), leaving START's line visible with a fold marker. returns the fold overlay." - (with-point ((s start)) + (with-point ((s start) + (e end)) (line-end s) - (let ((overlay (make-overlay s end 'fold-attribute))) + ;; 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 fold-marker 'fold-attribute)) diff --git a/src/internal-packages.lisp b/src/internal-packages.lisp index 92f4329b6..e28fc61ad 100644 --- a/src/internal-packages.lisp +++ b/src/internal-packages.lisp @@ -662,7 +662,8 @@ ;; display/logical-line.lisp (:export :make-region-overlays-using-global-mode - :line-fully-invisible-p) + :line-continuation-p + :invisible-overlay-covering) ;; interface.lisp (:export :with-implementation From 548b77e09a71e9485ce1df0c2b448ac7f466bbea Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Sat, 27 Jun 2026 12:40:53 +0300 Subject: [PATCH 05/10] add test --- src/attribute.lisp | 3 +++ src/display/logical-line.lisp | 16 ++++++++++++ src/ext/language-mode.lisp | 19 -------------- src/internal-packages.lisp | 4 ++- tests/visual-line.lisp | 49 +++++++++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 tests/visual-line.lisp 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/display/logical-line.lisp b/src/display/logical-line.lisp index 5b5f94d04..e51777f65 100644 --- a/src/display/logical-line.lisp +++ b/src/display/logical-line.lisp @@ -133,6 +133,22 @@ visual line each splice text out." (point< point (overlay-end overlay)) overlay))) +(defun fold-region (start end &optional (fold-marker "...")) + "hide the lines of the region [START, END), leaving START's line visible with a fold marker. +returns the fold overlay." + (with-point ((s start) + (e end)) + (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 fold-marker 'fold-attribute)) + 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. diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index 0cca24512..d417adda9 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -66,9 +66,6 @@ (define-editor-variable root-uri-patterns '()) (define-editor-variable detective-search nil) -(define-attribute fold-attribute - (t :foreground :base04)) - (defun prompt-for-symbol (prompt history-name) (prompt-for-string prompt :history-symbol history-name)) @@ -136,22 +133,6 @@ (when (point< start end) (values start end)))))) -(defun fold-region (start end &optional (fold-marker "...")) - "hide the lines of the region [START, END), leaving START's line visible with a fold marker. -returns the fold overlay." - (with-point ((s start) - (e end)) - (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 fold-marker 'fold-attribute)) - overlay))) - (defun fold-overlay-at (point) "the fold overlay whose header line is POINT's line, or NIL." (find-if diff --git a/src/internal-packages.lisp b/src/internal-packages.lisp index e28fc61ad..29e80c378 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 @@ -663,7 +664,8 @@ (:export :make-region-overlays-using-global-mode :line-continuation-p - :invisible-overlay-covering) + :invisible-overlay-covering + :fold-region) ;; interface.lisp (:export :with-implementation diff --git a/tests/visual-line.lisp b/tests/visual-line.lisp new file mode 100644 index 000000000..88a6a210a --- /dev/null +++ b/tests/visual-line.lisp @@ -0,0 +1,49 @@ +(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)) + +(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." + (fold-region (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))))) \ No newline at end of file From 3b67950942c57c4269928b1413f131a8f05a06c7 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Tue, 30 Jun 2026 22:48:21 +0300 Subject: [PATCH 06/10] make overlay enter/exit behavior customizable --- src/commands/move.lisp | 102 +++++++++++++++++-------- src/display/logical-line.lisp | 72 +++++++++++++++--- src/ext/language-mode.lisp | 7 +- src/internal-packages.lisp | 6 +- tests/visual-line.lisp | 138 +++++++++++++++++++++++++++++++++- 5 files changed, 278 insertions(+), 47 deletions(-) diff --git a/src/commands/move.lisp b/src/commands/move.lisp index 385d99079..da7d448a4 100644 --- a/src/commands/move.lisp +++ b/src/commands/move.lisp @@ -49,40 +49,76 @@ (defvar *cursor-positions-before-command* (make-hash-table :test 'eq) - "maps each cursor point to its absolute buffer position before the running -command, so `snap-cursors-out-of-invisible' can tell which direction it moved.") + "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.") -(add-hook *pre-command-hook* 'record-cursor-positions) -(add-hook *post-command-hook* 'snap-cursors-out-of-invisible) +(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 record-cursor-positions () - "snapshot every cursor's position so a later snap can pick the visible edge on the far side -of a fold the cursor moves into." +(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)))) + (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 snap-cursors-out-of-invisible () - "a fold collapses its range, so no cursor may rest within it or at its start. move any such -cursor to the nearest visible position in its direction of travel, so the cursor never appears -stuck on hidden text." +(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 ((overlay (invisible-overlay-covering point))) - (when overlay - (let ((forwardp (let ((before (gethash point *cursor-positions-before-command*))) - (or (null before) - (<= before (position-at-point (overlay-start overlay))))))) - (loop :for ov := (invisible-overlay-covering point) - :while ov - :do (if forwardp - (move-point point (overlay-end ov)) - (progn - (move-point point (overlay-start ov)) - ;; dont continue looping if we are at the beginning of the buffer. - ;; it could cause an endless loop. - (unless (character-offset point -1) - (return)))))))))) + (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." @@ -101,10 +137,16 @@ stuck on hidden text." (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-p point) + (loop :while (line-continuation-to-skip point) :do (unless (line-offset point direction) (return-from skip-invisible-lines nil))) point) @@ -116,7 +158,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." (loop :repeat steps :do (unless (move-to-next-virtual-line point dir) (return-from move-to-next-visible-virtual-line nil)) - (when (line-continuation-p point) + (when (line-continuation-to-skip point) (unless (skip-invisible-lines point dir) (return-from move-to-next-visible-virtual-line nil)))) point)) @@ -128,7 +170,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." (loop :repeat steps :do (unless (line-offset point dir) (return-from visible-line-offset nil)) - (when (line-continuation-p point) + (when (line-continuation-to-skip point) (unless (skip-invisible-lines point dir) (return-from visible-line-offset nil)))) point)) diff --git a/src/display/logical-line.lisp b/src/display/logical-line.lisp index e51777f65..b6f4fbffc 100644 --- a/src/display/logical-line.lisp +++ b/src/display/logical-line.lisp @@ -133,20 +133,71 @@ visual line each splice text out." (point< point (overlay-end overlay)) overlay))) -(defun fold-region (start end &optional (fold-marker "...")) +(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." +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)) - (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)) + (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 fold-marker 'fold-attribute)) + (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) @@ -313,8 +364,9 @@ several folds that each hide arbitrary character ranges across multiple buffer l ;; groups them without affecting this order. (loop :for overlay :in (stable-sort (loop :for overlay :in overlays - :when (or (overlay-get overlay :before-string) - (overlay-get overlay :after-string)) + :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))) diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index d417adda9..070628741 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -146,9 +146,10 @@ (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))) - (fold-region start end) - (move-point point start) - t)))) + (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 diff --git a/src/internal-packages.lisp b/src/internal-packages.lisp index 29e80c378..8a6e8b04e 100644 --- a/src/internal-packages.lisp +++ b/src/internal-packages.lisp @@ -665,7 +665,11 @@ :make-region-overlays-using-global-mode :line-continuation-p :invisible-overlay-covering - :fold-region) + :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/tests/visual-line.lisp b/tests/visual-line.lisp index 88a6a210a..45b576e87 100644 --- a/tests/visual-line.lisp +++ b/tests/visual-line.lisp @@ -15,14 +15,27 @@ (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." - (fold-region (point-at-line buffer first) - (point-at-line buffer (1+ last)))) + (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 @@ -46,4 +59,123 @@ (expect-visual-line 2 2 2) (expect-visual-line 3 3 4) (expect-visual-line 4 3 4) - (expect-visual-line 5 5 5))))) \ No newline at end of file + (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 From 46c45736a453aa26f1e2b5fe67dd8ba5e854fd2a Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Wed, 1 Jul 2026 23:21:57 +0300 Subject: [PATCH 07/10] fold only when on first line of defun --- src/ext/language-mode.lisp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index 070628741..af6045534 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -145,7 +145,9 @@ "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))) + (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) From 0a7868495da9e0216399f741093861d4da81585a Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Fri, 3 Jul 2026 17:06:55 +0300 Subject: [PATCH 08/10] fix scrolling issue when moving the cursor down there was an issue where the screen randomly jumps a few lines down --- src/window/virtual-line.lisp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/window/virtual-line.lisp b/src/window/virtual-line.lisp index 3d314759b..d71d36bf5 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) From 258aa2af473cbcbd681149dcc3d18a1addcd7298 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Wed, 8 Jul 2026 22:22:36 +0300 Subject: [PATCH 09/10] add enable-tab-fold variable to customize tab behavior --- src/ext/language-mode.lisp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index af6045534..08935b574 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -54,8 +54,6 @@ (define-editor-variable idle-function nil) (define-editor-variable beginning-of-defun-function nil) (define-editor-variable end-of-defun-function nil) -(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.") (define-editor-variable line-comment nil) (define-editor-variable insertion-line-comment nil) (define-editor-variable find-definitions-function nil) @@ -65,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)) @@ -163,7 +168,8 @@ nothing to fold." (define-command fold-or-indent-or-complete () () "fold or unfold the defun at point. otherwise indent and complete the symbol." - (unless (fold-toggle-at-point) + (unless (and (variable-value 'enable-tab-fold) + (fold-toggle-at-point)) (indent-line-and-complete-symbol))) (define-command unfold-all () () From 82fecb515f555c28456c01dd816e545a0e2f9a11 Mon Sep 17 00:00:00 2001 From: mahmoodsheikh36 Date: Wed, 8 Jul 2026 22:33:11 +0300 Subject: [PATCH 10/10] address feedback on docstring conventions --- extensions/vi-mode/commands/utils.lisp | 2 +- src/commands/move.lisp | 22 +++++++++++----------- src/display/logical-line.lisp | 16 ++++++++-------- src/ext/language-mode.lisp | 16 ++++++++-------- src/window/virtual-line.lisp | 4 ++-- tests/visual-line.lisp | 6 +++--- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/extensions/vi-mode/commands/utils.lisp b/extensions/vi-mode/commands/utils.lisp index 4018243c1..94f31aa4f 100644 --- a/extensions/vi-mode/commands/utils.lisp +++ b/extensions/vi-mode/commands/utils.lisp @@ -61,7 +61,7 @@ (1- len))))) (defun fall-within-line (point) - "clamps the given point to the character before the newline at the end of the line its in." + "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))) diff --git a/src/commands/move.lisp b/src/commands/move.lisp index da7d448a4..273a7158e 100644 --- a/src/commands/move.lisp +++ b/src/commands/move.lisp @@ -49,19 +49,19 @@ (defvar *cursor-positions-before-command* (make-hash-table :test 'eq) - "maps each cursor point to its absolute buffer position before the running command, so + "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, + "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 + "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*) @@ -73,7 +73,7 @@ and in which direction." (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 + "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*))) @@ -98,7 +98,7 @@ repositions POINT so that overlays it is then pushed into also fire." (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 + "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)))) @@ -107,7 +107,7 @@ command, each called as (FUNCTION point overlay direction)." (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 + "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)) @@ -121,7 +121,7 @@ position is unknown or unchanged)." (run-overlay-cursor-leave-functions point direction)))) (defun visual-line-end (point) - "move POINT to the end of its visual line. returns 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) @@ -130,7 +130,7 @@ position is unknown or unchanged)." point) (defun visual-line-beginning (point) - "move POINT to the start of its visual line. returns 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) @@ -144,7 +144,7 @@ position is unknown or unchanged)." overlay))) (defun skip-invisible-lines (point direction) - "move POINT past any fully invisible lines in DIRECTION (1 or -1). + "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) @@ -152,7 +152,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." point) (defun move-to-next-visible-virtual-line (point n) - "like `move-to-next-virtual-line' but skips fully invisible lines." + "Like `move-to-next-virtual-line' but skips fully invisible lines." (let ((dir (if (plusp n) 1 -1)) (steps (abs n))) (loop :repeat steps @@ -164,7 +164,7 @@ returns POINT on success, or NIL if a buffer boundary is reached." point)) (defun visible-line-offset (point n) - "like `line-offset' but skips fully invisible lines." + "Like `line-offset' but skips fully invisible lines." (let ((dir (if (plusp n) 1 -1)) (steps (abs n))) (loop :repeat steps diff --git a/src/display/logical-line.lisp b/src/display/logical-line.lisp index b6f4fbffc..b2a043e0c 100644 --- a/src/display/logical-line.lisp +++ b/src/display/logical-line.lisp @@ -105,7 +105,7 @@ (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. + "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." @@ -126,7 +126,7 @@ visual line each splice text out." (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." + "Return the :invisible overlay covering POINT." (loop :for overlay :in overlays :thereis (and (overlay-get overlay :invisible) (point<= (overlay-start overlay) point) @@ -134,7 +134,7 @@ visual line each splice text out." 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 + "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) @@ -154,7 +154,7 @@ default so a cursor never appears stuck on the hidden text of a fold." (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." + "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))) @@ -163,7 +163,7 @@ default so a cursor never appears stuck on the hidden text of a fold." (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." + "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) @@ -172,7 +172,7 @@ default so a cursor never appears stuck on the hidden text of a fold." ;; 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. + "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 @@ -201,7 +201,7 @@ callers can also set the overlay's :cursor-enter-functions / :cursor-leave-funct overlay))) (defun line-continuation-p (point) - "whether POINT's line continues a previous visual line. meaning the newline preceding it is + "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." @@ -231,7 +231,7 @@ buffer lines into one displayed line." (nreverse attributes))))) (defun create-logical-line (point overlays active-modes) - "build a logical-line for the visual line starting at POINT, joining any following buffer lines + "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 diff --git a/src/ext/language-mode.lisp b/src/ext/language-mode.lisp index 08935b574..e8d94bf80 100644 --- a/src/ext/language-mode.lisp +++ b/src/ext/language-mode.lisp @@ -65,11 +65,11 @@ (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 + "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.") + "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)) @@ -126,7 +126,7 @@ (beginning-of-defun-1 (- n))))) (defun fold-region-default (point) - "return (values start end) spanning the defun at POINT, or NIL." + "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) @@ -139,7 +139,7 @@ (values start end)))))) (defun fold-overlay-at (point) - "the fold overlay whose header line is POINT's line, or NIL." + "The fold overlay whose header line is POINT's line, or NIL." (find-if (lambda (overlay) (and (overlay-get overlay :fold) @@ -147,7 +147,7 @@ (buffer-overlays (point-buffer point)))) (defun fold-defun-at (point) - "fold the defun at POINT. Returns T when something was folded." + "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 @@ -159,7 +159,7 @@ 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 + "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) @@ -167,13 +167,13 @@ nothing to fold." (t nil)))) (define-command fold-or-indent-or-complete () () - "fold or unfold the defun at point. otherwise indent and complete the symbol." + "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." + "Remove every fold in the current buffer." (dolist (overlay (copy-list (buffer-overlays))) (when (overlay-get overlay :fold) (delete-overlay overlay)))) diff --git a/src/window/virtual-line.lisp b/src/window/virtual-line.lisp index d71d36bf5..d5b4bc96d 100644 --- a/src/window/virtual-line.lisp +++ b/src/window/virtual-line.lisp @@ -96,7 +96,7 @@ next line because it is at the end of width." offset))) (defun count-hidden-lines (start-point end-point) - "number of hidden buffer lines between START-POINT and 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) @@ -112,7 +112,7 @@ next line because it is at the end of width." :finally (return count)))) (defun window-cursor-y-not-wrapping (window) - "number of screen rows between the view point and the cursor. + "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)) diff --git a/tests/visual-line.lisp b/tests/visual-line.lisp index 45b576e87..619915bb0 100644 --- a/tests/visual-line.lisp +++ b/tests/visual-line.lisp @@ -9,7 +9,7 @@ (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." + "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) @@ -32,14 +32,14 @@ (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." + "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" + "Buffer line ~D belongs to the visual line spanning lines ~D..~D" containing-line first-line last-line)