Class: Canopus::MultiBuffer

Inherits:
Buffer
  • Object
show all
Defined in:
lib/canopus/multi_buffer.rb,
sig/services.rbs

Overview

Editable, nonoverlapping source excerpts with protected headings.

Defined Under Namespace

Classes: Excerpt

Instance Attribute Summary collapse

Attributes inherited from Buffer

#bom, #detection, #disk_digest, #encoding, #history, #line_ending, #newline, #notification_errors, #path, #read_only, #rope, #selections, #version

Instance Method Summary collapse

Methods inherited from Buffer

#anchor, #convert_line_endings, #copy_anchor, decode_bytes, #ensure_final_newline, #line, #line_count, #mixed_line_endings?, #on_edit, open, #persistent_undo_record, #release_anchor, #reload, #relocate, #resolve, #restore_persistent_undo!, #roundtrip?, #text, #trim_trailing_whitespace

Constructor Details

#initialize(excerpts: []) ⇒ MultiBuffer

Returns a new instance of MultiBuffer.

Parameters:

  • excerpts: (Array[[Buffer, Range[Integer]] | [Buffer, Range[Integer], String?]]) (defaults to: [])


8
9
10
11
12
13
14
15
16
# File 'lib/canopus/multi_buffer.rb', line 8

def initialize(excerpts: [])
  super("")
  @excerpts, @source_subscriptions, @multi_undo, @multi_redo = [], {}, [], []
  validate_excerpt_ranges(excerpts)
  @building = true
  excerpts.each { |buffer, range, title| add_excerpt(buffer, range, title: title) }
  @building = false
  refresh
end

Instance Attribute Details

#excerpts ⇒ Array[Excerpt] (readonly)

Returns the value of attribute excerpts.

Returns:



7
8
9
# File 'lib/canopus/multi_buffer.rb', line 7

def excerpts
  @excerpts
end

Instance Method Details

#add_excerpt(buffer, range, title: nil) ⇒ self

Parameters:

  • buffer (Buffer)
  • range (Range[Integer])
  • title: (String, nil) (defaults to: nil)

Returns:

  • (self)

Raises:



17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/canopus/multi_buffer.rb', line 17

def add_excerpt(buffer, range, title: nil)
  raise Error, "excerpt source must be a regular buffer" unless buffer.is_a?(Buffer) && !buffer.is_a?(MultiBuffer)
  ending = range.end + (range.exclude_end? ? 0 : 1)
  buffer.rope.byteslice(range.begin, ending - range.begin)
  overlap = !@building && @excerpts.any? do |excerpt|
    excerpt.buffer.equal?(buffer) && range.begin <= buffer.resolve(excerpt.last) && ending >= buffer.resolve(excerpt.first)
  end
  raise Error, "excerpts of the same buffer must be disjoint and nonadjacent" if overlap
  label = title || "#{buffer.path || 'Untitled'}:#{buffer.rope.point_at(range.begin).row + 1}"
  @excerpts << Excerpt.new(buffer, buffer.anchor(range.begin, bias: :left), buffer.anchor(ending, bias: :right), label.to_s.freeze)
  @source_subscriptions[buffer] ||= buffer.on_edit { refresh unless @syncing }
  refresh unless @building
  self
end

#attach_sources(replacements) ⇒ self

Attach a privately prepared projection to live sources on the foreground thread. Source ropes must be the exact immutable snapshots used to build it; caller additionally validates workspace identity/version before this. No text rebuild, regex scan or disk IO occurs here.

Parameters:

Returns:

  • (self)


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/canopus/multi_buffer.rb', line 36

def attach_sources(replacements)
  raise Error, "cannot attach an edited projection" unless @multi_undo.empty? && @multi_redo.empty?
  return self if replacements.empty?
  replacements.each do |source, target|
    raise Error, "unknown excerpt source" unless @source_subscriptions.key?(source)
    raise Error, "excerpt source must be a regular buffer" unless target.is_a?(Buffer) && !target.is_a?(MultiBuffer)
    raise Error, "source buffer is read-only" if target.read_only
    raise Error, "excerpt source snapshot changed" unless source.rope.equal?(target.rope)
  end
  targets = @source_subscriptions.keys.map { |source| replacements.fetch(source, source) }
  raise Error, "excerpt source aliases would overlap" unless targets.uniq.length == targets.length
  anchors = []
  subscriptions = {}
  replacements.each do |source, target|
    subscriptions[target] = target.on_edit { refresh unless @syncing } unless source.equal?(target)
  end
  @excerpts.each do |excerpt|
    target = replacements[excerpt.buffer]
    next unless target && !target.equal?(excerpt.buffer)
    entry = [excerpt, target, target.copy_anchor(excerpt.buffer, excerpt.first)]
    anchors << entry
    entry << target.copy_anchor(excerpt.buffer, excerpt.last)
  end
  committed = true
  anchors.each do |excerpt, target, first, last|
    excerpt.buffer.release_anchor(excerpt.first)
    excerpt.buffer.release_anchor(excerpt.last)
    excerpt.buffer, excerpt.first, excerpt.last = target, first, last
  end
  @source_subscriptions = @source_subscriptions.to_h do |source, subscription|
    target = replacements.fetch(source, source)
    if source.equal?(target)
      [source, subscription]
    else
      subscription.detach
      [target, subscriptions.fetch(target)]
    end
  end
  self
rescue StandardError
  unless committed
    subscriptions&.each_value(&:detach)
    anchors&.each do |_, target, first, last|
      target.release_anchor(first)
      target.release_anchor(last) if last
    end
  end
  raise
