restiny 0.1.2__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,73 +1,111 @@
1
+ from abc import abstractmethod
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
1
5
  from textual import on
2
6
  from textual.app import ComposeResult
3
7
  from textual.containers import VerticalScroll
4
8
  from textual.message import Message
5
- from textual.widgets import Button, Input, Static, Switch
9
+ from textual.widget import Widget
10
+ from textual.widgets import (
11
+ Button,
12
+ ContentSwitcher,
13
+ Input,
14
+ RadioButton,
15
+ RadioSet,
16
+ Switch,
17
+ )
6
18
 
19
+ from restiny.widgets.path_chooser import PathChooser
7
20
 
8
- class DynamicField(Static):
9
- """
10
- Enableable and removable field
11
- """
12
21
 
13
- DEFAULT_CSS = """
14
- DynamicField {
15
- layout: grid;
16
- grid-size: 4 1;
17
- grid-columns: auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
18
- }
19
- """
22
+ class DynamicField(Widget):
23
+ @abstractmethod
24
+ def compose(self) -> ComposeResult: ...
25
+
26
+ @property
27
+ @abstractmethod
28
+ def enabled(self) -> bool: ...
29
+
30
+ @enabled.setter
31
+ @abstractmethod
32
+ def enabled(self, value: bool) -> None: ...
33
+
34
+ @property
35
+ @abstractmethod
36
+ def key(self) -> str: ...
37
+
38
+ @key.setter
39
+ @abstractmethod
40
+ def key(self, value: str) -> None: ...
41
+
42
+ @property
43
+ @abstractmethod
44
+ def value(self) -> str | Path | None: ...
45
+
46
+ @value.setter
47
+ @abstractmethod
48
+ def value(self, value: str | Path | None) -> None: ...
49
+
50
+ @property
51
+ @abstractmethod
52
+ def is_empty(self) -> bool: ...
53
+
54
+ @property
55
+ @abstractmethod
56
+ def is_filled(self) -> bool: ...
20
57
 
21
58
  class Enabled(Message):
22
59
  """
23
60
  Sent when the user enables the field.
24
61
  """
25
62
 
26
- def __init__(self, control: 'DynamicField') -> None:
63
+ def __init__(self, field: 'DynamicField') -> None:
27
64
  super().__init__()
28
- self._control = control
65
+ self.field = field
29
66
 
67
+ @property
30
68
  def control(self) -> 'DynamicField':
31
- return self._control
69
+ return self.field
32
70
 
33
71
  class Disabled(Message):
34
72
  """
35
73
  Sent when the user disables the field.
36
74
  """
37
75
 
38
- def __init__(self, control: 'DynamicField') -> None:
76
+ def __init__(self, field: 'DynamicField') -> None:
39
77
  super().__init__()
40
- self._control = control
78
+ self.field = field
41
79
 
42
80
  @property
43
81
  def control(self) -> 'DynamicField':
44
- return self._control
82
+ return self.field
45
83
 
46
84
  class Empty(Message):
47
85
  """
48
86
  Sent when the key input and value input is empty.
49
87
  """
50
88
 
51
- def __init__(self, control: 'DynamicField') -> None:
89
+ def __init__(self, field: 'DynamicField') -> None:
52
90
  super().__init__()
53
- self._control = control
91
+ self.field = field
54
92
 
55
93
  @property
56
94
  def control(self) -> 'DynamicField':
57
- return self._control
95
+ return self.field
58
96
 
59
97
  class Filled(Message):
60
98
  """
61
99
  Sent when the key input or value input is filled.
62
100
  """
63
101
 
64
- def __init__(self, control: 'DynamicField') -> None:
102
+ def __init__(self, field: 'DynamicField') -> None:
65
103
  super().__init__()
66
- self._control = control
104
+ self.field = field
67
105
 
68
106
  @property
69
107
  def control(self) -> 'DynamicField':
70
- return self._control
108
+ return self.field
71
109
 
72
110
  class RemoveRequested(Message):
73
111
  """
@@ -76,37 +114,63 @@ class DynamicField(Static):
76
114
  to actually remove the field or not.
77
115
  """
78
116
 
79
- def __init__(self, control: 'DynamicField') -> None:
117
+ def __init__(self, field: 'DynamicField') -> None:
80
118
  super().__init__()
81
- self._control = control
119
+ self.field = field
82
120
 
83
121
  @property
84
122
  def control(self) -> 'DynamicField':
