Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/core/user_preferences.ex
defmodule Raxol.Core.UserPreferences do
@moduledoc """
Manages user preferences for the terminal emulator.
Acts as a GenServer holding the preferences state and handles persistence.
Uses `Raxol.Core.Utils.Debounce` for debounced save operations, avoiding
redundant disk writes when multiple preference changes occur in quick succession.
"""
alias Raxol.Core.Runtime.Log
use Raxol.Core.Behaviours.BaseManager
require Raxol.Core.Runtime.Log
alias Raxol.Core.Preferences.Persistence
alias Raxol.Core.Utils.Debounce
@save_delay_ms 1000
defmodule State do
@moduledoc "Internal state for the UserPreferences GenServer."
defstruct preferences: %{},
debounce: nil
end
@impl true
def init_manager(opts) do
preferences = initialize_preferences(Keyword.get(opts, :test_mode?, false))
{:ok, %State{preferences: preferences, debounce: Debounce.new()}}
end
def get(key_or_path, pid_or_name \\ __MODULE__) do
GenServer.call(pid_or_name, {:get, key_or_path})
end
def set(key_or_path, value, pid_or_name \\ __MODULE__) do
GenServer.call(pid_or_name, {:set, key_or_path, value})
end
def save!(pid_or_name \\ __MODULE__) do
GenServer.call(pid_or_name, :save_now)
end
def get_all(pid_or_name \\ __MODULE__) do
GenServer.call(pid_or_name, :get_all)
end
def set_preferences(preferences, pid_or_name \\ __MODULE__) do
GenServer.call(pid_or_name, {:set_preferences, preferences})
end
def reset_to_defaults_for_test!(pid_or_name \\ __MODULE__) do
case Process.whereis(pid_or_name) do
nil ->
# Process is not alive, just return ok
:ok
_pid ->
# Process is alive, call the reset function
GenServer.call(pid_or_name, :reset_to_defaults)
end
end
@impl true
def handle_manager_call({:get, key_or_path}, _from, state) do
path = normalize_path(key_or_path)
value = get_in(state.preferences, path)
{:reply, value, state}
end
@impl true
def handle_manager_call({:set, key_or_path, value}, from, state) do
path = normalize_path(key_or_path)
current_value = get_in(state.preferences, path)
handle_preference_change(
current_value == value,
current_value,
value,
path,
state,
from
)
end
@impl true
def handle_manager_call(:get_all, _from, state) do
{:reply, state.preferences, state}
end
@impl true
def handle_manager_call({:set_preferences, preferences}, from, state) do
new_preferences = deep_merge(state.preferences, preferences)
Raxol.Core.Runtime.Log.debug(
"All preferences updated: #{inspect(preferences)}"
)
new_state = %{state | preferences: new_preferences}
{caller_pid, _} = from
send(
caller_pid,
{:preferences_applied,
Process.info(self(), :registered_name) |> elem(1) || self()}
)
{:reply, :ok, schedule_save(new_state)}
end
@impl true
def handle_manager_call(:save_now, _from, state) do
state = cancel_pending_save(state)
case Persistence.save(state.preferences) do
:ok ->
Raxol.Core.Runtime.Log.debug("User preferences saved immediately.")
{:reply, :ok, state}
{:error, reason} ->
Raxol.Core.Runtime.Log.error(
"Failed to save preferences immediately: #{inspect(reason)}"
)
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_manager_call(:reset_to_defaults, _from, state) do
Raxol.Core.Runtime.Log.info(
"UserPreferences resetting to defaults for test."
)
new_preferences = default_preferences()
state = cancel_pending_save(state)
new_state = %{state | preferences: new_preferences}
{:reply, :ok, new_state}
end
defp schedule_save(%State{debounce: debounce} = state) do
{new_debounce, _ref} = Debounce.schedule(debounce, :save, @save_delay_ms)
%{state | debounce: new_debounce}
end
defp cancel_pending_save(%State{debounce: debounce} = state) do
%{state | debounce: Debounce.cancel(debounce, :save)}
end
@impl true
def handle_manager_info({:debounce, :save, debounce_id}, state) do
if Debounce.valid?(state.debounce, :save, debounce_id) do
case Persistence.save(state.preferences) do
:ok ->
Raxol.Core.Runtime.Log.debug(
"User preferences saved after debounce delay."
)
{:error, reason} ->
Raxol.Core.Runtime.Log.error(
"Failed to save preferences after delay: #{inspect(reason)}"
)
end
new_debounce = Debounce.clear(state.debounce, :save)
{:noreply, %{state | debounce: new_debounce}}
else
# Stale debounce message, ignore
{:noreply, state}
end
end
@impl true
def handle_manager_info(msg, state) do
case msg do
{:preferences_applied, _pid} ->
{:noreply, state}
# Legacy message format - ignore
{:perform_delayed_save, _timer_id} ->
{:noreply, state}
_ ->
Raxol.Core.Runtime.Log.warning_with_context(inspect(msg), %{})
{:noreply, state}
end
end
defp deep_merge(map1, map2) do
Map.merge(map1, map2, fn _key, val1, val2 ->
merge_values(is_map(val1) and is_map(val2), val1, val2)
end)
end
defp normalize_path(path) when is_atom(path), do: [path]
defp normalize_path(path) when is_list(path), do: path
defp normalize_path(path) when is_binary(path) do
case Raxol.Core.ErrorHandling.safe_call(fn ->
String.split(path, ".")
|> Enum.map(&String.to_existing_atom/1)
end) do
{:ok, result} ->
result
# safe_call/1 returns {:error, exception}, not {:error, {exception, stacktrace}}
{:error, %ArgumentError{}} ->
Raxol.Core.Runtime.Log.error(
"Invalid preference path string: #{inspect(path)} - cannot convert segments to atoms."
)
[]
{:error, _} ->
[]
end
end
# Helper functions for if-statement elimination
defp initialize_preferences(true) do
Raxol.Core.Runtime.Log.info(
"UserPreferences starting in test mode, using defaults only."
)
default_preferences()
end
defp initialize_preferences(false) do
case Persistence.load() do
{:ok, loaded_prefs} ->
Raxol.Core.Runtime.Log.info("User preferences loaded successfully.")
deep_merge(default_preferences(), loaded_prefs)
{:error, :file_not_found} ->
Raxol.Core.Runtime.Log.info(
"No preferences file found, using defaults."
)
default_preferences()
{:error, reason} ->
Raxol.Core.Runtime.Log.warning(
"Failed to load preferences: #{inspect(reason)}, using defaults."
)
default_preferences()
end
end
@spec handle_preference_change(any(), any(), any(), String.t(), map(), any()) ::
{:ok, any()}
| {:error, any()}
| {:reply, any(), any()}
| {:noreply, any()}
defp handle_preference_change(
true,
_current_value,
_value,
_path,
state,
from
) do
# Value unchanged - only send notification in test mode
send_notification_if_test(from)
{:reply, :ok, state}
end
@spec handle_preference_change(any(), any(), any(), String.t(), map(), any()) ::
{:ok, any()}
| {:error, any()}
| {:reply, any(), any()}
| {:noreply, any()}
defp handle_preference_change(false, _current_value, value, path, state, from) do
new_preferences = put_in(state.preferences, path, value)
Raxol.Core.Runtime.Log.debug(
"Preference updated: #{inspect(path)} = #{inspect(value)}"
)
new_state = %{state | preferences: new_preferences}
{caller_pid, _} = from
send(
caller_pid,
{:preferences_applied,
Process.info(self(), :registered_name) |> elem(1) || self()}
)
{:reply, :ok, schedule_save(new_state)}
end
defp send_notification_if_test({caller_pid, _}) do
execute_test_notification(Mix.env() == :test, caller_pid)
end
defp execute_test_notification(false, _caller_pid), do: :ok
defp execute_test_notification(true, caller_pid) do
send(
caller_pid,
{:preferences_applied,
Process.info(self(), :registered_name) |> elem(1) || self()}
)
end
defp merge_values(true, val1, val2), do: deep_merge(val1, val2)
defp merge_values(false, _val1, val2), do: val2
@doc """
Returns the current theme id as an atom, defaulting to :default if not set or invalid.
"""
def get_theme_id(pid_or_name \\ __MODULE__) do
theme = get([:theme, :active_id], pid_or_name) || get(:theme, pid_or_name)
normalize_theme_id(theme)
end
defp normalize_theme_id(theme) when is_atom(theme) do
theme
end
defp normalize_theme_id(theme) when is_binary(theme) do
case Raxol.Core.ErrorHandling.safe_call(fn ->
String.to_existing_atom(theme)
end) do
{:ok, result} -> result
# safe_call/1 returns {:error, exception}, not {:error, {exception, stacktrace}}
{:error, %ArgumentError{}} -> :default
{:error, _} -> :default
end
end
defp normalize_theme_id(_theme) do
:default
end
@doc """
Returns the default preferences map.
This includes default values for theme, terminal configuration, accessibility settings,
and keybindings.
"""
def default_preferences do
%{
theme: %{
active_id: :default
},
terminal: Raxol.Terminal.Config.Defaults.generate_default_config(),
accessibility: %{
enabled: true,
screen_reader: true,
high_contrast: false,
reduced_motion: false,
keyboard_focus: true,
large_text: false,
silence_announcements: false
},
keybindings:
%{
# Add default keybindings here
}
}
end
end