diff --git a/CHANGELOG.md b/CHANGELOG.md index e6eddc5..5e8b8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [1.3.0] - 2023-10-04 + +- Provide `Signalize::Struct`, a struct-like object to hold multiple signals (including computed) + (optional via `require "signalize/struct"`) + ## [1.2.0] - 2023-10-03 - Add `untracked` method (implements #5) diff --git a/Gemfile.lock b/Gemfile.lock index 93ce23e..9dd03af 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - signalize (1.2.0) + signalize (1.3.0) concurrent-ruby (~> 1.2) GEM diff --git a/README.md b/README.md index 884f8b6..e6e8c8a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ If bundler is not being used to manage dependencies, install the gem by executin ## Usage -Signalize's public API consists of four methods (you can think of them almost like functions): `signal`, `computed`, `effect`, and `batch`. +Signalize's public API consists of five methods (you can think of them almost like functions): `signal`, `untracked`, `computed`, `effect`, and `batch`. ### `signal(initial_value)` @@ -232,7 +232,7 @@ end counter.value = 1 # logs the new value ``` -### `peek` +### `signal.peek` If you need to access a signal's value inside an effect without subscribing to that signal's updates, use the `peek` method instead of `value`. @@ -252,6 +252,55 @@ effect do end ``` +## Signalize Struct + +An optional add-on to Signalize, the `Singalize::Struct` class lets you define multiple signal or computed variables to hold in struct-like objects. You can even add custom methods to your classes with a simple DSL. (The API is intentionally similar to `Data` in Ruby 3.2+, although these objects are of course mutable.) + +Here's what it looks like: + +```ruby +require "signalize/struct" + +include Signalize::API + +TestSignalsStruct = Signalize::Struct.define( + :str, + :int, + :multiplied_by_10 +) do # optional block for adding methods + def increment! + self.int += 1 + end +end + +struct = TestSignalsStruct.new( + int: 0, + str: "Hello World", + multiplied_by_10: computed { struct.int * 10 } +) + +effect do + puts struct.multiplied_by_10 # 0 +end + +effect do + puts struct.str # "Hello World" +end + +struct.increment! # above effect will now output 10 +struct.str = "Goodbye!" # above effect will now output "Goodbye!" +``` + +If you ever need to get at the actual `Signal` object underlying a value, just call `*_signal`. For example, you could call `int_signal` for the above example to get a signal object for `int`. + +Signalize structs require all of their members to be present when initializing…you can't pass only some keyword arguments. + +Signalize structs support `to_h` as well as `deconstruct_keys` which is used for pattern matching and syntax like `struct => { str: }` to set local variables. + +You can call `members` (as both object/class methods) to get a list of the value names in the struct. + +Finally, both `inspect` and `to_s` let you debug the contents of a struct. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rake test` to run the tests, or `bin/guard` or run them continuously in watch mode. You can also run `bin/console` for an interactive prompt that will allow you to experiment. @@ -260,7 +309,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Signalize is considered a direct port of the [original Signals JavaScript library](https://github.com/preactjs/signals). This means we are unlikely to accept any additional features other than what's provided by Signals. If Signals adds new functionality in the future, we will endeavor to replicate it in Signalize. Furthermore, if there's some unwanted behavior in Signalize that's also present in Signals, we are unlikely to modify that behavior. +Signalize is considered a direct port of the [original Signals JavaScript library](https://github.com/preactjs/signals). This means we are unlikely to accept any additional features other than what's provided by Signals (unless it's completely separate, like our `Signalize::Struct` add-on). If Signals adds new functionality in the future, we will endeavor to replicate it in Signalize. Furthermore, if there's some unwanted behavior in Signalize that's also present in Signals, we are unlikely to modify that behavior. However, if you're able to supply a bugfix or performance optimization which will help bring Signalize _more_ into alignment with its Signals counterpart, we will gladly accept your PR! diff --git a/lib/signalize/struct.rb b/lib/signalize/struct.rb new file mode 100644 index 0000000..358ca00 --- /dev/null +++ b/lib/signalize/struct.rb @@ -0,0 +1,91 @@ +require "signalize" + +module Signalize + class Struct + module Accessors + def members + @members ||= [] + end + + def signal_accessor(*names) + names.each do |name| + members.push(name.to_sym) unless members.find { _1 == name.to_sym } + signal_getter_name = "#{name}_signal".freeze + ivar_name = "@#{name}".freeze + + define_method "#{name}_signal" do + instance_variable_get(ivar_name) + end + + define_method name do + send(signal_getter_name)&.value + end + + define_method "#{name}=" do |val| + if instance_variable_defined?(ivar_name) + raise Signalize::Error, "Cannot assign a signal to a signal value" if val.is_a?(Signalize::Signal) + + sig = instance_variable_get(ivar_name) + if sig.is_a?(Signalize::Computed) + raise Signalize::Error, "Cannot set value of computed signal `#{ivar_name.delete_prefix("@")}'" + end + + sig.value = val + else + val = Signalize.signal(val) unless val.is_a?(Signalize::Computed) + instance_variable_set(ivar_name, val) + end + end + end + end + end + + extend Accessors + + def self.define(*names, &block) + Class.new(self).tap do |struct| + struct.signal_accessor(*names) + struct.class_eval(&block) if block + end + end + + def initialize(**data) + # The below code is all to replicate native Ruby ergonomics + unknown_keys = data.keys - members + unless unknown_keys.empty? + plural_suffix = unknown_keys.length > 1 ? "s" : "" + raise ArgumentError, "unknown keyword#{plural_suffix}: #{unknown_keys.map { ":#{_1}" }.join(", ")}" + end + + missing_keys = members - data.keys + unless missing_keys.empty? + plural_suffix = missing_keys.length > 1 ? "s" : "" + raise ArgumentError, "missing keyword#{plural_suffix}: #{missing_keys.map { ":#{_1}" }.join(", ")}" + end + + # Initialize with keyword arguments + data.each do |k, v| + send("#{k}=", v) + end + end + + def members = self.class.members + + def deconstruct_keys(...) = to_h.deconstruct_keys(...) + + def to_h = members.each_with_object({}) { _2[_1] = send("#{_1}_signal").peek } + + def inspect + var_peeks = instance_variables.filter_map do |var_name| + var = instance_variable_get(var_name) + "#{var_name.to_s.delete_prefix("@")}=#{var.peek.inspect}" if var.is_a?(Signalize::Signal) + end.join(", ") + + "#<#{self.class}#{var_peeks.empty? ? nil : " #{var_peeks}"}>" + end + + def to_s + inspect + end + end +end diff --git a/lib/signalize/version.rb b/lib/signalize/version.rb index 0011ef3..d46a3c4 100644 --- a/lib/signalize/version.rb +++ b/lib/signalize/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Signalize - VERSION = "1.2.0" + VERSION = "1.3.0" end diff --git a/test/test_struct.rb b/test/test_struct.rb new file mode 100644 index 0000000..13ab2cf --- /dev/null +++ b/test/test_struct.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "test_helper" +require "signalize/struct" + +class TestStruct < Minitest::Test + include Signalize::API + + TestSignalsStruct = Signalize::Struct.define( + :str, + :int + ) do + def increment! + self.int += 1 + end + end + + def test_int_value + struct = TestSignalsStruct.new(int: 0, str: "") + + assert_equal 0, struct.int + assert_equal 0, struct.int_signal.value + + # Write to a signal + struct.int = 1 + + assert_equal 1, struct.int + + struct.increment! + + assert_equal 2, struct.int + end + + def test_str_computed + struct = TestSignalsStruct.new(str: "Doe", int: 0) + name = signal("Jane") + + computed_ran_once = 0 + + full_name = computed do + computed_ran_once += 1 + name.value + " " + struct.str + end + + assert_equal "Jane Doe", full_name.value + + name.value = "John" + name.value = "Johannes" + # name.value = "..." + # Setting value multiple times won't trigger a computed value refresh + + # NOW we get a refreshed computed value: + assert_equal "Johannes Doe", full_name.value + assert_equal 2, computed_ran_once + + # Test deconstructing + struct => { str: } + assert_equal "Doe", str + end +end