1
|
|
"""
|
2
|
|
Declares Syncable and Reactive classes which provides baseclasses
|
3
|
|
for Panel components which sync their state with one or more bokeh
|
4
|
|
models rendered on the frontend.
|
5
|
|
"""
|
6
|
|
|
7
|
2
|
import difflib
|
8
|
2
|
import threading
|
9
|
|
|
10
|
2
|
from collections import namedtuple
|
11
|
2
|
from functools import partial
|
12
|
|
|
13
|
2
|
from bokeh.models import LayoutDOM
|
14
|
2
|
from tornado import gen
|
15
|
|
|
16
|
2
|
from .config import config
|
17
|
2
|
from .io.callbacks import PeriodicCallback
|
18
|
2
|
from .io.model import hold
|
19
|
2
|
from .io.notebook import push
|
20
|
2
|
from .io.server import unlocked
|
21
|
2
|
from .io.state import state
|
22
|
2
|
from .util import edit_readonly
|
23
|
2
|
from .viewable import Layoutable, Renderable, Viewable
|
24
|
|
|
25
|
2
|
LinkWatcher = namedtuple("Watcher","inst cls fn mode onlychanged parameter_names what queued target links transformed bidirectional_watcher")
|
26
|
|
|
27
|
|
|
28
|
2
|
class Syncable(Renderable):
|
29
|
|
"""
|
30
|
|
Syncable is an extension of the Renderable object which can not
|
31
|
|
only render to a bokeh model but also sync the parameters on the
|
32
|
|
object with the properties on the model.
|
33
|
|
|
34
|
|
In order to bi-directionally link parameters with bokeh model
|
35
|
|
instances the _link_params and _link_props methods define
|
36
|
|
callbacks triggered when either the parameter or bokeh property
|
37
|
|
values change. Since there may not be a 1-to-1 mapping between
|
38
|
|
parameter and the model property the _process_property_change and
|
39
|
|
_process_param_change may be overridden to apply any necessary
|
40
|
|
transformations.
|
41
|
|
"""
|
42
|
|
|
43
|
|
# Timeout if a notebook comm message is swallowed
|
44
|
2
|
_timeout = 20000
|
45
|
|
|
46
|
|
# Timeout before the first event is processed
|
47
|
2
|
_debounce = 50
|
48
|
|
|
49
|
|
# Mapping from parameter name to bokeh model property name
|
50
|
2
|
_rename = {}
|
51
|
|
|
52
|
2
|
__abstract = True
|
53
|
|
|
54
|
2
|
events = []
|
55
|
|
|
56
|
2
|
def __init__(self, **params):
|
57
|
2
|
super(Syncable, self).__init__(**params)
|
58
|
2
|
self._processing = False
|
59
|
2
|
self._events = {}
|
60
|
2
|
self._callbacks = []
|
61
|
2
|
self._links = []
|
62
|
2
|
self._link_params()
|
63
|
2
|
self._changing = {}
|
64
|
|
|
65
|
|
# Allows defining a mapping from model property name to a JS code
|
66
|
|
# snippet that transforms the object before serialization
|
67
|
2
|
_js_transforms = {}
|
68
|
|
|
69
|
|
# Transforms from input value to bokeh property value
|
70
|
2
|
_source_transforms = {}
|
71
|
2
|
_target_transforms = {}
|
72
|
|
|
73
|
|
#----------------------------------------------------------------
|
74
|
|
# Model API
|
75
|
|
#----------------------------------------------------------------
|
76
|
|
|
77
|
2
|
def _process_property_change(self, msg):
|
78
|
|
"""
|
79
|
|
Transform bokeh model property changes into parameter updates.
|
80
|
|
Should be overridden to provide appropriate mapping between
|
81
|
|
parameter value and bokeh model change. By default uses the
|
82
|
|
_rename class level attribute to map between parameter and
|
83
|
|
property names.
|
84
|
|
"""
|
85
|
2
|
inverted = {v: k for k, v in self._rename.items()}
|
86
|
2
|
return {inverted.get(k, k): v for k, v in msg.items()}
|
87
|
|
|
88
|
2
|
def _process_param_change(self, msg):
|
89
|
|
"""
|
90
|
|
Transform parameter changes into bokeh model property updates.
|
91
|
|
Should be overridden to provide appropriate mapping between
|
92
|
|
parameter value and bokeh model change. By default uses the
|
93
|
|
_rename class level attribute to map between parameter and
|
94
|
|
property names.
|
95
|
|
"""
|
96
|
2
|
properties = {self._rename.get(k, k): v for k, v in msg.items()
|
97
|
|
if self._rename.get(k, False) is not None}
|
98
|
2
|
if 'width' in properties and self.sizing_mode is None:
|
99
|
2
|
properties['min_width'] = properties['width']
|
100
|
2
|
if 'height' in properties and self.sizing_mode is None:
|
101
|
2
|
properties['min_height'] = properties['height']
|
102
|
2
|
return properties
|
103
|
|
|
104
|
2
|
def _link_params(self):
|
105
|
2
|
params = self._synced_params()
|
106
|
2
|
if params:
|
107
|
2
|
watcher = self.param.watch(self._param_change, params)
|
108
|
2
|
self._callbacks.append(watcher)
|
109
|
|
|
110
|
2
|
def _link_props(self, model, properties, doc, root, comm=None):
|
111
|
2
|
ref = root.ref['id']
|
112
|
2
|
if config.embed:
|
113
|
2
|
return
|
114
|
|
|
115
|
2
|
for p in properties:
|
116
|
2
|
if isinstance(p, tuple):
|
117
|
2
|
_, p = p
|
118
|
2
|
if comm:
|
119
|
2
|
model.on_change(p, partial(self._comm_change, doc, ref))
|
120
|
|
else:
|
121
|
2
|
model.on_change(p, partial(self._server_change, doc, ref))
|
122
|
|
|
123
|
2
|
@property
|
124
|
|
def _linkable_params(self):
|
125
|
0
|
return [p for p in self._synced_params()
|
126
|
|
if self._source_transforms.get(p, False) is not None]
|
127
|
|
|
128
|
2
|
def _synced_params(self):
|
129
|
2
|
return list(self.param)
|
130
|
|
|
131
|
2
|
def _update_model(self, events, msg, root, model, doc, comm):
|
132
|
2
|
self._changing[root.ref['id']] = [
|
133
|
|
attr for attr, value in msg.items()
|
134
|
|
if not model.lookup(attr).property.matches(getattr(model, attr), value)
|
135
|
|
]
|
136
|
2
|
try:
|
137
|
2
|
model.update(**msg)
|
138
|
|
finally:
|
139
|
2
|
del self._changing[root.ref['id']]
|
140
|
|
|
141
|
2
|
def _cleanup(self, root):
|
142
|
2
|
super(Syncable, self)._cleanup(root)
|
143
|
2
|
ref = root.ref['id']
|
144
|
2
|
self._models.pop(ref, None)
|
145
|
2
|
comm, client_comm = self._comms.pop(ref, (None, None))
|
146
|
2
|
if comm:
|
147
|
0
|
try:
|
148
|
0
|
comm.close()
|
149
|
0
|
except Exception:
|
150
|
0
|
pass
|
151
|
2
|
if client_comm:
|
152
|
0
|
try:
|
153
|
0
|
client_comm.close()
|
154
|
0
|
except Exception:
|
155
|
0
|
pass
|
156
|
|
|
157
|
2
|
def _param_change(self, *events):
|
158
|
2
|
msgs = []
|
159
|
2
|
for event in events:
|
160
|
2
|
msg = self._process_param_change({event.name: event.new})
|
161
|
2
|
if msg:
|
162
|
2
|
msgs.append(msg)
|
163
|
|
|
164
|
2
|
events = {event.name: event for event in events}
|
165
|
2
|
msg = {k: v for msg in msgs for k, v in msg.items()}
|
166
|
2
|
if not msg:
|
167
|
2
|
return
|
168
|
|
|
169
|
2
|
for ref, (model, parent) in self._models.items():
|
170
|
2
|
if ref not in state._views or ref in state._fake_roots:
|
171
|
0
|
continue
|
172
|
2
|
viewable, root, doc, comm = state._views[ref]
|
173
|
2
|
if comm or not doc.session_context or state._unblocked(doc):
|
174
|
2
|
with unlocked():
|
175
|
2
|
self._update_model(events, msg, root, model, doc, comm)
|
176
|
2
|
if comm and 'embedded' not in root.tags:
|
177
|
2
|
push(doc, comm)
|
178
|
|
else:
|
179
|
0
|
cb = partial(self._update_model, events, msg, root, model, doc, comm)
|
180
|
0
|
doc.add_next_tick_callback(cb)
|
181
|
|
|
182
|
2
|
def _process_events(self, events):
|
183
|
2
|
with edit_readonly(state):
|
184
|
2
|
state.busy = True
|
185
|
2
|
try:
|
186
|
2
|
with edit_readonly(self):
|
187
|
2
|
self.param.set_param(**self._process_property_change(events))
|
188
|
|
finally:
|
189
|
2
|
with edit_readonly(state):
|
190
|
2
|
state.busy = False
|
191
|
|
|
192
|
2
|
@gen.coroutine
|
193
|
2
|
def _change_coroutine(self, doc=None):
|
194
|
0
|
self._change_event(doc)
|
195
|
|
|
196
|
2
|
def _change_event(self, doc=None):
|
197
|
2
|
try:
|
198
|
2
|
state.curdoc = doc
|
199
|
2
|
thread = threading.current_thread()
|
200
|
2
|
thread_id = thread.ident if thread else None
|
201
|
2
|
state._thread_id = thread_id
|
202
|
2
|
events = self._events
|
203
|
2
|
self._events = {}
|
204
|
2
|
self._process_events(events)
|
205
|
|
finally:
|
206
|
2
|
self._processing = False
|
207
|
2
|
state.curdoc = None
|
208
|
2
|
state._thread_id = None
|
209
|
|
|
210
|
2
|
def _comm_change(self, doc, ref, attr, old, new):
|
211
|
2
|
if attr in self._changing.get(ref, []):
|
212
|
2
|
self._changing[ref].remove(attr)
|
213
|
2
|
return
|
214
|
|
|
215
|
2
|
with hold(doc):
|
216
|
2
|
self._process_events({attr: new})
|
217
|
|
|
218
|
2
|
def _server_change(self, doc, ref, attr, old, new):
|
219
|
2
|
if attr in self._changing.get(ref, []):
|
220
|
0
|
self._changing[ref].remove(attr)
|
221
|
0
|
return
|
222
|
|
|
223
|
2
|
state._locks.clear()
|
224
|
2
|
self._events.update({attr: new})
|
225
|
2
|
if not self._processing:
|
226
|
2
|
self._processing = True
|
227
|
2
|
if doc.session_context:
|
228
|
0
|
doc.add_timeout_callback(partial(self._change_coroutine, doc), self._debounce)
|
229
|
|
else:
|
230
|
2
|
self._change_event(doc)
|
231
|
|
|
232
|
|
|
233
|
2
|
class Reactive(Syncable, Viewable):
|
234
|
|
"""
|
235
|
|
Reactive is a Viewable object that also supports syncing between
|
236
|
|
the objects parameters and the underlying bokeh model either via
|
237
|
|
the defined pyviz_comms.Comm type or using bokeh server.
|
238
|
|
|
239
|
|
In addition it defines various methods which make it easy to link
|
240
|
|
the parameters to other objects.
|
241
|
|
"""
|
242
|
|
|
243
|
|
#----------------------------------------------------------------
|
244
|
|
# Public API
|
245
|
|
#----------------------------------------------------------------
|
246
|
|
|
247
|
2
|
def add_periodic_callback(self, callback, period=500, count=None,
|
248
|
|
timeout=None, start=True):
|
249
|
|
"""
|
250
|
|
Schedules a periodic callback to be run at an interval set by
|
251
|
|
the period. Returns a PeriodicCallback object with the option
|
252
|
|
to stop and start the callback.
|
253
|
|
|
254
|
|
Arguments
|
255
|
|
---------
|
256
|
|
callback: callable
|
257
|
|
Callable function to be executed at periodic interval.
|
258
|
|
period: int
|
259
|
|
Interval in milliseconds at which callback will be executed.
|
260
|
|
count: int
|
261
|
|
Maximum number of times callback will be invoked.
|
262
|
|
timeout: int
|
263
|
|
Timeout in seconds when the callback should be stopped.
|
264
|
|
start: boolean (default=True)
|
265
|
|
Whether to start callback immediately.
|
266
|
|
|
267
|
|
Returns
|
268
|
|
-------
|
269
|
|
Return a PeriodicCallback object with start and stop methods.
|
270
|
|
"""
|
271
|
0
|
self.param.warning(
|
272
|
|
"Calling add_periodic_callback on a Panel component is "
|
273
|
|
"deprecated and will be removed in the next minor release. "
|
274
|
|
"Use the pn.state.add_periodic_callback API instead."
|
275
|
|
)
|
276
|
0
|
cb = PeriodicCallback(callback=callback, period=period,
|
277
|
|
count=count, timeout=timeout)
|
278
|
0
|
if start:
|
279
|
0
|
cb.start()
|
280
|
0
|
return cb
|
281
|
|
|
282
|
2
|
def link(self, target, callbacks=None, bidirectional=False, **links):
|
283
|
|
"""
|
284
|
|
Links the parameters on this object to attributes on another
|
285
|
|
object in Python. Supports two modes, either specify a mapping
|
286
|
|
between the source and target object parameters as keywords or
|
287
|
|
provide a dictionary of callbacks which maps from the source
|
288
|
|
parameter to a callback which is triggered when the parameter
|
289
|
|
changes.
|
290
|
|
|
291
|
|
Arguments
|
292
|
|
---------
|
293
|
|
target: object
|
294
|
|
The target object of the link.
|
295
|
|
callbacks: dict
|
296
|
|
Maps from a parameter in the source object to a callback.
|
297
|
|
bidirectional: boolean
|
298
|
|
Whether to link source and target bi-directionally
|
299
|
|
**links: dict
|
300
|
|
Maps between parameters on this object to the parameters
|
301
|
|
on the supplied object.
|
302
|
|
"""
|
303
|
2
|
if links and callbacks:
|
304
|
0
|
raise ValueError('Either supply a set of parameters to '
|
305
|
|
'link as keywords or a set of callbacks, '
|
306
|
|
'not both.')
|
307
|
2
|
elif not links and not callbacks:
|
308
|
0
|
raise ValueError('Declare parameters to link or a set of '
|
309
|
|
'callbacks, neither was defined.')
|
310
|
2
|
elif callbacks and bidirectional:
|
311
|
0
|
raise ValueError('Bidirectional linking not supported for '
|
312
|
|
'explicit callbacks. You must define '
|
313
|
|
'separate callbacks for each direction.')
|
314
|
|
|
315
|
2
|
_updating = []
|
316
|
2
|
def link(*events):
|
317
|
2
|
for event in events:
|
318
|
2
|
if event.name in _updating: continue
|
319
|
2
|
_updating.append(event.name)
|
320
|
2
|
try:
|
321
|
2
|
if callbacks:
|
322
|
2
|
callbacks[event.name](target, event)
|
323
|
|
else:
|
324
|
2
|
setattr(target, links[event.name], event.new)
|
325
|
|
finally:
|
326
|
2
|
_updating.pop(_updating.index(event.name))
|
327
|
2
|
params = list(callbacks) if callbacks else list(links)
|
328
|
2
|
cb = self.param.watch(link, params)
|
329
|
|
|
330
|
2
|
bidirectional_watcher = None
|
331
|
2
|
if bidirectional:
|
332
|
2
|
_reverse_updating = []
|
333
|
2
|
reverse_links = {v: k for k, v in links.items()}
|
334
|
2
|
def reverse_link(*events):
|
335
|
2
|
for event in events:
|
336
|
2
|
if event.name in _reverse_updating: continue
|
337
|
2
|
_reverse_updating.append(event.name)
|
338
|
2
|
try:
|
339
|
2
|
setattr(self, reverse_links[event.name], event.new)
|
340
|
|
finally:
|
341
|
2
|
_reverse_updating.remove(event.name)
|
342
|
2
|
bidirectional_watcher = target.param.watch(reverse_link, list(reverse_links))
|
343
|
|
|
344
|
2
|
link = LinkWatcher(*tuple(cb)+(target, links, callbacks is not None, bidirectional_watcher))
|
345
|
2
|
self._links.append(link)
|
346
|
2
|
return cb
|
347
|
|
|
348
|
2
|
def controls(self, parameters=[], jslink=True):
|
349
|
|
"""
|
350
|
|
Creates a set of widgets which allow manipulating the parameters
|
351
|
|
on this instance. By default all parameters which support
|
352
|
|
linking are exposed, but an explicit list of parameters can
|
353
|
|
be provided.
|
354
|
|
|
355
|
|
Arguments
|
356
|
|
---------
|
357
|
|
parameters: list(str)
|
358
|
|
An explicit list of parameters to return controls for.
|
359
|
|
jslink: bool
|
360
|
|
Whether to use jslinks instead of Python based links.
|
361
|
|
This does not allow using all types of parameters.
|
362
|
|
|
363
|
|
Returns
|
364
|
|
-------
|
365
|
|
A layout of the controls
|
366
|
|
"""
|
367
|
2
|
from .param import Param
|
368
|
2
|
from .layout import Tabs, WidgetBox
|
369
|
2
|
from .widgets import LiteralInput
|
370
|
|
|
371
|
2
|
if parameters:
|
372
|
2
|
linkable = parameters
|
373
|
2
|
elif jslink:
|
374
|
2
|
linkable = self._linkable_params
|
375
|
|
else:
|
376
|
0
|
linkable = list(self.param)
|
377
|
|
|
378
|
2
|
params = [p for p in linkable if p not in Layoutable.param]
|
379
|
2
|
controls = Param(self.param, parameters=params, default_layout=WidgetBox,
|
380
|
|
name='Controls')
|
381
|
2
|
layout_params = [p for p in linkable if p in Layoutable.param]
|
382
|
2
|
if 'name' not in layout_params and self._rename.get('name', False) is not None and not parameters:
|
383
|
0
|
layout_params.insert(0, 'name')
|
384
|
2
|
style = Param(self.param, parameters=layout_params, default_layout=WidgetBox,
|
385
|
|
name='Layout')
|
386
|
2
|
if jslink:
|
387
|
2
|
for p in params:
|
388
|
2
|
widget = controls._widgets[p]
|
389
|
2
|
widget.jslink(self, value=p, bidirectional=True)
|
390
|
2
|
if isinstance(widget, LiteralInput):
|
391
|
0
|
widget.serializer = 'json'
|
392
|
2
|
for p in layout_params:
|
393
|
2
|
widget = style._widgets[p]
|
394
|
2
|
widget.jslink(self, value=p, bidirectional=True)
|
395
|
2
|
if isinstance(widget, LiteralInput):
|
396
|
2
|
widget.serializer = 'json'
|
397
|
|
|
398
|
2
|
if params and layout_params:
|
399
|
2
|
return Tabs(controls.layout[0], style.layout[0])
|
400
|
2
|
elif params:
|
401
|
2
|
return controls.layout[0]
|
402
|
0
|
return style.layout[0]
|
403
|
|
|
404
|
2
|
def jscallback(self, args={}, **callbacks):
|
405
|
|
"""
|
406
|
|
Allows defining a JS callback to be triggered when a property
|
407
|
|
changes on the source object. The keyword arguments define the
|
408
|
|
properties that trigger a callback and the JS code that gets
|
409
|
|
executed.
|
410
|
|
|
411
|
|
Arguments
|
412
|
|
----------
|
413
|
|
args: dict
|
414
|
|
A mapping of objects to make available to the JS callback
|
415
|
|
**callbacks: dict
|
416
|
|
A mapping between properties on the source model and the code
|
417
|
|
to execute when that property changes
|
418
|
|
|
419
|
|
Returns
|
420
|
|
-------
|
421
|
|
callback: Callback
|
422
|
|
The Callback which can be used to disable the callback.
|
423
|
|
"""
|
424
|
|
|
425
|
2
|
from .links import Callback
|
426
|
2
|
for k, v in list(callbacks.items()):
|
427
|
2
|
callbacks[k] = self._rename.get(v, v)
|
428
|
2
|
return Callback(self, code=callbacks, args=args)
|
429
|
|
|
430
|
2
|
def jslink(self, target, code=None, args=None, bidirectional=False, **links):
|
431
|
|
"""
|
432
|
|
Links properties on the source object to those on the target
|
433
|
|
object in JS code. Supports two modes, either specify a
|
434
|
|
mapping between the source and target model properties as
|
435
|
|
keywords or provide a dictionary of JS code snippets which
|
436
|
|
maps from the source parameter to a JS code snippet which is
|
437
|
|
executed when the property changes.
|
438
|
|
|
439
|
|
Arguments
|
440
|
|
----------
|
441
|
|
target: HoloViews object or bokeh Model or panel Viewable
|
442
|
|
The target to link the value to.
|
443
|
|
code: dict
|
444
|
|
Custom code which will be executed when the widget value
|
445
|
|
changes.
|
446
|
|
bidirectional: boolean
|
447
|
|
Whether to link source and target bi-directionally
|
448
|
|
**links: dict
|
449
|
|
A mapping between properties on the source model and the
|
450
|
|
target model property to link it to.
|
451
|
|
|
452
|
|
Returns
|
453
|
|
-------
|
454
|
|
link: GenericLink
|
455
|
|
The GenericLink which can be used unlink the widget and
|
456
|
|
the target model.
|
457
|
|
"""
|
458
|
2
|
if links and code:
|
459
|
0
|
raise ValueError('Either supply a set of properties to '
|
460
|
|
'link as keywords or a set of JS code '
|
461
|
|
'callbacks, not both.')
|
462
|
2
|
elif not links and not code:
|
463
|
0
|
raise ValueError('Declare parameters to link or a set of '
|
464
|
|
'callbacks, neither was defined.')
|
465
|
2
|
if args is None:
|
466
|
2
|
args = {}
|
467
|
|
|
468
|
2
|
mapping = code or links
|
469
|
2
|
for k in mapping:
|
470
|
2
|
if k.startswith('event:'):
|
471
|
0
|
continue
|
472
|
2
|
elif hasattr(self, 'object') and isinstance(self.object, LayoutDOM):
|
473
|
2
|
current = self.object
|
474
|
2
|
for attr in k.split('.'):
|
475
|
2
|
if not hasattr(current, attr):
|
476
|
0
|
raise ValueError(f"Could not resolve {k} on "
|
477
|
|
f"{self.object} model. Ensure "
|
478
|
|
"you jslink an attribute that "
|
479
|
|
"exists on the bokeh model.")
|
480
|
2
|
current = getattr(current, attr)
|
481
|
2
|
elif (k not in self.param and k not in list(self._rename.values())):
|
482
|
2
|
matches = difflib.get_close_matches(k, list(self.param))
|
483
|
2
|
if matches:
|
484
|
2
|
matches = ' Similar parameters include: %r' % matches
|
485
|
|
else:
|
486
|
0
|
matches = ''
|
487
|
2
|
raise ValueError("Could not jslink %r parameter (or property) "
|
488
|
|
"on %s object because it was not found.%s"
|
489
|
|
% (k, type(self).__name__, matches))
|
490
|
2
|
elif (self._source_transforms.get(k, False) is None or
|
491
|
|
self._rename.get(k, False) is None):
|
492
|
2
|
raise ValueError("Cannot jslink %r parameter on %s object, "
|
493
|
|
"the parameter requires a live Python kernel "
|
494
|
|
"to have an effect." % (k, type(self).__name__))
|
495
|
|
|
496
|
2
|
if isinstance(target, Syncable) and code is None:
|
497
|
2
|
for k, p in mapping.items():
|
498
|
2
|
if k.startswith('event:'):
|
499
|
0
|
continue
|
500
|
2
|
elif p not in target.param and p not in list(target._rename.values()):
|
501
|
2
|
matches = difflib.get_close_matches(p, list(target.param))
|
502
|
2
|
if matches:
|
503
|
2
|
matches = ' Similar parameters include: %r' % matches
|
504
|
|
else:
|
505
|
0
|
matches = ''
|
506
|
2
|
raise ValueError("Could not jslink %r parameter (or property) "
|
507
|
|
"on %s object because it was not found.%s"
|
508
|
|
% (p, type(self).__name__, matches))
|
509
|
2
|
elif (target._source_transforms.get(p, False) is None or
|
510
|
|
target._rename.get(p, False) is None):
|
511
|
2
|
raise ValueError("Cannot jslink %r parameter on %s object "
|
512
|
|
"to %r parameter on %s object. It requires "
|
513
|
|
"a live Python kernel to have an effect."
|
514
|
|
% (k, type(self).__name__, p, type(target).__name__))
|
515
|
|
|
516
|
2
|
from .links import Link
|
517
|
2
|
return Link(self, target, properties=links, code=code, args=args,
|
518
|
|
bidirectional=bidirectional)
|