nandi/frqpublic Fork 0
23846db48565a450ee8a6a689892db23663ad48e
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A web version, from the same core 23846db · on 23846db48565a450ee8a6a689892db23663ad48e · nandi · 11h ago
frq_core.dart · 202 lines · 7.6 KBDart Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 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
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
/// The Nim core, as Dart functions.
///
/// The logic lives in Nim; this is the shape it takes on this side. Two hosts
/// answer it — `src/host_ffi.dart` through `dart:ffi` on a desktop, and
/// `src/host_js.dart` against the `nim js` build in a browser — and
/// `src/host.dart` is the one line that chooses. Nothing below this comment
/// knows which, which is the point: the widget tree, its decoding, and the
/// change detection that makes polling cheap are the same work whatever
/// produced the JSON.
///
/// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to
/// have less Clojure, so new code on this side of the boundary is written in
/// the language the platform speaks.
///
/// No `package:ffi` either. That package exists mostly for `Utf8`
/// conversions, and doing them against `dart:convert` costs about ten lines
/// and keeps `pubspec.yaml` unchanged.
library;

import 'dart:convert';

import 'src/host.dart' as host;

// ------------------------------------------------------------------ public

/// The core's version, for a caller that wants to check the library it found
/// is the one it was built against.
String get version => host.hostVersion();

/// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`.
///
/// `tags`, `account` and `prefix` are null where the line carried none, which
/// is the distinction `frq.irc.parse` draws with nil and every caller depends
/// on — a PRIVMSG from a server with no prefix is not the same line as one
/// from a nick.
Map<String, dynamic> parseLine(String line) {
  final json = host.str1('frq_irc_parse_line', line);
  return jsonDecode(json ?? 'null') as Map<String, dynamic>;
}

/// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty,
/// which IRCv3 says are the same thing.
String? tagValue(String tags, String key) {
  return host.tagValueOf(tags, key);
}

String unescapeTag(String v) => host.str1('frq_irc_unescape_tag', v) ?? '';

String escapeTagValue(String v) => host.str1('frq_irc_escape_tag_value', v) ?? '';

/// The nick half of a `nick!user@host` prefix.
String nickOf(String prefix) => host.str1('frq_irc_nick_of', prefix) ?? '';


/// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one
/// interleaved story rather than two half-ones in different places.
void trace(String topic, String msg) {
  host.traceTo(topic, msg);
}

// --------------------------------------------------------------- transport
//
// `frq.net`'s three operations, with a Nim socket behind them. This is the
// wiring that leaves the existing ClojureDart screens, cells and actions
// alone: only the transport underneath them is Nim.


/// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
/// through [connEvent].
void connOpen(String hostname, int port, {bool tls = true}) =>
    host.connOpenAt(hostname, port, tls);


/// Queue a line. The transport adds the CRLF.
void connSend(String line) => host.connSendLine(line);


void connClose() => host.connCloseNow();

/// The next line, or null when none is waiting. Never blocks.
String? connRecv() => host.connRecvLine();

/// The next transport event — `open`, `close: …`, `error: …` — or null.
String? connEvent() => host.connEventNext();


// ---------------------------------------------------------------- the UI
//
// Nim owns the state and the screens; Dart owns the pixels. A tree goes out,
// an event id comes back, and nothing else crosses.
//
// `UiNode` is deliberately a dumb bag — a tag, a props map, children. A class
// per widget would put the tag vocabulary in two places and make every new tag
// a change on both sides; the point is that Nim can grow a screen without this
// file being touched.

/// One node of the widget tree Nim emitted.
class UiNode {
  final String tag;
  final Map<String, dynamic> props;
  final List<UiNode> children;

  const UiNode(this.tag, this.props, this.children);

  factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
        j['tag'] as String,
        (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
        ((j['children'] as List?) ?? const [])
            .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
            .toList(growable: false),
      );

  /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
  /// purpose: a renderer should skip a prop it does not understand rather than
  /// fail a whole screen over one.
  T prop<T>(String name, T fallback) {
    final v = props[name];
    return v is T ? v : fallback;
  }

  /// Structural, and that matters: the poll loop compares two trees by this
  /// string to decide whether to rebuild. A summary showing only tags and prop
  /// NAMES would call two screens equal when a message had arrived, and the
  /// room would never appear to fill.
  @override
  String toString() =>
      '<$tag $props ${children.map((c) => c.toString()).join()}>';
}

UiNode _treeFrom(String? json) =>
    UiNode.fromJson(jsonDecode(json ?? _emptyTree) as Map<String, dynamic>);

/// A tree and the JSON it came from.
///
/// The raw string is kept because it is the cheapest possible change
/// detector: Nim already produced it, and comparing two strings is free
/// beside decoding one. The renderer polls ten times a second and the answer
/// is almost always "nothing changed" — doing a `jsonDecode` and two
/// recursive `toString()`s to discover that was most of the idle cost of the
/// app in a busy room.
const _emptyTree = '{"tag":"vbox"}';

class UiFrame {
  final String json;
  final UiNode tree;
  const UiFrame(this.json, this.tree);
}

/// The current screen.
///
/// Not pure: the Nim side drains the socket's queue first, so two calls with
/// no [dispatch] between can differ when a line arrived in the gap. That is how
/// the room fills, and why the renderer polls.
UiNode render() => _treeFrom(
    host.uiRender());

/// The tree, asked for because time passed rather than because anything
/// happened. Same work as [render]; named for what the caller means.
UiNode poll() => _treeFrom(
    host.uiPoll());

/// The current screen, with the JSON it came from. The starting point for
/// [pollIfChanged].
UiFrame renderFrame() {
  final json = host.uiRender() ?? _emptyTree;
  return UiFrame(json, _treeFrom(json));
}

/// The tree, decoded only when it differs from [since] — otherwise null,
/// meaning "the screen you already have is current".
///
/// This is what the renderer polls with. The comparison is the JSON Nim
/// already produced, so an unchanged frame costs one string compare rather
/// than a decode and two recursive `toString()`s.
UiFrame? pollIfChanged(String since) {
  final json = host.uiPoll() ?? _emptyTree;
  if (json == since) return null;
  return UiFrame(json, _treeFrom(json));
}

/// Apply an event and get the tree it produced.
///
/// One call rather than dispatch-then-render, and not to save a crossing: it
/// makes the pair atomic, so there is no window in which Dart could render a
/// state nothing asked for.
UiNode dispatch(String id, [String value = '']) => dispatchFrame(id, value).tree;

/// As [dispatch], but keeping the JSON so the poll loop can compare against
/// it without re-stringifying the tree it just built.
UiFrame dispatchFrame(String id, [String value = '']) {
  final json = host.uiDispatch(jsonEncode({'id': id, 'value': value})) ??
      _emptyTree;
  return UiFrame(json, _treeFrom(json));
}


/// Fill a room with a representative conversation, so a test can lay the chat
/// screen out without a server. See the Nim side for why it exists.
void demoUi() => host.uiDemo();

/// Back to a fresh state, for a caller that wants a known starting point.
void resetUi() => host.uiReset();