630 lines
21 KiB
Ruby
630 lines
21 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Vamp
|
|
# @param name [String, Symbol]
|
|
# @return [String]
|
|
def self.chrome_name!(name)
|
|
name = name.to_s if name.is_a?(Symbol)
|
|
name = string!(name, "label/segment name")
|
|
unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
|
|
raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*"
|
|
end
|
|
name
|
|
end
|
|
|
|
# @param value [Hash{String, Symbol => Object}]
|
|
# @return [Hash{String => Object}]
|
|
def self.chrome_hash!(value)
|
|
raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash)
|
|
value.each_with_object({}) do |(key, item), result|
|
|
key = key.to_s if key.is_a?(Symbol)
|
|
key = string!(key, "chrome key")
|
|
raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key)
|
|
result[key] = item
|
|
end
|
|
end
|
|
|
|
# @param options [Hash{String, Symbol => Object}]
|
|
# @return [Hash{String => Object}]
|
|
def self.chrome_line!(options)
|
|
chrome_hash!(options).to_h do |name, value|
|
|
if %w[left center right].include?(name)
|
|
raise TypeError, "#{name} must be an Array" unless value.is_a?(Array)
|
|
[name, value.map { |item| chrome_name!(item) }]
|
|
else
|
|
chrome_name!(name)
|
|
style = chrome_hash!(value).to_h do |option, item|
|
|
case option
|
|
when "padding", "margin", "leading_margin", "trailing_margin"
|
|
unless item.is_a?(Integer) && item.between?(0, 64)
|
|
raise ArgumentError, "#{option} must be an Integer between 0 and 64"
|
|
end
|
|
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
|
|
item = string!(item, option)
|
|
else
|
|
raise ArgumentError, "unknown chrome style option: #{option}"
|
|
end
|
|
[option, item]
|
|
end
|
|
[name, style]
|
|
end
|
|
end
|
|
end
|
|
|
|
# Collects options for the independently configured top and bottom bars.
|
|
class BarConfig
|
|
# @return [Hash{String => Object}]
|
|
attr_reader :top, :bottom
|
|
|
|
# @return [void]
|
|
def initialize
|
|
@top = {}
|
|
@bottom = {}
|
|
end
|
|
|
|
# @param options [Hash{String, Symbol => Object}]
|
|
# @return [Hash{String => Object}]
|
|
def top=(options)
|
|
@top = Vamp.chrome_line!(options)
|
|
end
|
|
|
|
# @param options [Hash{String, Symbol => Object}]
|
|
# @return [Hash{String => Object}]
|
|
def bottom=(options)
|
|
@bottom = Vamp.chrome_line!(options)
|
|
end
|
|
end
|
|
|
|
# Collects anchor options for the independently positioned command and search popups.
|
|
class PopupConfig
|
|
# @return [Hash{String => String}]
|
|
attr_reader :command, :search
|
|
|
|
# @return [void]
|
|
def initialize
|
|
@command = {}
|
|
@search = {}
|
|
end
|
|
|
|
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
|
# @return [Hash{String => String}]
|
|
def command=(options)
|
|
@command = options!(options)
|
|
end
|
|
|
|
# @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid
|
|
# @return [Hash{String => String}]
|
|
def search=(options)
|
|
@search = options!(options)
|
|
end
|
|
|
|
private
|
|
|
|
# @param options [Hash{String, Symbol => String, Symbol}]
|
|
# @return [Hash{String => String}]
|
|
def options!(options)
|
|
Vamp.chrome_hash!(options).to_h do |option, value|
|
|
raise ArgumentError, "unknown popup option: #{option}" unless option == "anchor"
|
|
value = value.to_s if value.is_a?(Symbol)
|
|
value = Vamp.string!(value, "popup anchor")
|
|
unless %w[top_left top_center top_right center_left center center_right bottom_left bottom_center bottom_right].include?(value)
|
|
raise ArgumentError, "unknown popup anchor: #{value}"
|
|
end
|
|
[option, value]
|
|
end
|
|
end
|
|
end
|
|
|
|
# Collects and validates the Ruby startup configuration DSL.
|
|
class Config
|
|
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
|
|
attr_reader :lsp
|
|
|
|
# @return [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
|
|
attr_reader :language_tab_spaces
|
|
|
|
# @return [Vamp::BarConfig]
|
|
attr_reader :bar
|
|
|
|
# @return [Vamp::PopupConfig]
|
|
attr_reader :popup
|
|
|
|
# @return [void]
|
|
def initialize
|
|
@plugins = []
|
|
@lsp = {}
|
|
@review_directory = nil
|
|
@bindings = []
|
|
@leader = "space"
|
|
@file_explorer_side = "right"
|
|
@file_picker_view = "tree"
|
|
@file_explorer_view = "tree"
|
|
@clipboard_mode = "system"
|
|
@git_blame = true
|
|
@git_gutter = "bar"
|
|
@bar = BarConfig.new
|
|
@popup = PopupConfig.new
|
|
@labels = {}
|
|
@scroll_distance = 0
|
|
@scroll_step_lines = nil
|
|
@highlight_trailing_whitespace = false
|
|
@whitespace_characters = {}
|
|
@whitespace_scope = "all"
|
|
@tab_spaces = nil
|
|
@language_tab_spaces = {}
|
|
@diff_context_lines = 10
|
|
@diff_expand_lines = 15
|
|
@diff_view_mode = "split"
|
|
@diff_split_orientation = "vertical"
|
|
@diff_follow_working_copy = true
|
|
@pull_request_review_requested_symbol = "🙏"
|
|
@pull_request_approved_symbol = "👍"
|
|
@resolve_history_layout = "columns"
|
|
end
|
|
|
|
# @param path [String] relative to the configuration file
|
|
# @param options [Hash{String, Symbol => Object}] JSON-compatible plugin options
|
|
# @return [nil]
|
|
def plugin(path, options: {})
|
|
path = Vamp.string!(path, "plugin path")
|
|
raise ArgumentError, "plugin path must not be empty" if path.empty? || path.include?("\0")
|
|
raise TypeError, "plugin options must be a Hash" unless options.is_a?(Hash)
|
|
|
|
@plugins << { "path" => path, "options" => Vamp.json_value!(options) }
|
|
nil
|
|
end
|
|
|
|
# @param path [String, nil] relative to the configuration file; nil uses the XDG data directory
|
|
# @return [String, nil]
|
|
def review_directory=(path)
|
|
path = nonempty_string!(path, "review_directory") unless path.nil?
|
|
raise ArgumentError, "review_directory must not contain NUL" if path&.include?("\0")
|
|
|
|
@review_directory = path
|
|
end
|
|
|
|
# @param key [String]
|
|
# @return [String]
|
|
def leader=(key)
|
|
@leader = nonempty_string!(key, "leader key")
|
|
end
|
|
|
|
# @param side [String]
|
|
# @return [String]
|
|
def file_explorer_side=(side)
|
|
@file_explorer_side = nonempty_string!(side, "file explorer side")
|
|
end
|
|
|
|
# @param view [String]
|
|
# @return [String]
|
|
def file_picker_view=(view)
|
|
@file_picker_view = nonempty_string!(view, "file picker view")
|
|
end
|
|
|
|
# @param view [String]
|
|
# @return [String]
|
|
def file_explorer_view=(view)
|
|
@file_explorer_view = nonempty_string!(view, "file explorer view")
|
|
end
|
|
|
|
# @param mode [String]
|
|
# @return [String]
|
|
def clipboard_mode=(mode)
|
|
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
|
|
end
|
|
|
|
# @param enabled [Boolean]
|
|
# @return [Boolean]
|
|
def git_blame=(enabled)
|
|
raise TypeError, "git_blame must be true or false" unless [true, false].include?(enabled)
|
|
|
|
@git_blame = enabled
|
|
end
|
|
|
|
# @param style [String] "bar", "symbols", "line_numbers", or "none"
|
|
def git_gutter=(style)
|
|
style = nonempty_string!(style, "git gutter")
|
|
raise ArgumentError, "git gutter must be bar, symbols, line_numbers, or none" unless %w[bar symbols line_numbers none].include?(style)
|
|
|
|
@git_gutter = style
|
|
end
|
|
|
|
# @param values [Hash{String, Symbol => String}]
|
|
# @return [Hash]
|
|
def labels=(values)
|
|
@labels = Vamp.chrome_hash!(values).to_h do |name, text|
|
|
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
|
|
end
|
|
end
|
|
|
|
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
|
|
# @return [Integer, nil]
|
|
def scroll_distance=(distance)
|
|
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
|
|
end
|
|
|
|
# @param lines [Integer, nil] positive screen rows, or nil for half the viewport
|
|
# @return [Integer, nil]
|
|
def scroll_step_lines=(lines)
|
|
@scroll_step_lines = lines.nil? ? nil : diff_line_count!(lines, "scroll_step_lines", 1)
|
|
end
|
|
|
|
# @param enabled [Boolean]
|
|
# @return [Boolean]
|
|
def highlight_trailing_whitespace=(enabled)
|
|
raise TypeError, "highlight_trailing_whitespace must be true or false" unless [true, false].include?(enabled)
|
|
|
|
@highlight_trailing_whitespace = enabled
|
|
end
|
|
|
|
# @param characters [Hash{String => String}] whitespace character to single-cell marker
|
|
# @return [Hash{String => String}]
|
|
def whitespace_characters=(characters)
|
|
raise TypeError, "whitespace_characters must be a Hash" unless characters.is_a?(Hash)
|
|
|
|
characters.each do |whitespace, marker|
|
|
unless [whitespace, marker].all? { |value| value.is_a?(String) && value.length == 1 }
|
|
raise TypeError, "whitespace_characters keys and values must be single-character Strings"
|
|
end
|
|
end
|
|
@whitespace_characters = characters.dup
|
|
end
|
|
|
|
# @param scope [String] "all" or "leading"
|
|
# @return [String]
|
|
def whitespace_scope=(scope)
|
|
scope = Vamp.string!(scope, "whitespace_scope")
|
|
raise ArgumentError, "whitespace_scope must be all or leading" unless %w[all leading].include?(scope)
|
|
|
|
@whitespace_scope = scope
|
|
end
|
|
|
|
# @param spaces [Integer, nil] spaces per Tab, or nil for a literal tab
|
|
# @return [Integer, nil]
|
|
def tab_spaces=(spaces)
|
|
@tab_spaces = tab_spaces!(spaces)
|
|
end
|
|
|
|
# @param languages [Hash{String => Integer, nil}] LSP-style language IDs to Tab widths
|
|
# @return [Hash{String => Integer, nil}]
|
|
def language_tab_spaces=(languages)
|
|
@language_tab_spaces = language_tab_spaces!(languages)
|
|
end
|
|
|
|
# @param lines [Integer] nonnegative context lines around each diff change
|
|
# @return [Integer]
|
|
def diff_context_lines=(lines)
|
|
@diff_context_lines = diff_line_count!(lines, "diff_context_lines", 0)
|
|
end
|
|
|
|
# @param lines [Integer] positive lines revealed per diff expansion
|
|
# @return [Integer]
|
|
def diff_expand_lines=(lines)
|
|
@diff_expand_lines = diff_line_count!(lines, "diff_expand_lines", 1)
|
|
end
|
|
|
|
# @param mode [String] "split" or "unified"
|
|
# @return [String]
|
|
def diff_view_mode=(mode)
|
|
mode = nonempty_string!(mode, "diff view mode")
|
|
raise ArgumentError, "diff view mode must be split or unified" unless %w[split unified].include?(mode)
|
|
@diff_view_mode = mode
|
|
end
|
|
|
|
# @param orientation [String] "vertical" or "horizontal"
|
|
# @return [String]
|
|
def diff_split_orientation=(orientation)
|
|
orientation = nonempty_string!(orientation, "diff split orientation")
|
|
unless %w[vertical horizontal].include?(orientation)
|
|
raise ArgumentError, "diff split orientation must be vertical or horizontal"
|
|
end
|
|
@diff_split_orientation = orientation
|
|
end
|
|
|
|
# @param enabled [Boolean]
|
|
# @return [Boolean]
|
|
def diff_follow_working_copy=(enabled)
|
|
raise TypeError, "diff_follow_working_copy must be true or false" unless [true, false].include?(enabled)
|
|
|
|
@diff_follow_working_copy = enabled
|
|
end
|
|
|
|
# @param symbol [String] empty to hide the review-request indicator
|
|
# @return [String]
|
|
def pull_request_review_requested_symbol=(symbol)
|
|
@pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol")
|
|
end
|
|
|
|
# @param symbol [String] empty to hide the approval indicator
|
|
# @return [String]
|
|
def pull_request_approved_symbol=(symbol)
|
|
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
|
|
end
|
|
|
|
# @param layout [String] "columns" or "base_below"
|
|
# @return [String]
|
|
def resolve_history_layout=(layout)
|
|
layout = nonempty_string!(layout, "resolve history layout")
|
|
unless %w[columns base_below].include?(layout)
|
|
raise ArgumentError, "resolve history layout must be columns or base_below"
|
|
end
|
|
@resolve_history_layout = layout
|
|
end
|
|
|
|
# @return [Module<Vamp::Core>] frozen namespace with catalog-generated Action methods
|
|
def core
|
|
Core
|
|
end
|
|
|
|
# @return [Module<Vamp::Modes>] frozen namespace whose methods return Mode values
|
|
def modes
|
|
Modes
|
|
end
|
|
|
|
# @param key [String] one key or a leader-prefixed sequence
|
|
# @param command [Vamp::Action, String, nil] core action, external command, or disabled route
|
|
# @param mode [Vamp::Mode]
|
|
# @return [nil]
|
|
def bind(key, command, mode: Modes.normal)
|
|
key = Vamp.string!(key, "binding key")
|
|
raise TypeError, "binding mode must be a value from config.modes" unless mode.is_a?(Mode)
|
|
|
|
command = binding_target(command)
|
|
raise ArgumentError, "binding key must not be empty" if key.empty?
|
|
raise ArgumentError, "binding command must not be empty" if command == ""
|
|
|
|
@bindings << { "mode" => mode.name, "key" => key, "command" => command }
|
|
nil
|
|
end
|
|
|
|
# @return [Hash{String => Object}] validated startup configuration frame
|
|
def response
|
|
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
|
|
"review_directory" => @review_directory,
|
|
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
|
|
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
|
|
"git_blame" => @git_blame,
|
|
"git_gutter" => @git_gutter,
|
|
"scroll_distance" => @scroll_distance,
|
|
"scroll_step_lines" => @scroll_step_lines,
|
|
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
|
"whitespace_characters" => @whitespace_characters,
|
|
"whitespace_scope" => @whitespace_scope,
|
|
"tab_spaces" => @tab_spaces, "language_tab_spaces" => language_tab_spaces!(@language_tab_spaces),
|
|
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
|
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
|
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
|
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
|
|
"diff_follow_working_copy" => @diff_follow_working_copy,
|
|
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
|
|
"pull_request_approved_symbol" => @pull_request_approved_symbol,
|
|
"resolve_history_layout" => @resolve_history_layout }
|
|
end
|
|
|
|
private
|
|
|
|
def pull_request_symbol!(symbol, label)
|
|
symbol = Vamp.string!(symbol, label)
|
|
raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/)
|
|
|
|
symbol
|
|
end
|
|
# @param spaces [Integer, nil]
|
|
# @return [Integer, nil]
|
|
def tab_spaces!(spaces)
|
|
unless spaces.nil? || (spaces.is_a?(Integer) && spaces.between?(1, 255))
|
|
raise ArgumentError, "tab_spaces must be nil or an Integer between 1 and 255"
|
|
end
|
|
|
|
spaces
|
|
end
|
|
|
|
# @param languages [Hash{String => Integer, nil}]
|
|
# @return [Hash{String => Integer, nil}]
|
|
def language_tab_spaces!(languages)
|
|
raise TypeError, "language_tab_spaces must be a Hash" unless languages.is_a?(Hash)
|
|
|
|
languages.to_h do |language, spaces|
|
|
language = nonempty_string!(language, "Tab language")
|
|
raise ArgumentError, "Tab language must not contain control characters" if language.match?(/[[:cntrl:]]/)
|
|
|
|
[language, tab_spaces!(spaces)]
|
|
end
|
|
end
|
|
|
|
# @param value [Integer]
|
|
# @param label [String]
|
|
# @param minimum [Integer]
|
|
# @return [Integer]
|
|
def diff_line_count!(value, label, minimum)
|
|
raise TypeError, "#{label} must be an Integer" unless value.is_a?(Integer)
|
|
maximum = (1 << 64) - 1
|
|
unless value.between?(minimum, maximum)
|
|
raise ArgumentError, "#{label} must be between #{minimum} and #{maximum}"
|
|
end
|
|
|
|
value
|
|
end
|
|
|
|
# @param value [String]
|
|
# @param label [String]
|
|
# @return [String]
|
|
def nonempty_string!(value, label)
|
|
value = Vamp.string!(value, label)
|
|
raise ArgumentError, "#{label} must not be empty" if value.empty?
|
|
|
|
value
|
|
end
|
|
|
|
# @param command [Vamp::Action, String, nil]
|
|
# @return [String, nil]
|
|
def binding_target(command)
|
|
case command
|
|
when Action
|
|
command.name
|
|
when String
|
|
external_command(command)
|
|
when nil
|
|
nil
|
|
else
|
|
raise TypeError, "binding command must be an Action, String, or nil"
|
|
end
|
|
end
|
|
|
|
# @param command [String]
|
|
# @return [String]
|
|
def external_command(command)
|
|
name = Vamp.string!(command, "binding command")
|
|
if name.start_with?("core.") || Vamp.builtin_command?(name)
|
|
raise TypeError, "use config.core for built-in actions"
|
|
end
|
|
|
|
name
|
|
end
|
|
|
|
# @return [Hash{String => Boolean, Hash{String => Object}}]
|
|
def lsp_options
|
|
@lsp.to_h do |language, value|
|
|
language = lsp_language(language)
|
|
[language, lsp_value(value, language)]
|
|
end
|
|
end
|
|
|
|
# @param language [String]
|
|
# @return [String]
|
|
def lsp_language(language)
|
|
language = Vamp.string!(language, "LSP language key")
|
|
invalid = language.empty? || language.match?(/[[:cntrl:]]/)
|
|
raise ArgumentError, "LSP language keys must not be empty or contain control characters" if invalid
|
|
|
|
language
|
|
end
|
|
|
|
# @param value [Boolean, Hash{String, Symbol => Object}]
|
|
# @param language [String]
|
|
# @return [Boolean, Hash{String => Object}]
|
|
def lsp_value(value, language)
|
|
case value
|
|
when TrueClass, FalseClass
|
|
value
|
|
when Hash
|
|
Vamp.json_value!(value)
|
|
else
|
|
raise TypeError, "LSP language #{language.inspect} must be true, false, or a Hash"
|
|
end
|
|
end
|
|
end
|
|
|
|
# Provides an immutable editor snapshot and queues effects for an invocation.
|
|
class Editor
|
|
# @return [String] immutable context text, uppercase mode, or workspace path
|
|
attr_reader :text, :mode, :root
|
|
# @return [Integer] one-based grapheme row/column or nonnegative document ID/revision
|
|
attr_reader :row, :column, :buffer_id, :revision
|
|
# @return [String, nil] document path; nil for an unnamed buffer
|
|
attr_reader :path
|
|
# @return [Integer] positive invocation ID
|
|
attr_reader :request_id
|
|
# @return [Array<Hash{String => String}>] queued text and presentation effects
|
|
attr_reader :effects
|
|
|
|
# @param context [Hash{String => String, Integer, nil}] primary caret/document snapshot
|
|
# @param request_id [Integer] positive invocation ID
|
|
# @return [void]
|
|
def initialize(context, request_id)
|
|
validate_context(context)
|
|
assign_context_strings(context)
|
|
assign_context_numbers(context, request_id)
|
|
validate_context_position
|
|
validate_context_revision
|
|
@effects = []
|
|
@effect_bytes = 0
|
|
end
|
|
|
|
# @param text [String]
|
|
# @return [nil]
|
|
def insert(text)
|
|
queue("insert", text)
|
|
end
|
|
|
|
# @param text [String]
|
|
# @return [nil]
|
|
def replace(text)
|
|
queue("replace", text)
|
|
end
|
|
|
|
# @param text [String]
|
|
# @return [nil]
|
|
def message(text)
|
|
queue("message", text)
|
|
end
|
|
|
|
# @param name [String, Symbol]
|
|
# @param text [String] named session-wide plain text; empty clears it
|
|
# @return [nil]
|
|
def label(name, text)
|
|
name = Vamp.chrome_name!(name)
|
|
queue("label", text, name)
|
|
end
|
|
|
|
private
|
|
|
|
# @param context [Hash{String => String, Integer, nil}]
|
|
# @return [void]
|
|
def validate_context(context)
|
|
return if context.is_a?(Hash) && context.keys.sort == %w[buffer_id column mode path revision root row text]
|
|
|
|
raise ArgumentError, "invalid editor context"
|
|
end
|
|
|
|
# @param context [Hash{String => String, Integer, nil}]
|
|
# @return [void]
|
|
def assign_context_strings(context)
|
|
@text = Vamp.string!(context["text"], "context text").freeze
|
|
@mode = Vamp.string!(context["mode"], "context mode").freeze
|
|
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
|
|
@root = Vamp.string!(context["root"], "context root").freeze
|
|
end
|
|
|
|
# @param context [Hash{String => String, Integer, nil}]
|
|
# @param request_id [Integer]
|
|
# @return [void]
|
|
def assign_context_numbers(context, request_id)
|
|
@buffer_id = context["buffer_id"]
|
|
@revision = context["revision"]
|
|
@request_id = request_id
|
|
@row = context["row"]
|
|
@column = context["column"]
|
|
end
|
|
|
|
# @return [void]
|
|
def validate_context_position
|
|
return if @row.is_a?(Integer) && @row.positive? && @column.is_a?(Integer) && @column.positive?
|
|
|
|
raise ArgumentError, "context row and column must be positive integers"
|
|
end
|
|
|
|
# @return [void]
|
|
def validate_context_revision
|
|
return if @buffer_id.is_a?(Integer) && @buffer_id >= 0 && @revision.is_a?(Integer) && @revision >= 0
|
|
|
|
raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
|
|
end
|
|
|
|
# @param type [String] insert, replace, message, or label
|
|
# @param text [String]
|
|
# @param name [String, nil]
|
|
# @return [nil]
|
|
def queue(type, text, name = nil)
|
|
text = Vamp.string!(text, "effect text")
|
|
@effect_bytes += text.bytesize + (name ? name.bytesize : 0)
|
|
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
|
|
|
|
effect = { "type" => type, "text" => text }
|
|
effect["name"] = name if name
|
|
@effects << effect
|
|
nil
|
|
end
|
|
end
|
|
end
|