restiny 0.2.1__py3-none-any.whl → 0.5.0__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,17 +1,11 @@
1
- from dataclasses import dataclass
2
-
3
1
  from textual import on
4
2
  from textual.app import ComposeResult
3
+ from textual.containers import Horizontal
5
4
  from textual.message import Message
6
- from textual.widgets import Button, ContentSwitcher, Input, Select, Static
5
+ from textual.widgets import Button, ContentSwitcher, Select, Static
7
6
 
8
7
  from restiny.enums import HTTPMethod
9
-
10
-
11
- @dataclass
12
- class URLAreaData:
13
- method: str
14
- url: str
8
+ from restiny.widgets import CustomInput
15
9
 
16
10
 
17
11
  class URLArea(Static):
@@ -20,9 +14,8 @@ class URLArea(Static):
20
14
  BORDER_TITLE = 'URL'
21
15
  DEFAULT_CSS = """
22
16
  URLArea {
23
- layout: grid;
24
- grid-size: 3 1;
25
- grid-columns: 1fr 6fr 1fr;
17
+ width: 1fr;
18
+ height: auto;
26
19
  border: heavy black;
27
20
  border-title-color: gray;
28
21
  }
@@ -49,25 +42,34 @@ class URLArea(Static):
49
42
  self._request_pending = False
50
43
 
51
44
  def compose(self) -> ComposeResult:
52
- yield Select.from_values(
53
- values=HTTPMethod.values(), allow_blank=False, id='method'
54
- )
55
- yield Input(placeholder='Enter URL', select_on_focus=False, id='url')
56
- with ContentSwitcher(
57
- id='request-button-switcher', initial='send-request'
58
- ):
59
- yield Button(
60
- label='Send Request',
61
- id='send-request',
45
+ with Horizontal(classes='h-auto'):
46
+ yield Select.from_values(
47
+ values=[method.value for method in HTTPMethod],
48
+ allow_blank=False,
62
49
  classes='w-1fr',
63
- variant='default',
50
+ id='method',
64
51
  )
65
- yield Button(
66
- label='Cancel Request',
67
- id='cancel-request',
68
- classes='w-1fr',
69
- variant='error',
52
+ yield CustomInput(
53
+ placeholder='Enter URL',
54
+ select_on_focus=False,
55
+ classes='w-5fr',
56
+ id='url',
70
57
  )
58
+ with ContentSwitcher(
59
+ id='request-button-switcher', initial='send-request'
60
+ ):
61
+ yield Button(
62
+ label='Send Request',
63
+ id='send-request',
64
+ classes='w-1fr',
65
+ variant='default',
66
+ )
67
+ yield Button(
68
+ label='Cancel Request',
69
+ id='cancel-request',
70
+ classes='w-1fr',
71
+ variant='error',
72
+ )
71
73
 
72
74
  def on_mount(self) -> None:
73
75
  self._request_button_switcher = self.query_one(
@@ -75,16 +77,10 @@ class URLArea(Static):
75
77
  )
76
78
 
77
79
  self.method_select = self.query_one('#method', Select)
78
- self.url_input = self.query_one('#url', Input)
80
+ self.url_input = self.query_one('#url', CustomInput)
79
81
  self.send_request_button = self.query_one('#send-request', Button)
80
82
  self.cancel_request_button = self.query_one('#cancel-request', Button)
81
83
 
82
- def get_data(self) -> URLAreaData:
83
- return URLAreaData(
84
- method=self.method_select.value,
85
- url=self.url_input.value,
86
- )
87
-
88
84
  @property
89
85
  def request_pending(self) -> bool:
90
86
  return self._request_pending
@@ -98,10 +94,26 @@ class URLArea(Static):
98
94
 
99
95
  self._request_pending = value
100
96
 
97
+ @property
98
+ def method(self) -> HTTPMethod:
99
+ return self.method_select.value
100
+
101
+ @method.setter
102
+ def method(self, value: HTTPMethod) -> None:
103
+ self.method_select.value = value
104
+
105
+ @property
106
+ def url(self) -> str:
107
+ return self.url_input.value
108
+
109
+ @url.setter
110
+ def url(self, value: str) -> None:
111
+ self.url_input.value = value
112
+
101
113
  @on(Button.Pressed, '#send-request')
102
- @on(Input.Submitted, '#url')
114
+ @on(CustomInput.Submitted, '#url')
103
115
  def _on_send_request(
104
- self, message: Button.Pressed | Input.Submitted
116
+ self, message: Button.Pressed | CustomInput.Submitted
105
117
  ) -> None:
106
118
  if self.request_pending:
107
119
  return
@@ -109,7 +121,7 @@ class URLArea(Static):
109
121
  self.post_message(message=self.SendRequest())
110
122
 
111
123
  @on(Button.Pressed, '#cancel-request')
112
- @on(Input.Submitted, '#url')
124
+ @on(CustomInput.Submitted, '#url')
113
125
  def _on_cancel_request(self, message: Button.Pressed) -> None:
114
126
  if not self.request_pending:
115
127
  return
restiny/utils.py CHANGED
@@ -8,32 +8,43 @@ import httpx
8
8
  def build_curl_cmd(
9
9
  method: str,
10
10
  url: str,
11
- headers: dict[str, str] = {},
12
- params: dict[str, str] = {},
13
- raw_body: str | None = None,
14
- form_urlencoded: dict[str, str] = {},
15
- form_multipart: dict[str, str | Path] = {},
16
- files: list[Path] = [],
11
+ headers: dict[str, str] | None = None,
12
+ params: dict[str, str] | None = None,
13
+ body_raw: str | None = None,
14
+ body_form_urlencoded: dict[str, str] | None = None,
15
+ body_form_multipart: dict[str, str | Path] | None = None,
16
+ body_files: list[Path] | None = None,
17
+ auth_basic: tuple[str, str] | None = None,
18
+ auth_bearer: str | None = None,
19
+ auth_api_key_header: tuple[str, str] | None = None,
20
+ auth_api_key_param: tuple[str, str] | None = None,
21
+ auth_digest: tuple[str, str] | None = None,
17
22
  ) -> str:
18
23
  cmd_parts = ['curl']
24
+
25
+ # Method
19
26
  cmd_parts.extend(['--request', method])
20
27
 
21
- url = str(httpx.URL(url).copy_merge_params(params))
28
+ # URL + Params
29
+ if params:
30
+ url = str(httpx.URL(url).copy_merge_params(params))
22
31
  cmd_parts.extend(['--url', shlex.quote(url)])
23
32
 
33
+ # Headers
24
34
  for header_key, header_value in headers.items():
25
35
  header = f'{header_key}: {header_value}'
26
36
  cmd_parts.extend(['--header', shlex.quote(header)])
27
37
 
28
- if raw_body:
29
- cmd_parts.extend(['--data', shlex.quote(raw_body)])
30
- elif form_urlencoded:
31
- for form_key, form_value in form_urlencoded.items():
38
+ # Body
39
+ if body_raw:
40
+ cmd_parts.extend(['--data', shlex.quote(body_raw)])
41
+ elif body_form_urlencoded:
42
+ for form_key, form_value in body_form_urlencoded.items():
32
43
  cmd_parts.extend(
33
44
  ['--data', shlex.quote(f'{form_key}={form_value}')]
34
45
  )
35
- elif form_multipart:
36
- for form_key, form_value in form_multipart.items():
46
+ elif body_form_multipart:
47
+ for form_key, form_value in body_form_multipart.items():
37
48
  if isinstance(form_value, str):
38
49
  cmd_parts.extend(
39
50
  ['--form', shlex.quote(f'{form_key}={form_value}')]
@@ -42,10 +53,29 @@ def build_curl_cmd(
42
53
  cmd_parts.extend(
43
54
  ['--form', shlex.quote(f'{form_key}=@{form_value}')]
44
55
  )
45
- elif files:
46
- for file in files:
56
+ elif body_files:
57
+ for file in body_files:
47
58
  cmd_parts.extend(['--data', shlex.quote(f'@{file}')])
48
59
 
60
+ # Auth
61
+ if auth_basic:
62
+ user, pwd = auth_basic
63
+ cmd_parts.extend(['--user', shlex.quote(f'{user}:{pwd}')])
64
+ elif auth_bearer:
65
+ token = auth_bearer
66
+ cmd_parts.extend(['--header', shlex.quote(f'Authorization: {token}')])
67
+ elif auth_api_key_header:
68
+ key, value = auth_api_key_header
69
+ cmd_parts.extend(['--header', shlex.quote(f'{key}: {value}')])
70
+ elif auth_api_key_param:
71
+ key, value = auth_api_key_param
72
+ url_arg_index = cmd_parts.index('--url')
73
+ new_url = str(httpx.URL(url).copy_merge_params({key: value}))
74
+ cmd_parts[url_arg_index + 1] = shlex.quote(new_url)
75
+ elif auth_digest:
76
+ user, pwd = auth_digest
77
+ cmd_parts.extend(['--digest', '--user', shlex.quote(f'{user}:{pwd}')])
78
+
49
79
  return ' '.join(cmd_parts)
50
80
 
51
81
 
@@ -102,3 +132,10 @@ def first_char_non_empty(text: str) -> int | None:
102
132
 
103
133
  def seconds_to_milliseconds(seconds: int | float) -> int:
104
134
  return round(seconds * 1000)
135
+
136
+
137
+ def shorten_string(value: str, max_lenght: int, elipsis: str = '..') -> str:
138
+ if len(value) <= max_lenght:
139
+ return value
140
+
141
+ return value[: max_lenght - len(elipsis)] + elipsis
@@ -2,15 +2,29 @@
2
2
  This module contains reusable widgets used in the DataFox interface.
3
3
  """
