Skip to content

gole.widgets

gole.widgets 🔗

DirectoryTree 🔗

Bases: _DirectoryTree

Directory tree.

Source code in gole/widgets/directory_tree.py
class DirectoryTree(_DirectoryTree):
    """Directory tree."""

    show_root = False
    guide_depth = 2

    class DeletedPath(_DirectoryTree.FileSelected):
        """Posted when a path is deleted.

        Can be handled using `on_directory_tree_deleted_path` in a subclass of
        `DirectoryTree` or in a parent widget in the DOM.
        """

    class RenamedPath(_DirectoryTree.FileSelected):
        """Posted when a path is renamed.

        Can be handled using `on_directory_tree_renamed_path` in a subclass of
        `DirectoryTree` or in a parent widget in the DOM.
        """

        def __init__(
            self,
            node: TreeNode[DirEntry],
            old_path: AsyncPath,
            new_path: AsyncPath,
        ) -> None:
            super().__init__(node, new_path)
            self.old_path: AsyncPath = old_path

    BINDINGS = [
        Binding('a', 'new_file', 'file', tooltip='Creates a new file.'),
        Binding('A', 'new_dir', 'dir', tooltip='Creates a new directory.'),
        Binding(
            'f2',
            'rename',
            'rename',
            tooltip='Rename a file/directory.',
            key_display='F2',
        ),
        Binding(
            'd',
            'duplicate',
            'dulpicate',
            tooltip='Duplicate in a new file/directory.',
        ),
        Binding(
            'backspace',
            'delete',
            'del',
            tooltip='Deletes a new file/directory.',
        ),
        Binding('r', 'reload', 'reload', tooltip='Reload tree.'),
        Binding('ctrl+c', 'copy_path', 'Copy', tooltip='Copy the full path.'),
        Binding(
            'c', 'change_cwd', 'Change', tooltip='Change current workdir.'
        ),
    ]

    @property
    @override
    def app(self) -> 'Gole[None]':
        return super().app

    @work
    async def action_change_cwd(self):
        if (
            not (node := self.cursor_node)
            or not (data := node.data)
            or not (path := data.path).is_dir()
        ):
            if not (
                path := await self.app.push_screen_wait(
                    SelectDirectory(self.root.data.path)
                )
            ):
                self.notify('No directory selected', severity='error')
                return
        self.change_root(path)

    def change_root(self, path: Path):
        self.root = self._add_node(
            None, self.process_label(str(path)), DirEntry(self.PATH(path))
        )
        self.reload()

    async def action_copy_path(self):
        if not self.cursor_node or not self.cursor_node.data:
            return
        path = AsyncPath(self.cursor_node.data.path)
        full_path = await path.absolute()
        self.app.copy_to_clipboard(str(full_path))

    @work
    async def action_rename(self, path: str | None = None):
        if path:
            return await self._rename(path)

        location = AsyncPath(self.cursor_node.data.path)
        if await location.is_file():
            location = location.parent

        self.app.push_screen(
            FileSave(
                location,
                'Rename to ...',
                save_button='Rename',
                default_file=self.cursor_node.data.path,
            ),
            self._rename,
        )

    async def _rename(self, path: str | None = None):
        if not path:
            return

        current_path = AsyncPath(self.cursor_node.data.path)
        if not await current_path.exists():
            self.notify(
                f'Path [cyan]{current_path}[/] no exists.',
                severity='error',
            )
            return True

        if await (new_path := AsyncPath(path)).exists():
            self.notify(
                f'Path [cyan]{new_path}[/] already exists.',
                severity='error',
            )
            return True

        old_path = AsyncPath(self.cursor_node.data.path)
        await old_path.rename(path)

        self.notify(f'[yellow]{old_path}[/] renomead to [cyan]{new_path}[/].')
        self.reload()

        self.post_message(
            self.RenamedPath(self.cursor_node, old_path, new_path)
        )

    async def action_duplicate(self, path: str | None = None):
        if path:
            return await self._duplicate(path)

        location = AsyncPath(self.cursor_node.data.path)
        if await location.is_file():
            location = location.parent

        screen = FileOpen(
            location,
            'Duplicate to ...',
            open_button='Duplicate',
            must_exist=False,
            default_file=self.cursor_node.data.path,
        )
        self.app.push_screen(screen, self._duplicate)

    async def _duplicate(self, path: str | None = None):
        if not path:
            return

        current_path = AsyncPath(self.cursor_node.data.path)
        if not await current_path.exists():
            self.notify(
                f'Path [cyan]{current_path}[/] no exists.',
                severity='error',
            )
            return True

        if await (new_path := AsyncPath(path)).exists():
            self.notify(
                f'Path [cyan]{new_path}[/] already exists.',
                severity='error',
            )
            return True

        if await current_path.is_file():
            await new_path.write_text(await current_path.read_text())
        else:
            await asyncify(copytree)(current_path, new_path, symlinks=True)

        self.notify(
            f'Duplicate [cyan]{current_path}[/] -> [cyan]{new_path}[/]',
        )
        self.reload()

    async def action_delete(self):
        path = self.cursor_node.data.path
        screen = Confirm(
            'Delete',
            f'Do you want to delete [cyan]{path}[/] ?\n'
            '[$primary]This action cannot be reversed[/]',
        )
        self.app.push_screen(screen, self._delete)

    async def _delete(self, filepath: str | None = None):
        if not filepath:
            return
        filepath = self.cursor_node.data.path

        if not await (path := AsyncPath(filepath)).exists():
            self.notify(
                f'Path [cyan]{path}[/] no exists',
                severity='error',
            )
            return True
        if await path.is_dir():
            await asyncify(rmtree)(path)
        else:
            await path.unlink()

        self.notify(f'Deleted [cyan]{path}[/]')
        self.reload()

        self.post_message(self.DeletedPath(self.cursor_node, path))

    async def action_new_file(self):
        self.app.push_screen(
            FileSave(self.path, 'Create file ...', save_button='Create'),
            self._create,
        )

    async def action_new_dir(self):
        self.app.push_screen(
            FileSave(self.path, 'Create directory ...', save_button='Create'),
            partial(self._create, file=False),
        )

    async def _create(self, opened: str | None = None, file: bool = True):
        if not opened:
            return
        action = 'File' if file else 'Directory'

        if await (path := AsyncPath(opened)).exists():
            self.notify(f'{action} already exists', severity='error')
            return True

        if file:
            await path.touch(exist_ok=False)
            await self.app.board.action_add_text_pane(path)
        else:
            await path.mkdir(parents=True, exist_ok=False)

        self.reload()
        self.notify(f'{action} create with success')

    def action_reload(self):
        self.reload()

    async def _on_mount(self, event: Mount) -> None:
        if not self.app.settings.core.show_scroll:
            self.add_class('hide-scroll')