85
- return self._control
123
+ return self.field
124
+
125
+
126
+ class TextDynamicField(DynamicField):
127
+ """
128
+ Enableable and removable field
129
+ """
130
+
131
+ DEFAULT_CSS = """
132
+ TextDynamicField {
133
+ width: 100%;
134
+ height: auto;
135
+ layout: grid;
136
+ grid-size: 4 1;
137
+ grid-columns: auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
138
+ }
139
+ """
86
140
 
87
141
  def __init__(
88
142
  self, enabled: bool, key: str, value: str, *args, **kwargs
89
143
  ) -> None:
90
144
  super().__init__(*args, **kwargs)
91
-
92
- # Store initial values temporarily; applied after mounting.
93
145
  self._initial_enabled = enabled
94
146
  self._initial_key = key
95
147
  self._initial_value = value
96
148
 
97
149
  def compose(self) -> ComposeResult:
98
- yield Switch(value=self._initial_enabled, tooltip='Send this field?')
99
- yield Input(value=self._initial_key, placeholder='Key', id='input-key')
150
+ yield Switch(
151
+ value=self._initial_enabled,
152
+ tooltip='Send this field?',
153
+ id='enabled',
154
+ )
100
155
  yield Input(
101
- value=self._initial_value, placeholder='Value', id='input-value'
156
+ value=self._initial_key,
157
+ placeholder='Key',
158
+ select_on_focus=False,
159
+ id='key',
102
160
  )
103
- yield Button(label='➖', tooltip='Remove field')
161
+ yield Input(
162
+ value=self._initial_value,
163
+ placeholder='Value',
164
+ select_on_focus=False,
165
+ id='value',
166
+ )
167
+ yield Button(label='➖', tooltip='Remove field', id='remove')
104
168
 
105
- def on_mount(self) -> None:
106
- self.enabled_switch: Switch = self.query_one(Switch)
107
- self.key_input: Input = self.query_one('#input-key')
108
- self.value_input: Input = self.query_one('#input-value')
109
- self.remove_button: Button = self.query_one(Button)
169
+ async def on_mount(self) -> None:
170
+ self.enabled_switch = self.query_one('#enabled', Switch)
171
+ self.key_input = self.query_one('#key', Input)
172
+ self.value_input = self.query_one('#value', Input)
173
+ self.remove_button = self.query_one('#remove', Button)
110
174
 
111
175
  @property
112
176
  def enabled(self) -> bool:
@@ -132,57 +196,258 @@ class DynamicField(Static):
132
196
  def value(self, value: str) -> None:
133
197
  self.value_input.value = value
134
198
 
199
+ @property
200
+ def is_filled(self) -> bool:
201
+ return len(self.key_input.value) > 0 or len(self.value_input.value) > 0
202
+
135
203
  @property
136
204
  def is_empty(self) -> bool:
