Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/design/acme-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Acme-inspired Look and Execute experiment

LegMacs can explore Acme's text-as-interface model without turning the whole
editor into an Acme clone and without precluding a future Dired mode. The
first experiments are optional keyboard-driven `Look` and `Execute` actions.

## First slice

`look-at-point` obtains a target from the active region, or from a
filename-like token touching point when there is no selection. It interprets
relative targets in the current buffer's `:default-directory`.

- An existing regular file opens through LegMacs's normal file/buffer path.
- An existing directory opens as a plain writable text buffer containing one
entry per line; subdirectories carry a trailing slash.
- Missing text reports an error. Look never creates a new file.
- Re-looking an open file or directory reuses its buffer.
- Directory-buffer edits are only text edits and never mutate the filesystem.

The directory listing has no special major mode and suppresses no ordinary
editing keys. This is the intentional contrast with Dired: it is contextual
text on which the same global Look action can operate.

## Optional loading

The extension is inert unless a user loads it. Requiring its namespace makes
`look-at-point` and `execute-at-point` available through `M-x`; calling
`enable!` additionally binds F7 to Execute and F8 to Look:

```clojure
(ns legmacs-init
(:require [legmacs.extensions.acme :as acme]))

(acme/enable!) ; F7 Execute, F8 Look
```

## Directory context

Every buffer may carry `:default-directory`, analogous to Acme's window
directory and Emacs's buffer-local `default-directory`:

- file buffers use the file's containing directory;
- directory listings use the listed directory;
- generated buffers can inherit the originating buffer's context;
- buffers without an explicit value fall back to their filename or the
editor process directory.

This context is deliberately useful outside the extension: a future shell
command, compiler-output buffer, Dired mode, or REPL can use the same field.

## Deferred behavior

The first slice does not implement Acme addresses (`file.c:27`), literal-text
search, include lookup, plumber fallback, automatic filesystem mutation, or
Dired operations. Those should be separate additions
after the basic Look workflow has been tested interactively.

Display policy is also separate from target resolution. This first command
uses the active LegMacs window. A future `look-at-point-other-window` can
split or focus another window without changing path recognition.

## Execute slice

`execute-at-point` is the keyboard analogue of Acme button 2 and is bound to
F7 by `enable!`. It uses the same text expansion as Look: a non-empty active
region wins; otherwise the conservative filename-like token touching point is
the command. This matches the expansion documented by 9front release 11554's
`acme(1)` and the Acme paper.

The command is evaluated by `rc -p -c` in the originating buffer's
`:default-directory`. On Plan 9, `-p` prevents a new rc from importing
CPU-session `/env/fn#*` entries itself. The script creates a private
environment group with `rfork e` and removes copied `fn#*` entries there, so
nested rc scripts also start cleanly without changing LegMacs' environment.
It then establishes `path=(. /bin)` for Acme-like local-first lookup. A safely
quoted `cd` prefix gives the child its context without changing the LegMacs
process directory. Standard input is not connected; stdout and stderr are
captured after the command completes.

Each directory has one ordinary writable buffer named `dir/+Errors`. F7
opens or focuses that buffer, and later executions from the same directory
append a blank-line-separated block containing the command and its output.
Edits in this buffer are only text edits.

This is deliberately a synchronous first slice: LegMacs pauses while the
command runs. It is intended initially for short commands such as `pwd`,
`ls`, `ps`, and pipelines. Asynchronous jobs, live streaming, interactive
stdin, cancellation, process status, and exact stdout/stderr interleaving are
deferred until this basic interaction has been exercised on Plan 9.
10 changes: 10 additions & 0 deletions examples/legmacs_acme_init.example.lg
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
;; Minimal opt-in for the Acme-inspired Look and Execute experiment.
;;
;; Copy this to ~/.config/legmacs/legmacs_init.lg, or merge the require and
;; enable! call into an existing config. The extension registers its commands
;; when required; enable! adds the default F7 Execute and F8 Look bindings.

(ns legmacs-init
(:require [legmacs.extensions.acme :as acme]))

(acme/enable!)
1 change: 1 addition & 0 deletions legmacs/buffer.lg
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
:scroll-col 0
:goal-col nil
:filename filename
:default-directory nil
:buffer-name nil
:modified? false
:message nil
Expand Down
32 changes: 31 additions & 1 deletion legmacs/buffers.lg
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
;; showing it resumes there). Window commands are just more :buffer-command
;; ops: {:op :split :dir :below}, {:op :other-window}, {:op :delete-window},
;; {:op :delete-other-windows}. The buffer-switching ops (:switch,
;; :open-new, :next, :prev, :help) now mean "in the active window";
;; :open-new, :next, :prev, :help, :append-named) now mean "in the active window";
;; :kill also re-points any window that showed the killed buffer.
;;
;; :current (the active buffer id) is kept in sync with the active
Expand Down Expand Up @@ -178,6 +178,22 @@
(update :next-id inc))
id]))

(defn- appended-buffer-state
"Append `addition`'s text to an existing generated buffer, separated by a
blank line, and leave point at the end. Process output is not a user edit:
the existing buffer's modified and undo state are deliberately preserved."
[existing addition]
(let [old-lines (:lines existing)
new-lines (:lines addition)
old-lines (if (= [""] old-lines) [] old-lines)
lines (vec (concat old-lines (when (seq old-lines) [""]) new-lines))
row (dec (count lines))]
(assoc existing
:lines lines
:cursor-row row
:cursor-col (count (nth lines row))
:goal-col nil)))