DeletedPath 🔗

Bases: _DirectoryTree.FileSelected

Posted when a path is deleted.

Can be handled using on_directory_tree_deleted_path in a subclass of DirectoryTree or in a parent widget in the DOM.

Source code in gole/widgets/directory_tree.py
class DeletedPath(_DirectoryTree.FileSelected):
    """Posted when a path is deleted.

    Can be handled using `on_directory_tree_deleted_path` in a subclass of
    `DirectoryTree` or in a parent widget in the DOM.
    """

RenamedPath 🔗

Bases: _DirectoryTree.FileSelected

Posted when a path is renamed.

Can be handled using on_directory_tree_renamed_path in a subclass of DirectoryTree or in a parent widget in the DOM.

Source code in gole/widgets/directory_tree.py
class RenamedPath(_DirectoryTree.FileSelected):
    """Posted when a path is renamed.

    Can be handled using `on_directory_tree_renamed_path` in a subclass of
    `DirectoryTree` or in a parent widget in the DOM.
    """

    def __init__(
        self,
        node: TreeNode[DirEntry],
        old_path: AsyncPath,
        new_path: AsyncPath,
    ) -> None:
        super().__init__(node, new_path)
        self.old_path: AsyncPath = old_path

TextArea 🔗

Bases: _TextArea