137
- return (
138
- len(self.key_input.value) == 0 and len(self.value_input.value) == 0
205
+ return not self.is_filled
206
+
207
+ @on(Switch.Changed, '#enabled')
208
+ def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
209
+ if message.value is True:
210
+ self.post_message(self.Enabled(field=self))
211
+ elif message.value is False:
212
+ self.post_message(message=self.Disabled(field=self))
213
+
214
+ @on(Input.Changed, '#key')
215
+ @on(Input.Changed, '#value')
216
+ def on_input_changed(self, message: Input.Changed) -> None:
217
+ self.enabled_switch.value = True
218
+
219
+ if self.is_empty:
220
+ self.post_message(message=self.Empty(field=self))
221
+ elif self.is_filled:
222
+ self.post_message(message=self.Filled(field=self))
223
+
224
+ @on(Button.Pressed, '#remove')
225
+ def on_remove_requested(self, message: Button.Pressed) -> None:
226
+ self.post_message(self.RemoveRequested(field=self))
227
+
228
+
229
+ class _ValueKind(StrEnum):
230
+ TEXT = 'text'
231
+ FILE = 'file'
232
+
233
+
234
+ class TextOrFileDynamicField(DynamicField):
235
+ DEFAULT_CSS = """
236
+ TextOrFileDynamicField {
237
+ width: 100%;
238
+ height: auto;
239
+ layout: grid;
240
+ grid-size: 5 1;
241
+ grid-columns: auto auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
242
+ }
243
+
244
+ TextOrFileDynamicField > RadioSet > RadioButton.-selected {
245
+ background: $surface;
246
+ }
247
+
248
+ TextOrFileDynamicField > ContentSwitcher > PathChooser{
249
+ margin-right: 1;
250
+ }
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ enabled: bool = False,
256
+ key: str = '',
257
+ value: str | Path | None = '',
258
+ value_kind: _ValueKind = _ValueKind.TEXT,
259
+ *args,
260
+ **kwargs,
261
+ ) -> None:
262
+ super().__init__(*args, **kwargs)
263
+ self._initial_enabled = enabled
264
+ self._initial_key = key
265
+ self._initial_value = value
266
+ self._initial_value_kind = value_kind
267
+
268
+ def compose(self) -> ComposeResult:
269
+ with RadioSet(id='value-kind', compact=True):
270
+ yield RadioButton(
271
+ label=_ValueKind.TEXT,
272
+ value=bool(self._initial_value_kind == _ValueKind.TEXT),
273
+ id='value-kind-text',
274
+ )
275
+ yield RadioButton(
276
+ label=_ValueKind.FILE,
277
+ value=bool(self._initial_value_kind == _ValueKind.FILE),
278
+ id='value-kind-file',
279
+ )
280
+ yield Switch(
281
+ value=self._initial_enabled,
282
+ tooltip='Send this field?',
283
+ id='enabled',
139
284
  )
285
+ yield Input(
286
+ value=self._initial_key,
287
+ placeholder='Key',
288
+ select_on_focus=False,
289
+ id='key',
290
+ )
291
+ with ContentSwitcher(
292
+ initial='value-text'
293
+ if self._initial_value_kind == _ValueKind.TEXT
294
+ else 'value-file',
295
+ id='value-kind-switcher',
296
+ ):
297
+ yield Input(
298
+ value=self._initial_value
299
+ if self._initial_value_kind == _ValueKind.TEXT
300
+ else '',
301
+ placeholder='Value',
302
+ select_on_focus=False,
303
+ id='value-text',
304
+ )
305
+ yield PathChooser.file(
306
+ path=self._initial_value
307
+ if self._initial_value_kind == _ValueKind.FILE
308
+ else None,
309
+ id='value-file',
310
+ )
311
+ yield Button(label='➖', tooltip='Remove field', id='remove')
312
+
313
+ def on_mount(self) -> None:
314
+ self.value_kind_switcher = self.query_one(
315
+ '#value-kind-switcher', ContentSwitcher
316
+ )
317
+
318
+ self.value_kind_radioset = self.query_one('#value-kind', RadioSet)
319
+ self.value_kind_text_radio_button = self.query_one(
320
+ '#value-kind-text', RadioButton
321
+ )
322
+ self.value_kind_file_radio_button = self.query_one(
323
+ '#value-kind-file', RadioButton
324
+ )
325
+ self.enabled_switch = self.query_one('#enabled', Switch)
326
+ self.key_input = self.query_one('#key', Input)
327
+ self.value_text_input = self.query_one('#value-text', Input)
328
+ self.value_file_input = self.query_one('#value-file', PathChooser)
329
+ self.remove_button = self.query_one('#remove', Button)
330
+
331
+ @property
332
+ def enabled(self) -> bool:
333
+ return self.enabled_switch.value
334
+
335
+ @enabled.setter
336
+ def enabled(self, value: bool) -> None:
337
+ self.enabled_switch.value = value
338
+
339
+ @property
340
+ def key(self) -> str:
341
+ return self.key_input.value
342
+
343
+ @key.setter
344
+ def key(self, value: str) -> None:
345
+ self.key_input.value = value
346
+
347
+ @property
348
+ def value(self) -> str | Path | None:
349
+ if self.value_kind == _ValueKind.TEXT:
350
+ return self.value_text_input.value
351
+ elif self.value_kind == _ValueKind.FILE:
352
+ return self.value_file_input.path
353
+
354
+ @value.setter
355
+ def value(self, value: str | Path | None) -> None:
356
+ if isinstance(value, str):
357
+ self.value_text_input.value = value
358
+ elif isinstance(value, Path) or value is None:
359
+ self.value_file_input.path = value
360
+
361
+ @property
362
+ def value_kind(self) -> _ValueKind:
363
+ return _ValueKind(self.value_kind_radioset.pressed_button.label)
364
+
365
+ @value_kind.setter
366
+ def value_kind(self, value: _ValueKind) -> None:
367
+ if value == _ValueKind.TEXT:
368
+ self.value_kind_switcher.current = 'value-text'
369
+ self.value_kind_text_radio_button.value = True
370
+ elif value == _ValueKind.FILE:
371
+ self.value_kind_switcher.current = 'value-file'
372
+ self.value_kind_file_radio_button.value = True
140
373
 
141
374
  @property
142
375
  def is_filled(self) -> bool:
143
- return len(self.key_input.value) > 0 or len(self.value_input.value) > 0
376
+ if len(self.key_input.value) > 0:
377
+ return True
378
+ elif (
379
+ self.value_kind == _ValueKind.TEXT
380
+ and len(self.value_text_input.value) > 0
381
+ ):
382
+ return True
383
+ elif (
384
+ self.value_kind == _ValueKind.FILE
385
+ and self.value_file_input.path is not None
386
+ ):
387
+ return True
388
+ else:
389
+ return False
390
+
391
+ @property
392
+ def is_empty(self) -> bool:
393
+ return not self.is_filled
394
+
395
+ @on(RadioSet.Changed, '#value-kind')
396
+ def on_value_kind_changed(self, message: RadioSet.Changed) -> None:
397
+ self.value_kind = _ValueKind(message.pressed.label)
144
398
 
145
- @on(Switch.Changed)
399
+ @on(Switch.Changed, '#enabled')
146
400
  def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
147
401
  if message.value is True:
148
- self.post_message(self.Enabled(control=self))
402
+ self.post_message(self.Enabled(field=self))
149
403
  elif message.value is False:
150
- self.post_message(message=self.Disabled(control=self))
404
+ self.post_message(message=self.Disabled(field=self))
151
405
 
152
- @on(Input.Changed)
153
- def on_input_changed(self, message: Input.Changed) -> None:
406
+ @on(Input.Changed, '#key')
407
+ @on(Input.Changed, '#value-text')
408
+ @on(PathChooser.Changed, '#value-file')
409
+ def on_input_changed(
410
+ self, message: Input.Changed | PathChooser.Changed
411
+ ) -> None:
154
412
  self.enabled_switch.value = True
155
413
 
156
414
  if self.is_empty:
157
- self.post_message(message=self.Empty(control=self))
415
+ self.post_message(message=self.Empty(field=self))
158
416
  elif self.is_filled:
159
- self.post_message(message=self.Filled(control=self))
417
+ self.post_message(message=self.Filled(field=self))
160
418
 
161
- @on(Button.Pressed)
419
+ @on(Button.Pressed, '#remove')
162
420
  def on_remove_requested(self, message: Button.Pressed) -> None:
163
- self.post_message(self.RemoveRequested(control=self))
421
+ self.post_message(self.RemoveRequested(field=self))
164
422
 
165
423
 
166
- class DynamicFields(Static):
424
+ class DynamicFields(Widget):
167
425
  """
168
426
  Enableable and removable fields
169
427
  """
170
428
 
429
+ DEFAULT_CSS = """
430
+ DynamicFields {
431
+ width: auto;
432
+ height: 1fr;
433
+ }
434
+ """
435
+
171
436
  class FieldEmpty(Message):
172
437
  """
173
438
  Sent when one of the fields becomes empty.
174
439
  """
175
440
 
176
441
  def __init__(
177
- self, control: 'DynamicFields', field: DynamicField
442
+ self, fields: 'DynamicFields', field: DynamicField
178
443
  ) -> None:
179
444
  super().__init__()
180
- self._control = control
445
+ self.fields = fields
181
446
  self.field = field
182
447
 
183
448
  @property
184
449
  def control(self) -> 'DynamicFields':
185
- return self._control
450
+ return self.fields
186
451
 
187
452
  class FieldFilled(Message):
188
453
  """
@@ -190,29 +455,31 @@ class DynamicFields(Static):
190
455
  """
191
456
 
192
457
  def __init__(
193
- self, control: 'DynamicFields', field: DynamicField
458
+ self, fields: 'DynamicFields', field: DynamicField
194
459
  ) -> None:
195
460
  super().__init__()
196
- self._control = control
461
+ self.fields = fields
197
462
  self.field = field
198
463
 
199
464
  @property
200
465
  def control(self) -> 'DynamicFields':
201
- return self._control
466
+ return self.fields
202
467
 
203
- def __init__(self, fields: list[DynamicField], *args, **kwargs) -> None:
468
+ def __init__(
469
+ self,
470
+ fields: list[DynamicField],
471
+ *args,
472
+ **kwargs,
473
+ ) -> None:
204
474
  super().__init__(*args, **kwargs)
205
- self._initial_fields = fields
475
+ self._fields = fields
206
476
 
207
477
  def compose(self) -> ComposeResult:
208
- yield VerticalScroll()
478
+ with VerticalScroll(can_focus=False):
479
+ yield from self._fields
209
480
 
210
- async def on_mount(self) -> None:
211
- self.fields_container: VerticalScroll = self.query_one(VerticalScroll)
212
-
213
- # Set initial_fields
214
- for field in self._initial_fields:
215
- await self.add_field(field=field)
481
+ def on_mount(self) -> None:
482
+ self.fields_container = self.query_one(VerticalScroll)
216
483
 
217
484
  @property
218
485
  def fields(self) -> list[DynamicField]:
@@ -220,14 +487,14 @@ class DynamicFields(Static):
220
487
 
221
488
  @property
222
489
  def empty_fields(self) -> list[DynamicField]:
223
- return [field for field in self.query(DynamicField) if field.is_empty]
490
+ return [field for field in self.fields if field.is_empty]
224
491
 
225
492
  @property
226
493
  def filled_fields(self) -> list[DynamicField]:
227
- return [field for field in self.query(DynamicField) if field.is_filled]
494
+ return [field for field in self.fields if field.is_filled]
228
495
 
229
496
  @property
230
- def values(self) -> list[dict[str, str | bool]]:
497
+ def values(self) -> list[dict[str, str | bool | Path | None]]:
231
498
  return [
232
499
  {
233
500
  'enabled': field.enabled,
@@ -237,51 +504,63 @@ class DynamicFields(Static):
237
504
  for field in self.fields
238
505
  ]
239
506
 
507
+ async def add_field(self, field: DynamicField) -> None:
508
+ await self.fields_container.mount(field)
509
+
510
+ def remove_field(self, field: DynamicField) -> None:
511
+ field.add_class('hidden')
512
+ field.remove()
513
+
240
514
  @on(DynamicField.Empty)
241
- async def on_field_is_empty(self, message: DynamicField.Empty) -> None:
242
- await self.remove_field(field=message.control)
515
+ def _on_field_is_empty(self, message: DynamicField.Empty) -> None:
516
+ self._focus_neighbor_field_then_remove(field=message.field)
243
517
  self.post_message(
244
- message=self.FieldEmpty(control=self, field=message.control)
518
+ message=self.FieldEmpty(fields=self, field=message.field)
245
519
  )
246
520
 
247
521
  @on(DynamicField.Filled)
248
- async def on_field_is_filled(self, message: DynamicField.Filled) -> None:
522
+ async def _on_field_is_filled(self, message: DynamicField.Filled) -> None:
249
523
  if len(self.empty_fields) == 0:
250
- await self.add_field(
251
- field=DynamicField(enabled=False, key='', value='')
252
- )
524
+ field = message.field
525
+ if isinstance(field, TextDynamicField):
526
+ await self.add_field(
527
+ TextDynamicField(enabled=False, key='', value='')
528
+ )
529
+ elif isinstance(field, TextOrFileDynamicField):
530
+ await self.add_field(
531
+ TextOrFileDynamicField(
532
+ enabled=False,
533
+ key='',
534
+ value='',
535
+ value_kind=_ValueKind.TEXT,
536
+ )
537
+ )
253
538
 
254
539
  self.post_message(
255
- message=self.FieldFilled(control=self, field=message.control)
540
+ message=self.FieldFilled(fields=self, field=message.field)
256
541
  )
257
542
 
258
543
  @on(DynamicField.RemoveRequested)
259
- async def on_field_remove_requested(
544
+ def _on_field_remove_requested(
260
545
  self, message: DynamicField.RemoveRequested
261
546
  ) -> None:
262
- await self.remove_field(field=message.control)
547
+ self._focus_neighbor_field_then_remove(field=message.field)
263
548
 
264
- async def add_field(self, field: DynamicField) -> None:
265
- await self.fields_container.mount(field)
266
-
267
- async def remove_field(self, field: DynamicField) -> None:
549
+ def _focus_neighbor_field_then_remove(self, field: DynamicField) -> None:
268
550
  if len(self.fields) == 1:
269
551
  self.app.bell()
270
552
  return
271
- elif self.fields[-1] is field: # Last field
553
+ elif field is self.fields[-1]:
272
554
  self.app.bell()
273
555
  return
274
556
 
275
- if self.fields[0] is field: # First field
276
- self.app.screen.focus_next()
277
- self.app.screen.focus_next()
278
- self.app.screen.focus_next()
279
- self.app.screen.focus_next()
280
- elif self.fields[-2] is field: # Penultimate field
281
- self.app.screen.focus_previous()
282
- self.app.screen.focus_previous()
283
- self.app.screen.focus_previous()
284
- self.app.screen.focus_previous()
557
+ field_index = self.fields.index(field)
285
558
 
286
- field.add_class('hidden')
287
- await field.remove() # Maybe the `await` is unnecessary
559
+ neighbor_field = None
560
+ if field_index == 0:
561
+ neighbor_field = self.fields[field_index + 1]
562
+ else:
563
+ neighbor_field = self.fields[field_index - 1]
564
+
565
+ self.app.set_focus(neighbor_field.query_one(Input))
566
+ self.remove_field(field=field)