end

#begin_undo_group ⇒ self

Returns:

  • (self)


86
87
88
89
90
# File 'lib/canopus/multi_buffer.rb', line 86

def begin_undo_group
  @multi_undo_start = @multi_undo.length unless @undo_group_depth&.positive?
  @excerpts.map(&:buffer).uniq.each(&:begin_undo_group)
  super
end

#close ⇒ void

This method returns an undefined value.



181
182
183
184
185
186
187
# File 'lib/canopus/multi_buffer.rb', line 181

def close
  @source_subscriptions.each_value(&:detach)
  @excerpts.each do |excerpt|
    excerpt.buffer.release_anchor(excerpt.first)
    excerpt.buffer.release_anchor(excerpt.last)
  end
end

#dirty? ⇒ Boolean

Returns:

  • (Boolean)


85
# File 'lib/canopus/multi_buffer.rb', line 85

def dirty? = @excerpts.any? { |excerpt| excerpt.buffer.dirty? }

#edit(changes, **options) ⇒ patch?

Parameters:

  • changes (changes)
  • kind: (Symbol)
  • selections: (Array[Selection])
  • before_selections: (Array[Selection])
  • time: (Numeric)
  • group: (Boolean)

Returns:

  • (patch, nil)


95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/canopus/multi_buffer.rb', line 95

def edit(changes, **options)
  return super if @refreshing
  return if changes.empty?
  @rope.apply_edits(changes)
  grouped = changes.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(range, text), result|
    ending = range.end + (range.exclude_end? ? 0 : 1)
    excerpt = @excerpts.find { |item| range.begin >= item.view_start && ending <= item.view_end }
    raise Error, "search-result headings cannot be edited" unless excerpt
    raise Error, "source buffer is read-only" if excerpt.buffer.read_only
    start = excerpt.buffer.resolve(excerpt.first)
    result[excerpt.buffer] << [(start + range.begin - excerpt.view_start)...(start + ending - excerpt.view_start), text]
  end
  patches = grouped.to_h do |buffer, edits|
    [buffer, Patch.new(buffer.rope, buffer.rope.apply_edits(edits), edits)]
  end
  checkpoints = grouped.keys.to_h { |buffer| [buffer, buffer.send(:edit_checkpoint)] }
  before = grouped.keys.to_h { |buffer| [buffer, buffer.rope] }
  @syncing = true
  applied = []
  begin
    grouped.each do |buffer, edits|
      begin
        buffer.edit(edits, kind: :multi_buffer)
      ensure
        # A source may mutate and then raise. Conversely a pre-commit
        # failure must never undo an unrelated previous source edit.
        applied << buffer unless buffer.rope.equal?(before.fetch(buffer))
      end
    end
    result = super(changes, **options)
    rebuild_text
    after = grouped.keys.to_h { |buffer| [buffer, buffer.rope] }
    if @undo_group_depth&.positive? && @multi_undo.length > @multi_undo_start
      previous = @multi_undo.last
      previous[0] = before.merge(previous.first)
      previous[1] = previous.last.merge(after)
    else
      @multi_undo << [before, after]
    end
    @multi_redo.clear
    result
  rescue StandardError
    applied.reverse_each do |buffer|
      buffer.send(:restore_edit, checkpoints.fetch(buffer), patches.fetch(buffer).inverse)
    rescue StandardError
      # Preserve the original application error, and still restore the
      # other sources. Notification failures are isolated by Buffer.
    end
    raise
  ensure
    @syncing = false
  end
end

#end_undo_group ⇒ Array[Buffer]

Returns:



91
92
93
94
# File 'lib/canopus/multi_buffer.rb', line 91

def end_undo_group
  super
  @excerpts.map(&:buffer).uniq.each(&:end_undo_group)
end

#redo ⇒ Boolean

Returns:

  • (Boolean)


162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/canopus/multi_buffer.rb', line 162

def redo
  raise Error, "end the undo group before redo" if @undo_group_depth&.positive?
  group = @multi_redo.last
  return false unless group
  raise Error, "source changed since this excerpt edit" unless group.first.all? { |buffer, snapshot| buffer.rope.equal?(snapshot) }
  @syncing = true
  group.first.keys.each(&:redo)
  super
  @multi_undo << @multi_redo.pop
  rebuild_text
  true
ensure
  @syncing = false
end

#save(path = nil, force: false) ⇒ self

Parameters:

  • path (String, nil) (defaults to: nil)
  • force: (Boolean) (defaults to: false)

Returns:

  • (self)

Raises:



176
177
178
179
180
# File 'lib/canopus/multi_buffer.rb', line 176

def save(path = nil, force: false)
  raise Error, "Save excerpt files individually to choose new paths" if path
  @excerpts.map(&:buffer).uniq.each { |buffer| buffer.save(force: force) if buffer.dirty? }
  self
end

#undo ⇒ Boolean

Returns:

  • (Boolean)


148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/canopus/multi_buffer.rb', line 148

def undo
  raise Error, "end the undo group before undo" if @undo_group_depth&.positive?
  group = @multi_undo.last
  return false unless group
  raise Error, "source changed since this excerpt edit" unless group.last.all? { |buffer, snapshot| buffer.rope.equal?(snapshot) }
  @syncing = true
  group.last.keys.reverse_each(&:undo)
  super
  @multi_redo << @multi_undo.pop
  rebuild_text
  true
ensure
  @syncing = false
end