Source code in gole/widgets/text_area/area.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
class TextArea(_TextArea, inherit_bindings=False):
    BINDINGS = BINDINGS

    @dataclass
    class Saved(_TextArea.Changed):
        """Post message on save text area"""

    @property
    @override
    def app(self) -> 'Gole[None]':
        return super().app

    def __init__(
        self,
        text: str = '',
        language: str = 'markdown',
        path: AsyncPath | None = None,
        *,
        read_only: bool = False,
        line_number_start: int = 1,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
        disabled: bool = False,
        tooltip: RenderableType | None = None,
    ):
        super().__init__(
            text,
            language=language,
            theme=self.app.settings.theme.editor,
            soft_wrap=self.app.settings.editor.soft_wrap,
            tab_behavior=self.app.settings.editor.tab_behavior,
            show_line_numbers=self.app.settings.editor.show_line_numbers,
            max_checkpoints=self.app.settings.editor.max_checkpoints,
            read_only=read_only,
            line_number_start=line_number_start,
            name=name,
            id=id,
            classes=classes,
            disabled=disabled,
            tooltip=tooltip,
        )
        self.match_cursor_bracket = (
            self.app.settings.editor.match_cursor_bracket
        )
        self.cursor_blink = self.app.settings.editor.cursor_blink

        self.path: AsyncPath | None = path
        self.recorded_text: str = text

        self._languages = get_extra_languages()
        self.encoding = 'UTF-8'

    async def _on_mount(self, event: Mount) -> None:
        super()._on_mount(event)

        if not self.app.settings.core.show_scroll:
            self.add_class('hide-scroll')

        await self.load_path_text()

        self.auto_complete_setup = TextAutoComplete(
            self,
            self.auto_complete_candidates,
        )
        self.call_later(self.screen.mount, self.auto_complete_setup)

    async def load_path_text(self):
        if self.path:
            self.path = await self.path.resolve()
            if await self.path.exists():
                text = await self.path.read_bytes()

                encoding = detect(text).get('encoding') or self.encoding
                self.encoding = 'UTF-8' if encoding == 'ascii' else encoding

                self.text = text.decode(self.encoding)

        self.recorded_text = self.text
        await self.load_cache()

        self.post_message(self.Changed(self))

    def auto_complete_candidates(
        self, state: TargetState
    ) -> list[DropdownItem]:
        words: set[str] = set(
            wrd
            for word in filter(bool, self.text.split())
            if (wrd := re.sub(r'[^A-Z-a-z0-9].*', '', word))
        )

        current_word = self.word
        return [
            DropdownItem(word)
            for word in sorted(words)
            if word != current_word
        ]

    # Comment

    def _has_comment(self, text: str, template: str) -> bool:
        template_schema = template.split('{}')
        before, after = (
            template_schema
            if len(template_schema) == 2
            else (template_schema[0], '')
        )

        lines = [
            line.strip().startswith(before) and line.rstrip().endswith(after)
            for line in text.splitlines(keepends=True)
            if line.strip()
        ]
        return all(lines)

    def _uncomment_selection(self, line: str, template: str, depth: int):
        template_schema = template.split('{}')
        before, after = (
            template_schema
            if len(template_schema) == 2
            else (template_schema[0], '')
        )

        line = line.replace(before, '', 1)
        return line[::-1].replace(after[::-1], '', 1)[::-1]

    def _get_chars_before(self, word: str) -> tuple[str, str]:
        index = 0
        if words := word.strip().split():
            index = word.index(words[0][0])

        return word[:index], word[index:]

    def _get_depth(self, text: str) -> int:
        return min(
            len(chars[0])
            for line in text.splitlines(keepends=True)
            if (chars := self._get_chars_before(line)) and chars[1].strip()
        )

    def _comment_selection(self, line: str, template: str, depth: int):
        newline = self.document.newline

        before, line = line[:depth], line[depth:]
        template = before + template

        if line.endswith(newline):
            line = line.removesuffix(newline)
            template += newline

        return template.replace('{}', line)

    def _comment(self, text: str):
        template = (
            self.app.settings.language.model_dump()
            .get(self.language, {})
            .get('comment', '# {}')
        )

        commenter = (
            self._uncomment_selection
            if self._has_comment(text, template)
            else self._comment_selection
        )

        depth = self._get_depth(text)

        return ''.join(
            commenter(line, template, depth) if line.strip() else line
            for line in text.splitlines(keepends=True)
        )

    def action_comment_section(self):
        """Comment out selected section or current line."""
        start, end = sorted((self.selection.start, self.selection.end))
        start_line, _ = start
        end_line, _ = end

        if start == end:
            end_line = start_line
            end_column = len(self.get_line(start_line))
        else:
            end_column = len(self.get_line(end_line))

        tabs = []
        for line in range(start_line, end_line):
            tabs.append(self.wrapped_document.get_tab_widths(line))

        text = self.get_text_range((start_line, 0), (end_line, end_column))

        return self.edit(
            Edit(
                self._comment(text),
                (start_line, 0),
                (end_line, end_column),
                True,
            ),
        )

    def update_path(self, path: AsyncPath):
        """Update path, language and post the message `TextArea.Changed`."""
        self.path = path
        self.language = get_language(path.name)
        self.post_message(self.Changed(self))

    async def _on_key(self, event: Key) -> None:
        pairs = {
            '(': '()',
            '[': '[]',
            '{': '{}',
            '<': '<>',
            "'": "''",
            '"': '""',
            '´': '´´',
            '`': '``',
        }

        if (pair := pairs.get(event.character)) and (
            text := self.selected_text
        ):
            event.prevent_default()
            event.stop()
            self.replace(pair[0] + text + pair[1], *self.selection)
            return

        if (
            self.app.settings.editor.close_automatic_pairs
            and event.character
            and pair
        ):
            self.insert(pair)
            self.move_cursor_relative(columns=-1)
            event.prevent_default()
            event.stop()
            return

        self._restart_blink()
        if self.read_only:
            return

        key = event.key

        if event.is_printable or key in ['escape', 'enter', 'tab']:
            event.prevent_default()
            event.stop()

        if event.is_printable and event.character:
            return self._replace_via_keyboard(event.character, *self.selection)

        if self.auto_complete_setup.display:
            return

        if key == 'enter':
            return self.insert_newline()
        if key == 'tab':
            return self.insert_tab()

    def insert_newline(self):
        self._replace_via_keyboard(self.document.newline, *self.selection)

    def insert_tab(self):
        if self.indent_type == 'tabs':
            text = '\t'
        else:
            text = ' ' * self._find_columns_to_next_tab_stop()
        self._replace_via_keyboard(text, *self.selection)

    async def action_save(self):
        """Save file (create if not exists)."""
        if not await self.path.exists():
            if not await self.path.parent.exists():
                await self.path.parent.mkdir(parents=True)
            await self.path.touch()

        self.cleanup()

        await self.path.write_text(self.text)

        self.recorded_text = self.text
        self.post_message(self.Saved(self))

    def cleanup(self):
        text = self.text
        newline = self.document.newline
        replace = False

        if self.app.settings.editor.space_cleanup:
            text = newline.join(map(str.rstrip, text.splitlines()))
            replace = True
        if self.app.settings.editor.newline_end_file:
            text = text.rstrip() + newline
            replace = True

        if replace:
            self.replace(text, self.document.start, self.document.end)

    def action_copy(self) -> None:
        """Copy selection to clipboard."""
        if not (text := self.selected_text):
            text = self.document.get_line(self.cursor_location[0])
        self.app.copy_to_clipboard(text)

    def action_indent_section(self) -> None:
        """Indent line/selection."""
        if self.indent_type == 'tabs':
            indent = 1
            indent_value = '\t'
        else:
            indent = self.indent_width
            indent_value = ' ' * indent

        if selected_text := self.selected_text:
            # indent selection
            text = ''.join(
                indent_value + line if line.strip() else line
                for line in selected_text.splitlines(keepends=True)
            )
            self.replace(text, *self.selection)
        else:
            # indent line
            line, column = self.cursor_location
            self.insert(indent_value, (line, 0))
            self.selection = Selection.cursor((line, column + indent))

    def action_outdent_section(self) -> None:
        """Outdent line/selection."""
        if self.indent_type == 'tabs':
            indent = 1
            indent_value = '\t'
        else:
            indent = self.indent_width
            indent_value = ' ' * indent

        if selected_text := self.selected_text:
            # outdent selection
            text = selected_text
            text = ''.join(
                line.removeprefix(indent_value) if line.strip() else line
                for line in selected_text.splitlines(keepends=True)
            )
            self.replace(text, *self.selection)
        else:
            # outdent line
            line, column = self.cursor_location
            text = self.document[line]
            self.replace(
                text.removeprefix(indent_value),
                (line, 0),
                (line, len(text)),
                maintain_selection_offset=False,
            )
            self.selection = Selection.cursor((line, column - indent))

    def action_duplicate_section(self) -> None:
        """Duplicate selected section or current line."""
        if text := self.selected_text:
            return self._duplicate_selection(text)
        self._duplicate_line()

    def _duplicate_selection(self, text: str) -> None:
        location = (self.selection.end[0] + 1, 0)
        result = self.insert(text, location, maintain_selection_offset=False)

        self.selection = Selection(location, result.end_location)

    def _duplicate_line(self) -> None:
        line, column = self.cursor_location
        text = self.document[line] + self.document.newline

        location = (self.selection.end[0], 0)
        result = self.insert(text, location, maintain_selection_offset=False)

        self.selection = Selection.cursor((result.end_location[0], column))

    @property
    def unsaved(self) -> bool:
        return self.recorded_text != self.text

    async def get_cache(self) -> TextCache:
        if cache := await self.app.cache.TEXT_CACHE.get(
            doc_id=PathID(self.path)
        ):
            return cache

        language = get_language(self.path.name)
        config = self.app.settings.language.model_dump().get(language, {})
        indent_type = config.get('indent_type', 'spaces')
        indent_width = config.get('indent_width', 4)

        doc = {
            'indent_width': indent_width,
            'indent_type': indent_type,
            'language': language,
            'cursor': self.cursor_location,
            'history': dump_history(self.history),
        }
        return TextCache(doc, self.path)

    async def load_cache(self):
        cache = await self.get_cache()

        self.language = cache['language']

        line, column = cache['cursor']
        line_count = self.document.line_count - 1
        if line > line_count:
            line = line_count

        self.indent_type = cache['indent_type']
        self.indent_width = cache['indent_width']

        self.selection = Selection.cursor((line, column))
        self.history = load_history(cache['history'])

    async def update_cache(self):
        doc = {
            'indent_width': self.indent_width,
            'indent_type': self.indent_type,
            'language': self.language,
            'cursor': list(self.cursor_location),
            'history': dump_history(self.history),
        }
        await self.app.cache.TEXT_CACHE.upsert(TextCache(doc, self.path))

    @property
    def word(self) -> str:
        return self.get_text_range(
            self.get_cursor_word_left_location(), self.cursor_location
        )

