Events

Manages the history and state of all application events.

Applications need to track, count, and display event history for debugging and feedback. Direct management of event arrays and counters across the app leads to scattered state.

This class centralizes event storage. It records new events, maintains counts, and manages temporary highlights.

Use it to store key presses, mouse clicks, and window resizes for display in a log or counter.

Examples

events = Events.new
events.record(key_event)
puts events.count(:key) #=> 1

if events.lit?(:key)
  puts "Key event just happened!"
end

Source Code

# frozen_string_literal: true

# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
# SPDX-License-Identifier: AGPL-3.0-or-later

require_relative "timestamp"
require_relative "event_color_cycle"
require_relative "event_entry"

# Manages the history and state of all application events.
#
# Applications need to track, count, and display event history for debugging and feedback.
# Direct management of event arrays and counters across the app leads to scattered state.
#
# This class centralizes event storage. It records new events, maintains counts, and manages temporary highlights.
#
# Use it to store key presses, mouse clicks, and window resizes for display in a log or counter.
#
# === Examples
#
#   events = Events.new
#   events.record(key_event)
#   puts events.count(:key) #=> 1
#
#   if events.lit?(:key)
#     puts "Key event just happened!"
#   end
class Events
  # Duration in milliseconds for an event to remain highlighted in the UI.
  HIGHLIGHT_DURATION_MS = 300

  # Creates a new Events manager.
  def initialize
    @entries = []
    @color_cycle = EventColorCycle.new
    @none_count = 0
    @lit_type = nil
    @lit_until = Timestamp.now
    @live = {}
  end

  # Records a new event.
  #
  # [event] RatatuiRuby::Event object.
  # [context] Hash of additional context (e.g., last_dimensions).
  #
  # === Example
  #
  #   events.record(mouse_event)
  def record(event, context: {})
    if event.is_a?(RatatuiRuby::Event::None) || event == :none
      @none_count += 1
      return
    end

    color = @color_cycle.next_color
    timestamp = Timestamp.now
    entry = EventEntry.create(event, color, timestamp)
    @entries << entry
    update_lit_type(entry)

    display_type = live_type_for(entry.type)
    @live[display_type] = { time: Time.now, description: entry.description }
  end

  private def live_type_for(type)
    case type
    when :focus_gained, :focus_lost
      :focus
    else
      type
    end
  end

  # Returns the most recent live event data for a type.
  #
  # [type] Symbol event type to look up.
  #
  # === Example
  #
  #   events.live_event(:key) #=> { time: ..., description: "..." }
  def live_event(type)
    @live[type]
  end

  # Returns all recorded live event data.
  #
  # === Example
  #
  #   events.live_events #=> { key: { ... }, mouse: { ... } }
  def live_events
    @live
  end

  # Returns the most recent entries up to the given limit.
  #
  # [max_entries] Integer maximum number of entries to return.
  #
  # === Example
  #
  #   events.visible(10) #=> [#<EventEntry ...>, ...]
  def visible(max_entries)
    @entries.last(max_entries)
  end

  # Checks if any events have been recorded.
  #
  # === Example
  #
  #   events.empty? #=> true
  def empty?
    @entries.empty?
  end

  # Returns the count of events for a type.
  #
  # [type] Symbol event type.
  #
  # === Example
  #
  #   events.count(:key) #=> 5
  def count(type)
    return @none_count if type == :none

    @entries.count { |e| e.matches_type?(type) }
  end

  # Returns counts grouped by subtype (kind or modifier status).
  #
  # [type] Symbol event type.
  #
  # === Example
  #
  #   events.sub_counts(:mouse) #=> { "down" => 1, "up" => 2 }
  def sub_counts(type)
    return {} if type == :none

    entries = @entries.select { |e| e.matches_type?(type) }

    defaults = { key: %w[unmodified modified], focus: %w[gained lost], mouse: %w[down up drag moved scroll_up scroll_down] }
    entries.each_with_object(defaults.fetch(type, []).to_h { |k| [k, 0] }) do |entry, counts|
      group = if entry.event.respond_to?(:kind)
        entry.event.kind.to_s
      elsif entry.event.respond_to?(:modifiers)
        entry.event.modifiers.empty? ? "unmodified" : "modified"
      elsif type == :focus
        entry.type.to_s.sub("focus_", "")
      end

      counts[group] += 1 if group
    end
  end

  # Checks if a type should be highlighted.
  #
  # [type] Symbol event type.
  #
  # === Example
  #
  #   events.lit?(:key) #=> true
  def lit?(type)
    return false if Timestamp.now.milliseconds >= @lit_until.milliseconds

    @lit_type == type
  end

  # Returns all event entries.
  #
  # === Example
  #
  #   events.entries #=> [#<EventEntry ...>, ...]
  def entries
    @entries
  end

  private def update_lit_type(entry)
    @lit_type = live_type_for(entry.type)
    @lit_until = Timestamp.new(milliseconds: Timestamp.now.milliseconds + HIGHLIGHT_DURATION_MS)
  end
end