4
4
 
5
+ from restiny.widgets.collections_tree import CollectionsTree
6
+ from restiny.widgets.confirm_prompt import ConfirmPrompt, ConfirmPromptResult
5
7
  from restiny.widgets.custom_directory_tree import CustomDirectoryTree
8
+ from restiny.widgets.custom_input import CustomInput
6
9
  from restiny.widgets.custom_text_area import CustomTextArea
7
- from restiny.widgets.dynamic_fields import DynamicFields, TextDynamicField
10
+ from restiny.widgets.dynamic_fields import (
11
+ DynamicFields,
12
+ TextDynamicField,
13
+ TextOrFileDynamicField,
14
+ )
15
+ from restiny.widgets.password_input import PasswordInput
8
16
  from restiny.widgets.path_chooser import PathChooser
9
17
 
10
18
  __all__ = [
11
19
  'TextDynamicField',
20
+ 'TextOrFileDynamicField',
12
21
  'DynamicFields',
13
22
  'CustomDirectoryTree',
14
23
  'CustomTextArea',
15
24
  'PathChooser',
25
+ 'PasswordInput',
26
+ 'CustomInput',
27
+ 'CollectionsTree',
28
+ 'ConfirmPrompt',
29
+ 'ConfirmPromptResult',
16
30
  ]