Saved dataclass 🔗

Bases: _TextArea.Changed

Post message on save text area

Source code in gole/widgets/text_area/area.py
@dataclass
class Saved(_TextArea.Changed):
    """Post message on save text area"""

action_comment_section() 🔗

Comment out selected section or current line.

Source code in gole/widgets/text_area/area.py
def action_comment_section(self):
    """Comment out selected section or current line."""
    start, end = sorted((self.selection.start, self.selection.end))
    start_line, _ = start
    end_line, _ = end

    if start == end:
        end_line = start_line
        end_column = len(self.get_line(start_line))
    else:
        end_column = len(self.get_line(end_line))

    tabs = []
    for line in range(start_line, end_line):
        tabs.append(self.wrapped_document.get_tab_widths(line))

    text = self.get_text_range((start_line, 0), (end_line, end_column))

    return self.edit(
        Edit(
            self._comment(text),
            (start_line, 0),
            (end_line, end_column),
            True,
        ),
    )

update_path(path: AsyncPath) 🔗

Update path, language and post the message TextArea.Changed.

Source code in gole/widgets/text_area/area.py
def update_path(self, path: AsyncPath):
    """Update path, language and post the message `TextArea.Changed`."""
    self.path = path
    self.language = get_language(path.name)
    self.post_message(self.Changed(self))