(defn- apply-buffer-command
"Apply one :buffer-command to the workspace. The buffer-switching ops
act on the active window; the window ops reshape the tree itself."
Expand All @@ -187,6 +203,20 @@
:switch (set-window-buffer workspace awin (:id cmd))
:open-new (let [[ws id] (add-buffer workspace (:state cmd))]
(set-window-buffer ws awin id))
;; A named, editable output buffer that accumulates blocks over time.
;; The Acme extension uses this for directory-scoped +Errors buffers,
;; but the operation is intentionally generic process-output plumbing.
:append-named
(if-let [existing (some (fn [id]
(when (= (:name cmd)
(:buffer-name (get-in workspace [:buffers id])))
id))
(:order workspace))]
(-> workspace
(update-in [:buffers existing] appended-buffer-state (:state cmd))
(set-window-buffer awin existing))
(let [[ws id] (add-buffer workspace (:state cmd))]
(set-window-buffer ws awin id)))
;; after the kill, every window that showed the dead buffer gets
;; re-pointed at whatever buffer kill-buffer-op picked as current
;; (or the fresh fallback) -- a window is never left on a buffer
Expand Down
39 changes: 31 additions & 8 deletions legmacs/commands.lg
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
(:require [string]
[os]
[legmacs.buffer :as buf]
[legmacs.path :as path]
[legmacs.keymap :as km :refer [defcommand]]
[legmacs.minibuffer :as mb]
[legmacs.completion :as completion]
Expand Down Expand Up @@ -419,7 +420,15 @@
(defn write-buffer-to [state path]
(spit path (buf/join-lines (:lines state)))
(-> state
(assoc :filename path :modified? false)
;; Saving a named/generated buffer turns it into an ordinary file
;; buffer. In particular, saving an editable Acme directory listing
;; must not leave the directory's display name or generated-kind marker
;; attached to the newly written file.
(assoc :filename path
:default-directory (path/directory-of path)
:buffer-name nil
:modified? false)
(dissoc :directory-buffer?)
(set-message (str "Wrote " path))))

(defcommand save-buffer
Expand Down Expand Up @@ -464,9 +473,14 @@
file with. [state message] -- message says whether it was loaded or
is a new file, for callers that want to show it."
[path]
(if-let [content (try (slurp path) (catch e nil))]
[(with-auto-mode (buf/new-state path (buf/split-lines content)) path) (str "Loaded " path)]
[(with-auto-mode (buf/new-state path [""]) path) (str "New file " path)]))
(let [directory (path/directory-of path)]
(if-let [content (try (slurp path) (catch e nil))]
[(assoc (with-auto-mode (buf/new-state path (buf/split-lines content)) path)
:default-directory directory)
(str "Loaded " path)]
[(assoc (with-auto-mode (buf/new-state path [""]) path)
:default-directory directory)
(str "New file " path)])))

(defcommand find-file
"Open a file in a new buffer, or switch to it if it's already open --
Expand Down Expand Up @@ -530,7 +544,10 @@
(if-let [existing (some #(when (= (:name %) name) %) (:buffer-list state))]
(assoc state :buffer-command {:op :switch :id (:id existing)})
(assoc state :buffer-command
{:op :open-new :state (assoc (buf/new-state nil [""]) :buffer-name name)}))))
{:op :open-new
:state (assoc (buf/new-state nil [""])
:buffer-name name
:default-directory (path/default-directory state))}))))
{:submit-selected? true
:completer (fn [input]
(completion/fuzzy-filter input (map :name (:buffer-list state))))}))
Expand All @@ -544,7 +561,10 @@
[state]
(if-let [existing (some #(when (= (:name %) "*repl*") %) (:buffer-list state))]
(assoc state :buffer-command {:op :switch :id (:id existing)})
(assoc state :buffer-command {:op :open-new :state (repl/new-repl-state)})))
(assoc state :buffer-command
{:op :open-new
:state (assoc (repl/new-repl-state)
:default-directory (path/default-directory state))})))

(defcommand list-buffers
"Show every open buffer: name, modified flag, mode."
Expand All @@ -559,7 +579,9 @@
[state]
(let [do-kill (fn [state]
(assoc state :buffer-command
{:op :kill :id (:current-buffer-id state) :fallback (buf/new-state nil [""])}))]
{:op :kill :id (:current-buffer-id state)
:fallback (assoc (buf/new-state nil [""])
:default-directory (path/default-directory state))}))]
(if (:modified? state)
(ask-yes-or-no state "Buffer modified; kill anyway? (y/n) " do-kill (fn [state] (set-message state "")))
(do-kill state))))
Expand Down Expand Up @@ -714,7 +736,8 @@
[state name lines]
(let [buf (-> (buf/new-state nil (vec lines))
(modes/switch-to-mode :help)
(assoc :buffer-name name :read-only? true))]
(assoc :buffer-name name :read-only? true
:default-directory (path/default-directory state)))]
(assoc state :buffer-command {:op :help :name name :state buf})))

(defcommand describe-bindings
Expand Down
Loading
Loading