@@ -0,0 +1,70 @@
1
+ from textual.widgets import Tree
2
+ from textual.widgets.tree import TreeNode
3
+
4
+ from restiny.enums import HTTPMethod
5
+
6
+
7
+ class CollectionsTree(Tree):
8
+ show_root = False
9
+
10
+ def on_mount(self) -> None:
11
+ self.node_by_id: dict[int | None, TreeNode] = {}
12
+ self.node_by_id[None] = self.root
13
+ self.root.data = {'name': '/', 'id': None}
14
+
15
+ @property
16
+ def current_parent_folder(self) -> TreeNode:
17
+ if not self.cursor_node:
18
+ return self.root
19
+
20
+ return self.cursor_node.parent
21
+
22
+ @property
23
+ def current_folder(self) -> TreeNode:
24
+ if not self.cursor_node:
25
+ return self.root
26
+
27
+ if self.cursor_node.allow_expand:
28
+ return self.cursor_node
29
+ else:
30
+ return self.cursor_node.parent
31
+
32
+ def add_folder(
33
+ self, parent_node: TreeNode | None, name: str, id: int
34
+ ) -> TreeNode:
35
+ parent_node = parent_node or self.root
36
+
37
+ node = parent_node.add(label=name)
38
+ node.data = {
39
+ 'name': name,
40
+ 'id': id,
41
+ }
42
+ self.node_by_id[id] = node
43
+ return node
44
+
45
+ def add_request(
46
+ self, parent_node: TreeNode | None, method: str, name: str, id: int
47
+ ) -> TreeNode:
48
+ parent_node = parent_node or self.root
49
+
50
+ method_to_color = {
51
+ HTTPMethod.GET: '#00cc66', # green
52
+ HTTPMethod.POST: '#ffcc00', # yellow
53
+ HTTPMethod.PUT: '#3388ff', # blue
54
+ HTTPMethod.PATCH: '#00b3b3', # teal
55
+ HTTPMethod.DELETE: '#ff3333', # red
56
+ HTTPMethod.HEAD: '#808080', # gray
57
+ HTTPMethod.OPTIONS: '#cc66ff', # magenta
58
+ HTTPMethod.CONNECT: '#ff9966', # orange
59
+ HTTPMethod.TRACE: '#6666ff', # violet
60
+ }
61
+ node = parent_node.add_leaf(
62
+ label=f'[{method_to_color[method]}]{method}[/] {name}'
63
+ ) # noqa
64
+ node.data = {
65
+ 'method': method,
66
+ 'name': name,
67
+ 'id': id,
68
+ }
69
+ self.node_by_id[id] = node
70
+ return node
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label
9
+
10
+
11
+ @dataclass
12
+ class ConfirmPromptResult:
13
+ confirmed: bool
14
+
15
+
16
+ class ConfirmPrompt(ModalScreen):
17
+ DEFAULT_CSS = """
18
+ ConfirmPrompt {
19
+ align: center middle;
20
+ }
21
+
22
+ #modal-content {
23
+ border: heavy black;
24
+ border-title-color: gray;
25
+ background: $surface;
26
+ width: auto;
27
+ height: auto;
28
+ max-width: 40%;
29
+ text-align: center;
30
+ }
31
+
32
+ Horizontal {
33
+ align: center middle;
34
+ }
35
+
36
+ #message {
37
+ }
38
+ """
39
+ AUTO_FOCUS = '#confirm'
40
+
41
+ BINDINGS = [
42
+ Binding(
43
+ key='escape',
44
+ action='dismiss',
45
+ description='Quit the screen',
46
+ show=False,
47
+ ),
48
+ ]
49
+
50
+ def __init__(self, message: str = 'Are you sure?') -> None:
51
+ super().__init__()
52
+ self._message = message
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Vertical(id='modal-content'):
56
+ with Horizontal(classes='w-1fr h-auto mt-1'):
57
+ yield Label(self._message, id='message')
58
+ with Horizontal(classes='w-1fr h-auto mt-1'):
59
+ yield Button(label='Cancel', classes='w-1fr', id='cancel')
60
+ yield Button(label='Confirm', classes='w-1fr', id='confirm')
61
+
62
+ def on_mount(self) -> None:
63
+ self.modal_content = self.query_one('#modal-content', Vertical)
64
+ self.message_label = self.query_one('#message', Label)
65
+ self.cancel_button = self.query_one('#cancel', Button)
66
+ self.confirm_button = self.query_one('#confirm', Button)
67
+
68
+ self.modal_content.border_title = 'Confirm'
69
+
70
+ @on(Button.Pressed, '#cancel')
71
+ def _on_cancel(self, message: Button.Pressed) -> None:
72
+ self.dismiss(result=ConfirmPromptResult(confirmed=False))
73
+
74
+ @on(Button.Pressed, '#confirm')
75
+ def _on_confirm(self, message: Button.Pressed) -> None:
76
+ self.dismiss(result=ConfirmPromptResult(confirmed=True))
@@ -0,0 +1,20 @@
1
+ from textual import on
2
+ from textual.binding import Binding
3
+ from textual.events import Blur
4
+ from textual.widgets import Input
5
+
6
+
7
+ class CustomInput(Input):
8
+ BINDINGS = [
9
+ Binding(
10
+ key='ctrl+a',
11
+ action='select_all',
12
+ description='Select all text',
13
+ show=False,
14
+ ),
15
+ ]
16
+
17
+ @on(Blur)
18
+ def on_blur(self, event: Blur) -> None:
19
+ self.selection = 0, 0
20
+ self.cursor_position = len(self.value)
@@ -10,12 +10,12 @@ from textual.widget import Widget
10
10
  from textual.widgets import (
11
11
  Button,
12
12
  ContentSwitcher,
13
- Input,
14
13
  RadioButton,
15
14
  RadioSet,
16
15
  Switch,
17
16
  )