action_save() async 🔗

Save file (create if not exists).

Source code in gole/widgets/text_area/area.py
async def action_save(self):
    """Save file (create if not exists)."""
    if not await self.path.exists():
        if not await self.path.parent.exists():
            await self.path.parent.mkdir(parents=True)
        await self.path.touch()

    self.cleanup()

    await self.path.write_text(self.text)

    self.recorded_text = self.text
    self.post_message(self.Saved(self))

action_copy() -> None 🔗

Copy selection to clipboard.

Source code in gole/widgets/text_area/area.py
def action_copy(self) -> None:
    """Copy selection to clipboard."""
    if not (text := self.selected_text):
        text = self.document.get_line(self.cursor_location[0])
    self.app.copy_to_clipboard(text)

action_indent_section() -> None 🔗

Indent line/selection.

Source code in gole/widgets/text_area/area.py
def action_indent_section(self) -> None:
    """Indent line/selection."""
    if self.indent_type == 'tabs':
        indent = 1
        indent_value = '\t'
    else:
        indent = self.indent_width
        indent_value = ' ' * indent

    if selected_text := self.selected_text:
        # indent selection
        text = ''.join(
            indent_value + line if line.strip() else line
            for line in selected_text.splitlines(keepends=True)
        )
        self.replace(text, *self.selection)
    else:
        # indent line
        line, column = self.cursor_location
        self.insert(indent_value, (line, 0))
        self.selection = Selection.cursor((line, column + indent))

action_outdent_section() -> None 🔗

Outdent line/selection.

Source code in gole/widgets/text_area/area.py
def action_outdent_section(self) -> None:
    """Outdent line/selection."""
    if self.indent_type == 'tabs':
        indent = 1
        indent_value = '\t'
    else:
        indent = self.indent_width
        indent_value = ' ' * indent

    if selected_text := self.selected_text:
        # outdent selection
        text = selected_text
        text = ''.join(
            line.removeprefix(indent_value) if line.strip() else line
            for line in selected_text.splitlines(keepends=True)
        )
        self.replace(text, *self.selection)
    else:
        # outdent line
        line, column = self.cursor_location
        text = self.document[line]
        self.replace(
            text.removeprefix(indent_value),
            (line, 0),
            (line, len(text)),
            maintain_selection_offset=False,
        )
        self.selection = Selection.cursor((line, column - indent))

action_duplicate_section() -> None 🔗

Duplicate selected section or current line.

Source code in gole/widgets/text_area/area.py
def action_duplicate_section(self) -> None:
    """Duplicate selected section or current line."""
    if text := self.selected_text:
        return self._duplicate_selection(text)
    self._duplicate_line()