18
17
 
18
+ from restiny.widgets import CustomInput
19
19
  from restiny.widgets.path_chooser import PathChooser
20
20
 
21
21
 
@@ -142,24 +142,24 @@ class TextDynamicField(DynamicField):
142
142
  self, enabled: bool, key: str, value: str, *args, **kwargs
143
143
  ) -> None:
144
144
  super().__init__(*args, **kwargs)
145
- self._initial_enabled = enabled
146
- self._initial_key = key
147
- self._initial_value = value
145
+ self._enabled = enabled
146
+ self._key = key
147
+ self._value = value
148
148
 
149
149
  def compose(self) -> ComposeResult:
150
150
  yield Switch(
151
- value=self._initial_enabled,
151
+ value=self._enabled,
152
152
  tooltip='Send this field?',
153
153
  id='enabled',
154
154
  )
155
- yield Input(
156
- value=self._initial_key,
155
+ yield CustomInput(
156
+ value=self._key,
157
157
  placeholder='Key',
158
158
  select_on_focus=False,
159
159
  id='key',
160
160
  )
161
- yield Input(
162
- value=self._initial_value,
161
+ yield CustomInput(
162
+ value=self._value,
163
163
  placeholder='Value',
164
164
  select_on_focus=False,
165
165
  id='value',
@@ -168,8 +168,8 @@ class TextDynamicField(DynamicField):
168
168
 
169
169
  async def on_mount(self) -> None:
170
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)
171
+ self.key_input = self.query_one('#key', CustomInput)
172
+ self.value_input = self.query_one('#value', CustomInput)
173
173
  self.remove_button = self.query_one('#remove', Button)
174
174
 
175
175
  @property
@@ -211,9 +211,9 @@ class TextDynamicField(DynamicField):
211
211
  elif message.value is False:
212
212
  self.post_message(message=self.Disabled(field=self))
213
213
 
214
- @on(Input.Changed, '#key')
215
- @on(Input.Changed, '#value')
216
- def on_input_changed(self, message: Input.Changed) -> None:
214
+ @on(CustomInput.Changed, '#key')
215
+ @on(CustomInput.Changed, '#value')
216
+ def on_input_changed(self, message: CustomInput.Changed) -> None:
217
217
  self.enabled_switch.value = True
218
218
 
219
219
  if self.is_empty:
@@ -260,51 +260,51 @@ class TextOrFileDynamicField(DynamicField):
260
260
  **kwargs,
261
261
  ) -> None:
262
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
263
+ self._enabled = enabled
264
+ self._key = key
265
+ self._value = value
266
+ self._value_kind = value_kind
267
267
 
268
268
  def compose(self) -> ComposeResult:
269
269
  with RadioSet(id='value-kind', compact=True):
270
270
  yield RadioButton(
271
271
  label=_ValueKind.TEXT,
272
- value=bool(self._initial_value_kind == _ValueKind.TEXT),
272
+ value=bool(self._value_kind == _ValueKind.TEXT),
273
273
  id='value-kind-text',
274
274
  )
275
275
  yield RadioButton(
276
276
  label=_ValueKind.FILE,
277
- value=bool(self._initial_value_kind == _ValueKind.FILE),
277
+ value=bool(self._value_kind == _ValueKind.FILE),
278
278
  id='value-kind-file',
279
279
  )
280
280
  yield Switch(
281
- value=self._initial_enabled,
281
+ value=self._enabled,
282
282
  tooltip='Send this field?',
283
283
  id='enabled',
284
284
  )
285
- yield Input(
286
- value=self._initial_key,
285
+ yield CustomInput(
286
+ value=self._key,
287
287
  placeholder='Key',
288
288
  select_on_focus=False,
289
289
  id='key',
290
290
  )
291
291
  with ContentSwitcher(
292
292
  initial='value-text'
293
- if self._initial_value_kind == _ValueKind.TEXT
293
+ if self._value_kind == _ValueKind.TEXT
294
294
  else 'value-file',
295
295
  id='value-kind-switcher',
296
296
  ):
297
- yield Input(
298
- value=self._initial_value
299
- if self._initial_value_kind == _ValueKind.TEXT
297
+ yield CustomInput(
298
+ value=self._value
299
+ if self._value_kind == _ValueKind.TEXT
300
300
  else '',
301
301
  placeholder='Value',
302
302
  select_on_focus=False,
303
303
  id='value-text',
304
304
  )
305
305
  yield PathChooser.file(
306
- path=self._initial_value
307
- if self._initial_value_kind == _ValueKind.FILE
306
+ path=self._value
307
+ if self._value_kind == _ValueKind.FILE
308
308
  else None,
309
309
  id='value-file',
310
310
  )
@@ -323,8 +323,8 @@ class TextOrFileDynamicField(DynamicField):
323
323
  '#value-kind-file', RadioButton
324
324
  )
325
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)
326
+ self.key_input = self.query_one('#key', CustomInput)
327
+ self.value_text_input = self.query_one('#value-text', CustomInput)
328
328
  self.value_file_input = self.query_one('#value-file', PathChooser)
329
329
  self.remove_button = self.query_one('#remove', Button)
330
330
 
@@ -403,11 +403,11 @@ class TextOrFileDynamicField(DynamicField):
403
403
  elif message.value is False:
404
404
  self.post_message(message=self.Disabled(field=self))
405
405
 
406
- @on(Input.Changed, '#key')
407
- @on(Input.Changed, '#value-text')
406
+ @on(CustomInput.Changed, '#key')
407
+ @on(CustomInput.Changed, '#value-text')
408
408
  @on(PathChooser.Changed, '#value-file')
409
409
  def on_input_changed(
410
- self, message: Input.Changed | PathChooser.Changed
410
+ self, message: CustomInput.Changed | PathChooser.Changed
411
411
  ) -> None:
412
412
  self.enabled_switch.value = True
413
413
 
@@ -493,27 +493,41 @@ class DynamicFields(Widget):
493
493
  def filled_fields(self) -> list[DynamicField]:
494
494
  return [field for field in self.fields if field.is_filled]
495
495
 
496
- @property
497
- def values(self) -> list[dict[str, str | bool | Path | None]]:
498
- return [
499
- {
500
- 'enabled': field.enabled,
501
- 'key': field.key,
502
- 'value': field.value,
503
- }
504
- for field in self.fields
505
- ]
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:
496
+ async def add_field(
497
+ self, field: DynamicField, before_last: bool = False
498
+ ) -> None:
499
+ if before_last:
500
+ await self.fields_container.mount(field, before=self.fields[-1])
501
+ else:
502
+ await self.fields_container.mount(field)
503
+
504
+ def remove_field(
505
+ self, field: DynamicField, focus_neighbor: bool = False
506
+ ) -> None:
507
+ if len(self.fields) == 1:
508
+ self.app.bell()
509
+ return
510
+ elif field is self.fields[-1]:
511
+ self.app.bell()
512
+ return
513
+
514
+ if focus_neighbor:
515
+ field_index = self.fields.index(field)
516
+
517
+ neighbor_field = None
518
+ if field_index == 0:
519
+ neighbor_field = self.fields[field_index + 1]
520
+ else:
521
+ neighbor_field = self.fields[field_index - 1]
522
+
523
+ self.app.set_focus(neighbor_field.query_one(CustomInput))
524
+
511
525
  field.add_class('hidden')
512
526
  field.remove()
513
527
 
514
528
  @on(DynamicField.Empty)
515
529
  def _on_field_is_empty(self, message: DynamicField.Empty) -> None:
516
- self._focus_neighbor_field_then_remove(field=message.field)
530
+ self.remove_field(field=message.field, focus_neighbor=True)
517
531
  self.post_message(
518
532
  message=self.FieldEmpty(fields=self, field=message.field)
519
533
  )
@@ -544,23 +558,4 @@ class DynamicFields(Widget):
544
558
  def _on_field_remove_requested(
545
559
  self, message: DynamicField.RemoveRequested
546
560
  ) -> None:
547
- self._focus_neighbor_field_then_remove(field=message.field)
548
-
549
- def _focus_neighbor_field_then_remove(self, field: DynamicField) -> None:
550
- if len(self.fields) == 1:
551
- self.app.bell()
552
- return
553
- elif field is self.fields[-1]:
554
- self.app.bell()
555
- return
556
-
557
- field_index = self.fields.index(field)
558
-
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)
561
+ self.remove_field(field=message.field, focus_neighbor=True)